Add config-driven game host updates

This commit is contained in:
2026-03-25 13:58:51 +08:00
parent f0ced54805
commit d1cc6cc473
28 changed files with 3247 additions and 105 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -9,7 +9,7 @@ import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibra
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
import { GameRuntime } from '../../game/core/gameRuntime'
import { type GameEffect } from '../../game/core/gameResult'
import { type GameEffect, type GameResult } from '../../game/core/gameResult'
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
@@ -170,6 +170,7 @@ export interface MapEngineViewState {
panelAccuracyUnitText: string
punchButtonText: string
punchButtonEnabled: boolean
skipButtonEnabled: boolean
punchHintText: string
punchFeedbackVisible: boolean
punchFeedbackText: string
@@ -194,6 +195,18 @@ export interface MapEngineCallbacks {
onData: (patch: Partial<MapEngineViewState>) => void
}
export interface MapEngineGameInfoRow {
label: string
value: string
}
export interface MapEngineGameInfoSnapshot {
title: string
subtitle: string
localRows: MapEngineGameInfoRow[]
globalRows: MapEngineGameInfoRow[]
}
const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'buildVersion',
'renderMode',
@@ -273,6 +286,7 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'panelAccuracyUnitText',
'punchButtonText',
'punchButtonEnabled',
'skipButtonEnabled',
'punchHintText',
'punchFeedbackVisible',
'punchFeedbackText',
@@ -338,6 +352,19 @@ function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: numb
return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
}
function formatGameSessionStatusText(status: 'idle' | 'running' | 'finished' | 'failed'): string {
if (status === 'running') {
return '进行中'
}
if (status === 'finished') {
return '已结束'
}
if (status === 'failed') {
return '已失败'
}
return '未开始'
}
function formatRotationText(rotationDeg: number): string {
return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
}
@@ -577,12 +604,21 @@ export class MapEngine {
courseData: OrienteeringCourseData | null
courseOverlayVisible: boolean
cpRadiusMeters: number
configAppId: string
configSchemaVersion: string
configVersion: string
controlScoreOverrides: Record<string, number>
defaultControlScore: number | null
gameRuntime: GameRuntime
telemetryRuntime: TelemetryRuntime
gamePresentation: GamePresentationState
gameMode: 'classic-sequential' | 'score-o'
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
requiresFocusSelection: boolean
skipEnabled: boolean
skipRadiusMeters: number
skipRequiresConfirm: boolean
autoFinishOnLastControl: boolean
punchFeedbackTimer: number
contentCardTimer: number
@@ -734,6 +770,11 @@ export class MapEngine {
this.courseData = null
this.courseOverlayVisible = false
this.cpRadiusMeters = 5
this.configAppId = ''
this.configSchemaVersion = '1'
this.configVersion = ''
this.controlScoreOverrides = {}
this.defaultControlScore = null
this.gameRuntime = new GameRuntime()
this.telemetryRuntime = new TelemetryRuntime()
this.telemetryRuntime.configure()
@@ -741,6 +782,10 @@ export class MapEngine {
this.gameMode = 'classic-sequential'
this.punchPolicy = 'enter-confirm'
this.punchRadiusMeters = 5
this.requiresFocusSelection = false
this.skipEnabled = false
this.skipRadiusMeters = 30
this.skipRequiresConfirm = true
this.autoFinishOnLastControl = true
this.punchFeedbackTimer = 0
this.contentCardTimer = 0
@@ -754,7 +799,7 @@ export class MapEngine {
projectionMode: PROJECTION_MODE,
mapReady: false,
mapReadyText: 'BOOTING',
mapName: 'LCX 测试地图',
mapName: '未命名配置',
configStatusText: '远程配置待加载',
zoom: DEFAULT_ZOOM,
rotationDeg: 0,
@@ -836,6 +881,7 @@ export class MapEngine {
gameSessionStatus: 'idle',
gameModeText: '顺序赛',
punchButtonEnabled: false,
skipButtonEnabled: false,
punchHintText: '等待进入检查点范围',
punchFeedbackVisible: false,
punchFeedbackText: '',
@@ -895,6 +941,68 @@ export class MapEngine {
return { ...this.state }
}
getGameInfoSnapshot(): MapEngineGameInfoSnapshot {
const definition = this.gameRuntime.definition
const sessionState = this.gameRuntime.state
const telemetryState = this.telemetryRuntime.state
const telemetryPresentation = this.telemetryRuntime.getPresentation()
const currentTarget = definition && sessionState
? definition.controls.find((control) => control.id === sessionState.currentTargetControlId) || null
: null
const currentTargetText = currentTarget
? `${currentTarget.label} / ${currentTarget.kind === 'start'
? '开始点'
: currentTarget.kind === 'finish'
? '结束点'
: '检查点'}`
: '--'
const title = this.state.mapName || (definition ? definition.title : '当前游戏')
const subtitle = `${this.getGameModeText()} / ${formatGameSessionStatusText(this.state.gameSessionStatus)}`
const localRows: MapEngineGameInfoRow[] = [
{ label: '比赛名称', value: title || '--' },
{ label: '配置版本', value: this.configVersion || '--' },
{ label: 'Schema版本', value: this.configSchemaVersion || '--' },
{ label: '活动ID', value: this.configAppId || '--' },
{ label: '地图', value: this.state.mapName || '--' },
{ label: '模式', value: this.getGameModeText() },
{ label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) },
{ label: '当前目标', value: currentTargetText },
{ label: '进度', value: this.gamePresentation.hud.progressText || '--' },
{ label: '当前积分', value: sessionState ? String(sessionState.score) : '0' },
{ label: '已完成点', value: sessionState ? String(sessionState.completedControlIds.length) : '0' },
{ label: '已跳过点', value: sessionState ? String(sessionState.skippedControlIds.length) : '0' },
{ label: '打点规则', value: `${this.punchPolicy} / ${this.punchRadiusMeters}m` },
{ label: '跳点规则', value: this.skipEnabled ? `${this.skipRadiusMeters}m / ${this.skipRequiresConfirm ? '确认跳过' : '直接跳过'}` : '关闭' },
{ label: '定位源', value: this.state.locationSourceText || '--' },
{ label: '当前位置', value: this.state.gpsCoordText || '--' },
{ label: 'GPS精度', value: telemetryState.lastGpsAccuracyMeters == null ? '--' : `${telemetryState.lastGpsAccuracyMeters.toFixed(1)}m` },
{ label: '目标距离', value: `${telemetryPresentation.distanceToTargetValueText}${telemetryPresentation.distanceToTargetUnitText}` || '--' },
{ label: '当前速度', value: `${telemetryPresentation.speedText} km/h` },
{ label: '心率源', value: this.state.heartRateSourceText || '--' },
{ label: '当前心率', value: this.state.panelHeartRateValueText === '--' ? '--' : `${this.state.panelHeartRateValueText}${this.state.panelHeartRateUnitText}` },
{ label: '心率设备', value: this.state.heartRateDeviceText || '--' },
{ label: '心率分区', value: this.state.panelHeartRateZoneNameText === '--' ? '--' : `${this.state.panelHeartRateZoneNameText} ${this.state.panelHeartRateZoneRangeText}` },
{ label: '本局用时', value: telemetryPresentation.timerText },
{ label: '累计里程', value: telemetryPresentation.mileageText },
{ label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` },
{ label: '提示状态', value: this.state.punchHintText || '--' },
]
const globalRows: MapEngineGameInfoRow[] = [
{ label: '全球积分', value: '未接入' },
{ label: '全球排名', value: '未接入' },
{ label: '在线人数', value: '未接入' },
{ label: '队伍状态', value: '未接入' },
{ label: '实时广播', value: '未接入' },
]
return {
title,
subtitle,
localRows,
globalRows,
}
}
destroy(): void {
this.clearInertiaTimer()
this.clearPreviewResetTimer()
@@ -948,6 +1056,12 @@ export class MapEngine {
this.setCourseHeading(null)
}
clearStartSessionResidue(): void {
this.currentGpsTrack = []
this.courseOverlayVisible = false
this.setCourseHeading(null)
}
handleClearMapTestArtifacts(): void {
this.clearFinishedTestOverlay()
this.setState({
@@ -963,6 +1077,29 @@ export class MapEngine {
return this.gamePresentation.hud.hudTargetControlId
}
isSkipAvailable(): boolean {
const definition = this.gameRuntime.definition
const state = this.gameRuntime.state
if (!definition || !state || state.status !== 'running' || !definition.skipEnabled) {
return false
}
const currentTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null
if (!currentTarget || currentTarget.kind !== 'control' || !this.currentGpsPoint) {
return false
}
const avgLatRad = ((currentTarget.point.lat + this.currentGpsPoint.lat) / 2) * Math.PI / 180
const dx = (this.currentGpsPoint.lon - currentTarget.point.lon) * 111320 * Math.cos(avgLatRad)
const dy = (this.currentGpsPoint.lat - currentTarget.point.lat) * 110540
const distanceMeters = Math.sqrt(dx * dx + dy * dy)
return distanceMeters <= definition.skipRadiusMeters
}
shouldConfirmSkipAction(): boolean {
return !!(this.gameRuntime.definition && this.gameRuntime.definition.skipRequiresConfirm)
}
getLocationControllerViewPatch(): Partial<MapEngineViewState> {
const debugState = this.locationController.getDebugState()
return {
@@ -993,10 +1130,10 @@ export class MapEngine {
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
}
loadGameDefinitionFromCourse(): GameEffect[] {
loadGameDefinitionFromCourse(): GameResult | null {
if (!this.courseData) {
this.clearGameRuntime()
return []
return null
}
const definition = buildGameDefinitionFromCourse(
@@ -1006,18 +1143,20 @@ export class MapEngine {
this.autoFinishOnLastControl,
this.punchPolicy,
this.punchRadiusMeters,
this.requiresFocusSelection,
this.skipEnabled,
this.skipRadiusMeters,
this.skipRequiresConfirm,
this.controlScoreOverrides,
this.defaultControlScore,
)
const result = this.gameRuntime.loadDefinition(definition)
this.telemetryRuntime.loadDefinition(definition)
this.gamePresentation = result.presentation
this.courseOverlayVisible = true
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, this.getHudTargetControlId())
this.refreshCourseHeadingFromPresentation()
this.syncGameResultState(result)
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, result.presentation.hud.hudTargetControlId)
this.updateSessionTimerLoop()
this.setState({
gameModeText: this.getGameModeText(),
})
return result.effects
return result
}
refreshCourseHeadingFromPresentation(): void {
@@ -1083,6 +1222,7 @@ export class MapEngine {
panelProgressText: this.gamePresentation.hud.progressText,
punchButtonText: this.gamePresentation.hud.punchButtonText,
punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
skipButtonEnabled: this.isSkipAvailable(),
punchHintText: this.gamePresentation.hud.punchHintText,
}
@@ -1121,6 +1261,28 @@ export class MapEngine {
}
}
resetTransientGameUiState(): void {
this.clearPunchFeedbackTimer()
this.clearContentCardTimer()
this.clearMapPulseTimer()
this.clearStageFxTimer()
this.setState({
punchFeedbackVisible: false,
punchFeedbackText: '',
punchFeedbackTone: 'neutral',
punchFeedbackFxClass: '',
contentCardVisible: false,
contentCardTitle: '',
contentCardBody: '',
contentCardFxClass: '',
mapPulseVisible: false,
mapPulseFxClass: '',
stageFxVisible: false,
stageFxClass: '',
punchButtonFxClass: '',
}, true)
}
clearSessionTimerInterval(): void {
if (this.sessionTimerInterval) {
clearInterval(this.sessionTimerInterval)
@@ -1300,6 +1462,33 @@ export class MapEngine {
return this.resolveGameStatusText(effects)
}
syncGameResultState(result: GameResult): void {
this.gamePresentation = result.presentation
this.refreshCourseHeadingFromPresentation()
}
resolveAppliedGameStatusText(result: GameResult, fallbackStatusText?: string | null): string | null {
return this.applyGameEffects(result.effects) || fallbackStatusText || this.resolveGameStatusText(result.effects)
}
commitGameResult(
result: GameResult,
fallbackStatusText?: string | null,
extraPatch: Partial<MapEngineViewState> = {},
syncRenderer = true,
): string | null {
this.syncGameResultState(result)
const gameStatusText = this.resolveAppliedGameStatusText(result, fallbackStatusText)
this.setState({
...this.getGameViewPatch(gameStatusText),
...extraPatch,
}, true)
if (syncRenderer) {
this.syncRenderer()
}
return gameStatusText
}
handleStartGame(): void {
if (!this.gameRuntime.definition || !this.gameRuntime.state) {
this.setState({
@@ -1312,6 +1501,10 @@ export class MapEngine {
return
}
this.feedbackDirector.reset()
this.resetTransientGameUiState()
this.clearStartSessionResidue()
if (!this.locationController.listening) {
this.locationController.start()
}
@@ -1328,15 +1521,30 @@ export class MapEngine {
})
}
this.gamePresentation = this.gameRuntime.getPresentation()
this.courseOverlayVisible = true
this.refreshCourseHeadingFromPresentation()
const defaultStatusText = this.currentGpsPoint
? `顺序打点已开始 (${this.buildVersion})`
: `顺序打点已开始GPS定位启动中 (${this.buildVersion})`
const gameStatusText = this.applyGameEffects(gameResult.effects) || defaultStatusText
this.commitGameResult(gameResult, defaultStatusText)
}
handleForceExitGame(): void {
this.feedbackDirector.reset()
if (!this.courseData) {
this.clearGameRuntime()
this.resetTransientGameUiState()
this.setState({
...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
}, true)
this.syncRenderer()
return
}
this.loadGameDefinitionFromCourse()
this.resetTransientGameUiState()
this.setState({
...this.getGameViewPatch(gameStatusText),
...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
}, true)
this.syncRenderer()
}
@@ -1347,13 +1555,7 @@ export class MapEngine {
type: 'punch_requested',
at: Date.now(),
})
this.gamePresentation = gameResult.presentation
this.refreshCourseHeadingFromPresentation()
const gameStatusText = this.applyGameEffects(gameResult.effects)
this.setState({
...this.getGameViewPatch(gameStatusText),
}, true)
this.syncRenderer()
this.commitGameResult(gameResult)
}
handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
@@ -1388,9 +1590,8 @@ export class MapEngine {
lat: latitude,
accuracyMeters,
})
this.gamePresentation = gameResult.presentation
this.refreshCourseHeadingFromPresentation()
gameStatusText = this.applyGameEffects(gameResult.effects)
this.syncGameResultState(gameResult)
gameStatusText = this.resolveAppliedGameStatusText(gameResult)
}
if (gpsInsideMap && !this.hasGpsCenteredOnce) {
@@ -1462,14 +1663,24 @@ export class MapEngine {
}
this.gameMode = nextMode
const effects = this.loadGameDefinitionFromCourse()
const result = this.loadGameDefinitionFromCourse()
const modeText = this.getGameModeText()
const statusText = this.applyGameEffects(effects) || `已切换到${modeText} (${this.buildVersion})`
this.setState({
...this.getGameViewPatch(statusText),
if (!result) {
return
}
this.commitGameResult(result, `已切换到${modeText} (${this.buildVersion})`, {
gameModeText: modeText,
}, true)
this.syncRenderer()
})
}
handleSkipAction(): void {
const gameResult = this.gameRuntime.dispatch({
type: 'skip_requested',
at: Date.now(),
lon: this.currentGpsPoint ? this.currentGpsPoint.lon : null,
lat: this.currentGpsPoint ? this.currentGpsPoint.lat : null,
})
this.commitGameResult(gameResult)
}
handleConnectHeartRate(): void {
@@ -1625,9 +1836,18 @@ export class MapEngine {
this.tileBoundsByZoom = config.tileBoundsByZoom
this.courseData = config.course
this.cpRadiusMeters = config.cpRadiusMeters
this.configAppId = config.configAppId
this.configSchemaVersion = config.configSchemaVersion
this.configVersion = config.configVersion
this.controlScoreOverrides = config.controlScoreOverrides
this.defaultControlScore = config.defaultControlScore
this.gameMode = config.gameMode
this.punchPolicy = config.punchPolicy
this.punchRadiusMeters = config.punchRadiusMeters
this.requiresFocusSelection = config.requiresFocusSelection
this.skipEnabled = config.skipEnabled
this.skipRadiusMeters = config.skipRadiusMeters
this.skipRequiresConfirm = config.skipRequiresConfirm
this.autoFinishOnLastControl = config.autoFinishOnLastControl
this.telemetryRuntime.configure(config.telemetryConfig)
this.feedbackDirector.configure({
@@ -1636,10 +1856,11 @@ export class MapEngine {
uiEffectsConfig: config.uiEffectsConfig,
})
const gameEffects = this.loadGameDefinitionFromCourse()
const gameStatusText = this.applyGameEffects(gameEffects)
const gameResult = this.loadGameDefinitionFromCourse()
const gameStatusText = gameResult ? this.resolveAppliedGameStatusText(gameResult) : null
const statePatch: Partial<MapEngineViewState> = {
configStatusText: `远程配置已载入 / ${config.courseStatusText}`,
mapName: config.configTitle,
configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`,
projectionMode: config.projectionModeText,
tileSource: config.tileSource,
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
@@ -1647,7 +1868,7 @@ export class MapEngine {
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
...this.getGameViewPatch(),
...this.getGameViewPatch(gameStatusText),
}
if (!this.state.stageWidth || !this.state.stageHeight) {
@@ -1869,12 +2090,10 @@ export class MapEngine {
at: Date.now(),
controlId: focusedControlId,
})
this.gamePresentation = gameResult.presentation
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId())
this.setState({
...this.getGameViewPatch(focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`),
}, true)
this.syncRenderer()
this.commitGameResult(
gameResult,
focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`,
)
}
findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
@@ -2472,6 +2691,8 @@ export class MapEngine {
activeLegIndices: this.gamePresentation.map.activeLegIndices,
completedLegIndices: this.gamePresentation.map.completedLegIndices,
completedControlSequences: this.gamePresentation.map.completedControlSequences,
skippedControlIds: this.gamePresentation.map.skippedControlIds,
skippedControlSequences: this.gamePresentation.map.skippedControlSequences,
osmReferenceEnabled: this.state.osmReferenceEnabled,
overlayOpacity: MAP_OVERLAY_OPACITY,
}

View File

@@ -115,6 +115,10 @@ export class CourseLabelRenderer {
return COMPLETED_LABEL_COLOR
}
if (scene.skippedControlSequences.includes(sequence)) {
return COMPLETED_LABEL_COLOR
}
return DEFAULT_LABEL_COLOR
}
@@ -127,6 +131,10 @@ export class CourseLabelRenderer {
return SCORE_COMPLETED_LABEL_COLOR
}
if (scene.skippedControlSequences.includes(sequence)) {
return SCORE_COMPLETED_LABEL_COLOR
}
return SCORE_LABEL_COLOR
}

View File

@@ -45,6 +45,8 @@ export interface MapScene {
activeLegIndices: number[]
completedLegIndices: number[]
completedControlSequences: number[]
skippedControlIds: string[]
skippedControlSequences: number[]
osmReferenceEnabled: boolean
overlayOpacity: number
}

View File

@@ -346,6 +346,10 @@ export class WebGLVectorRenderer {
return scene.completedLegIndices.includes(index)
}
isSkippedControl(scene: MapScene, sequence: number): boolean {
return scene.skippedControlSequences.includes(sequence)
}
pushCourseLeg(
positions: number[],
colors: number[],
@@ -462,7 +466,7 @@ export class WebGLVectorRenderer {
return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR
}
if (scene.completedControlSequences.includes(sequence)) {
if (scene.completedControlSequences.includes(sequence) || this.isSkippedControl(scene, sequence)) {
return COMPLETED_ROUTE_COLOR
}

View File

@@ -20,6 +20,12 @@ export function buildGameDefinitionFromCourse(
autoFinishOnLastControl = true,
punchPolicy: PunchPolicyType = 'enter-confirm',
punchRadiusMeters = 5,
requiresFocusSelection = false,
skipEnabled = false,
skipRadiusMeters = 30,
skipRequiresConfirm = true,
controlScoreOverrides: Record<string, number> = {},
defaultControlScore: number | null = null,
): GameDefinition {
const controls: GameControl[] = []
@@ -31,22 +37,28 @@ export function buildGameDefinitionFromCourse(
kind: 'start',
point: start.point,
sequence: null,
score: null,
displayContent: null,
})
}
for (const control of sortBySequence(course.layers.controls)) {
const label = control.label || String(control.sequence)
const controlId = `control-${control.sequence}`
const score = controlId in controlScoreOverrides
? controlScoreOverrides[controlId]
: defaultControlScore
controls.push({
id: `control-${control.sequence}`,
id: controlId,
code: label,
label,
kind: 'control',
point: control.point,
sequence: control.sequence,
score,
displayContent: {
title: `收集 ${label}`,
body: buildDisplayBody(label, control.sequence),
title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}` : buildDisplayBody(label, control.sequence),
},
})
}
@@ -59,6 +71,7 @@ export function buildGameDefinitionFromCourse(
kind: 'finish',
point: finish.point,
sequence: null,
score: null,
displayContent: null,
})
}
@@ -70,6 +83,10 @@ export function buildGameDefinitionFromCourse(
controlRadiusMeters,
punchRadiusMeters,
punchPolicy,
requiresFocusSelection,
skipEnabled,
skipRadiusMeters,
skipRequiresConfirm,
controls,
autoFinishOnLastControl,
}

View File

@@ -17,6 +17,7 @@ export interface GameControl {
kind: GameControlKind
point: LonLatPoint
sequence: number | null
score: number | null
displayContent: GameControlDisplayContent | null
}
@@ -27,6 +28,10 @@ export interface GameDefinition {
controlRadiusMeters: number
punchRadiusMeters: number
punchPolicy: PunchPolicyType
requiresFocusSelection: boolean
skipEnabled: boolean
skipRadiusMeters: number
skipRequiresConfirm: boolean
controls: GameControl[]
autoFinishOnLastControl: boolean
audioConfig?: GameAudioConfig

View File

@@ -2,5 +2,6 @@ export type GameEvent =
| { type: 'session_started'; at: number }
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
| { type: 'punch_requested'; at: number }
| { type: 'skip_requested'; at: number; lon: number | null; lat: number | null }
| { type: 'control_focused'; at: number; controlId: string | null }
| { type: 'session_ended'; at: number }

View File

@@ -65,6 +65,7 @@ export class GameRuntime {
startedAt: null,
endedAt: null,
completedControlIds: [],
skippedControlIds: [],
currentTargetControlId: null,
inRangeControlId: null,
score: 0,

View File

@@ -7,6 +7,7 @@ export interface GameSessionState {
startedAt: number | null
endedAt: number | null
completedControlIds: string[]
skippedControlIds: string[]
currentTargetControlId: string | null
inRangeControlId: string | null
score: number

View File

@@ -39,6 +39,10 @@ export class FeedbackDirector {
this.uiEffectDirector.configure(config.uiEffectsConfig || DEFAULT_GAME_UI_EFFECTS_CONFIG)
}
reset(): void {
this.soundDirector.resetContexts()
}
destroy(): void {
this.soundDirector.destroy()
this.hapticsDirector.destroy()

View File

@@ -17,6 +17,8 @@ export interface MapPresentationState {
completedLegIndices: number[]
completedControlIds: string[]
completedControlSequences: number[]
skippedControlIds: string[]
skippedControlSequences: number[]
}
export const EMPTY_MAP_PRESENTATION_STATE: MapPresentationState = {
@@ -38,4 +40,6 @@ export const EMPTY_MAP_PRESENTATION_STATE: MapPresentationState = {
completedLegIndices: [],
completedControlIds: [],
completedControlSequences: [],
skippedControlIds: [],
skippedControlSequences: [],
}

View File

@@ -35,10 +35,24 @@ function getCompletedControlSequences(definition: GameDefinition, state: GameSes
.map((control) => control.sequence as number)
}
function getSkippedControlSequences(definition: GameDefinition, state: GameSessionState): number[] {
return getScoringControls(definition)
.filter((control) => state.skippedControlIds.includes(control.id) && typeof control.sequence === 'number')
.map((control) => control.sequence as number)
}
function getCurrentTarget(definition: GameDefinition, state: GameSessionState): GameControl | null {
return getSequentialTargets(definition).find((control) => control.id === state.currentTargetControlId) || null
}
function getNextTarget(definition: GameDefinition, currentTarget: GameControl): GameControl | null {
const targets = getSequentialTargets(definition)
const currentIndex = targets.findIndex((control) => control.id === currentTarget.id)
return currentIndex >= 0 && currentIndex < targets.length - 1
? targets[currentIndex + 1]
: null
}
function getCompletedLegIndices(definition: GameDefinition, state: GameSessionState): number[] {
const targets = getSequentialTargets(definition)
const completedLegIndices: number[] = []
@@ -115,6 +129,43 @@ function buildPunchHintText(definition: GameDefinition, state: GameSessionState,
: `${targetText}内,可点击打点`
}
function buildSkipFeedbackText(currentTarget: GameControl): string {
if (currentTarget.kind === 'start') {
return '开始点不可跳过'
}
if (currentTarget.kind === 'finish') {
return '终点不可跳过'
}
return `已跳过检查点 ${typeof currentTarget.sequence === 'number' ? currentTarget.sequence : currentTarget.label}`
}
function resolveGuidanceForTarget(
definition: GameDefinition,
previousState: GameSessionState,
target: GameControl | null,
location: LonLatPoint | null,
): { guidanceState: GameSessionState['guidanceState']; inRangeControlId: string | null; effects: GameEffect[] } {
if (!target || !location) {
const guidanceState: GameSessionState['guidanceState'] = 'searching'
return {
guidanceState,
inRangeControlId: null,
effects: getGuidanceEffects(previousState.guidanceState, guidanceState, target ? target.id : null),
}
}
const distanceMeters = getApproxDistanceMeters(target.point, location)
const guidanceState = getGuidanceState(definition, distanceMeters)
const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? target.id : null
return {
guidanceState,
inRangeControlId,
effects: getGuidanceEffects(previousState.guidanceState, guidanceState, target.id),
}
}
function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
const scoringControls = getScoringControls(definition)
const sequentialTargets = getSequentialTargets(definition)
@@ -168,6 +219,8 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
revealFullCourse,
activeLegIndices,
completedLegIndices,
skippedControlIds: [],
skippedControlSequences: [],
},
hud: hudPresentation,
}
@@ -192,6 +245,8 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
completedLegIndices,
completedControlIds: completedControls.map((control) => control.id),
completedControlSequences: getCompletedControlSequences(definition, state),
skippedControlIds: state.skippedControlIds,
skippedControlSequences: getSkippedControlSequences(definition, state),
}
return {
@@ -265,20 +320,19 @@ function buildCompletedEffect(control: GameControl): GameEffect {
}
function applyCompletion(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl, at: number): GameResult {
const targets = getSequentialTargets(definition)
const currentIndex = targets.findIndex((control) => control.id === currentTarget.id)
const completedControlIds = state.completedControlIds.includes(currentTarget.id)
? state.completedControlIds
: [...state.completedControlIds, currentTarget.id]
const nextTarget = currentIndex >= 0 && currentIndex < targets.length - 1
? targets[currentIndex + 1]
: null
const nextTarget = getNextTarget(definition, currentTarget)
const completedFinish = currentTarget.kind === 'finish'
const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl)
const nextState: GameSessionState = {
...state,
startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt,
completedControlIds,
skippedControlIds: currentTarget.id === state.currentTargetControlId
? state.skippedControlIds.filter((controlId) => controlId !== currentTarget.id)
: state.skippedControlIds,
currentTargetControlId: nextTarget ? nextTarget.id : null,
inRangeControlId: null,
score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
@@ -303,6 +357,39 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
}
}
function applySkip(
definition: GameDefinition,
state: GameSessionState,
currentTarget: GameControl,
location: LonLatPoint | null,
): GameResult {
const nextTarget = getNextTarget(definition, currentTarget)
const nextPhase = nextTarget && nextTarget.kind === 'finish' ? 'finish' : 'course'
const nextGuidance = resolveGuidanceForTarget(definition, state, nextTarget, location)
const nextState: GameSessionState = {
...state,
skippedControlIds: state.skippedControlIds.includes(currentTarget.id)
? state.skippedControlIds
: [...state.skippedControlIds, currentTarget.id],
currentTargetControlId: nextTarget ? nextTarget.id : null,
inRangeControlId: nextGuidance.inRangeControlId,
guidanceState: nextGuidance.guidanceState,
modeState: {
mode: 'classic-sequential',
phase: nextTarget ? nextPhase : 'done',
},
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [
...nextGuidance.effects,
{ type: 'punch_feedback', text: buildSkipFeedbackText(currentTarget), tone: 'neutral' },
],
}
}
export class ClassicSequentialRule implements RulePlugin {
get mode(): 'classic-sequential' {
return 'classic-sequential'
@@ -314,6 +401,7 @@ export class ClassicSequentialRule implements RulePlugin {
startedAt: null,
endedAt: null,
completedControlIds: [],
skippedControlIds: [],
currentTargetControlId: getInitialTargetId(definition),
inRangeControlId: null,
score: 0,
@@ -423,6 +511,48 @@ export class ClassicSequentialRule implements RulePlugin {
return applyCompletion(definition, state, currentTarget, event.at)
}
if (event.type === 'skip_requested') {
if (!definition.skipEnabled) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: '当前配置未开启跳点', tone: 'warning' }],
}
}
if (currentTarget.kind !== 'control') {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: currentTarget.kind === 'start' ? '开始点不可跳过' : '终点不可跳过', tone: 'warning' }],
}
}
if (event.lon === null || event.lat === null) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: '当前无定位,无法跳点', tone: 'warning' }],
}
}
const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
if (distanceMeters > definition.skipRadiusMeters) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: `未进入跳点范围 (${Math.round(definition.skipRadiusMeters)}m)`, tone: 'warning' }],
}
}
return applySkip(
definition,
state,
currentTarget,
{ lon: event.lon, lat: event.lat },
)
}
return {
nextState: state,
presentation: buildPresentation(definition, state),

View File

@@ -33,6 +33,10 @@ function getScoreControls(definition: GameDefinition): GameControl[] {
return definition.controls.filter((control) => control.kind === 'control')
}
function getControlScore(control: GameControl): number {
return control.kind === 'control' && typeof control.score === 'number' ? control.score : 0
}
function getRemainingScoreControls(definition: GameDefinition, state: GameSessionState): GameControl[] {
return getScoreControls(definition).filter((control) => !state.completedControlIds.includes(control.id))
}
@@ -112,6 +116,46 @@ function getFocusedTarget(
return null
}
function resolveInteractiveTarget(
definition: GameDefinition,
modeState: ScoreOModeState,
primaryTarget: GameControl | null,
focusedTarget: GameControl | null,
): GameControl | null {
if (modeState.phase === 'start') {
return primaryTarget
}
if (definition.requiresFocusSelection) {
return focusedTarget
}
return focusedTarget || primaryTarget
}
function getNearestInRangeControl(
controls: GameControl[],
referencePoint: LonLatPoint,
radiusMeters: number,
): GameControl | null {
let nearest: GameControl | null = null
let nearestDistance = Number.POSITIVE_INFINITY
for (const control of controls) {
const distance = getApproxDistanceMeters(control.point, referencePoint)
if (distance > radiusMeters) {
continue
}
if (!nearest || distance < nearestDistance) {
nearest = control
nearestDistance = distance
}
}
return nearest
}
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
if (distanceMeters <= definition.punchRadiusMeters) {
return 'ready'
@@ -166,14 +210,15 @@ function buildPunchHintText(
const modeState = getModeState(state)
if (modeState.phase === 'controls' || modeState.phase === 'finish') {
if (!focusedTarget) {
if (definition.requiresFocusSelection && !focusedTarget) {
return modeState.phase === 'finish'
? '点击地图选中终点后结束比赛'
: '点击地图选中一个目标点'
}
const targetLabel = getDisplayTargetLabel(focusedTarget)
if (state.inRangeControlId === focusedTarget.id) {
const displayTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
const targetLabel = getDisplayTargetLabel(displayTarget)
if (displayTarget && state.inRangeControlId === displayTarget.id) {
return definition.punchPolicy === 'enter'
? `${targetLabel}内,自动打点中`
: `${targetLabel}内,可点击打点`
@@ -258,7 +303,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
.filter((control) => typeof control.sequence === 'number')
.map((control) => control.sequence as number)
const revealFullCourse = completedStart
const interactiveTarget = modeState.phase === 'start' ? primaryTarget : focusedTarget
const interactiveTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
const punchButtonEnabled = running
&& definition.punchPolicy === 'enter-confirm'
&& !!interactiveTarget
@@ -287,6 +332,8 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
completedLegIndices: [],
completedControlIds: completedControls.map((control) => control.id),
completedControlSequences,
skippedControlIds: [],
skippedControlSequences: [],
}
const hudPresentation: HudPresentationState = {
@@ -348,7 +395,9 @@ function applyCompletion(
completedControlIds,
currentTargetControlId: null,
inRangeControlId: null,
score: getScoreControls(definition).filter((item) => completedControlIds.includes(item.id)).length,
score: getScoreControls(definition)
.filter((item) => completedControlIds.includes(item.id))
.reduce((sum, item) => sum + getControlScore(item), 0),
status: control.kind === 'finish' ? 'finished' : state.status,
guidanceState: 'searching',
}
@@ -401,6 +450,7 @@ export class ScoreORule implements RulePlugin {
startedAt: null,
endedAt: null,
completedControlIds: [],
skippedControlIds: [],
currentTargetControlId: startControl ? startControl.id : null,
inRangeControlId: null,
score: 0,
@@ -481,12 +531,16 @@ export class ScoreORule implements RulePlugin {
guidanceTarget = focusedTarget || nextPrimaryTarget
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
punchTarget = focusedTarget
} else if (!definition.requiresFocusSelection) {
punchTarget = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
}
} else if (modeState.phase === 'finish') {
nextPrimaryTarget = getFinishControl(definition)
guidanceTarget = focusedTarget || nextPrimaryTarget
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
punchTarget = focusedTarget
} else if (!definition.requiresFocusSelection && nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) {
punchTarget = nextPrimaryTarget
}
} else if (targetControl) {
guidanceTarget = targetControl
@@ -556,7 +610,7 @@ export class ScoreORule implements RulePlugin {
if (event.type === 'punch_requested') {
const focusedTarget = getFocusedTarget(definition, state)
if ((modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
if (definition.requiresFocusSelection && (modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
@@ -569,7 +623,7 @@ export class ScoreORule implements RulePlugin {
controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
}
if (!controlToPunch || (focusedTarget && controlToPunch.id !== focusedTarget.id)) {
if (!controlToPunch || (definition.requiresFocusSelection && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
return {
nextState: state,
presentation: buildPresentation(definition, state),

View File

@@ -1,4 +1,10 @@
import { MapEngine, type MapEngineStageRect, type MapEngineViewState } from '../../engine/map/mapEngine'
import {
MapEngine,
type MapEngineGameInfoRow,
type MapEngineGameInfoSnapshot,
type MapEngineStageRect,
type MapEngineViewState,
} from '../../engine/map/mapEngine'
import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
type CompassTickData = {
angle: number
@@ -13,13 +19,20 @@ type CompassLabelData = {
className: string
}
type SideButtonMode = 'all' | 'left' | 'right' | 'hidden'
type SideActionButtonState = 'muted' | 'default' | 'active'
type MapPageData = MapEngineViewState & {
showDebugPanel: boolean
showGameInfoPanel: boolean
statusBarHeight: number
topInsetHeight: number
hudPanelIndex: number
configSourceText: string
mockBridgeUrlDraft: string
mockHeartRateBridgeUrlDraft: string
gameInfoTitle: string
gameInfoSubtitle: string
gameInfoLocalRows: MapEngineGameInfoRow[]
gameInfoGlobalRows: MapEngineGameInfoRow[]
panelTimerText: string
panelMileageText: string
panelDistanceValueText: string
@@ -28,12 +41,17 @@ type MapPageData = MapEngineViewState & {
compassTicks: CompassTickData[]
compassLabels: CompassLabelData[]
sideButtonMode: SideButtonMode
sideToggleIconSrc: string
sideButton4Class: string
sideButton11Class: string
sideButton16Class: string
showLeftButtonGroup: boolean
showRightButtonGroups: boolean
showBottomDebugButton: boolean
}
const INTERNAL_BUILD_VERSION = 'map-build-196'
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
const INTERNAL_BUILD_VERSION = 'map-build-207'
const CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json'
const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json'
let mapEngine: MapEngine | null = null
let stageCanvasAttached = false
function buildSideButtonVisibility(mode: SideButtonMode) {
@@ -93,12 +111,66 @@ function getFallbackStageRect(): MapEngineStageRect {
}
}
function getSideToggleIconSrc(mode: SideButtonMode): string {
if (mode === 'left') {
return '../../assets/btn_more2.png'
}
if (mode === 'hidden') {
return '../../assets/btn_more1.png'
}
return '../../assets/btn_more3.png'
}
function getSideActionButtonClass(state: SideActionButtonState): string {
if (state === 'muted') {
return 'map-side-button map-side-button--muted'
}
if (state === 'active') {
return 'map-side-button map-side-button--active'
}
return 'map-side-button map-side-button--default'
}
function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGameInfoPanel' | 'skipButtonEnabled' | 'gameSessionStatus'>) {
const sideButton4State: SideActionButtonState = data.gameSessionStatus === 'idle' ? 'default' : 'active'
const sideButton11State: SideActionButtonState = data.showGameInfoPanel ? 'active' : 'default'
const sideButton16State: SideActionButtonState = data.skipButtonEnabled ? 'default' : 'muted'
return {
sideToggleIconSrc: getSideToggleIconSrc(data.sideButtonMode),
sideButton4Class: getSideActionButtonClass(sideButton4State),
sideButton11Class: getSideActionButtonClass(sideButton11State),
sideButton16Class: getSideActionButtonClass(sideButton16State),
}
}
function buildEmptyGameInfoSnapshot(): MapEngineGameInfoSnapshot {
return {
title: '当前游戏',
subtitle: '未开始',
localRows: [],
globalRows: [
{ label: '全球积分', value: '未接入' },
{ label: '全球排名', value: '未接入' },
{ label: '在线人数', value: '未接入' },
{ label: '队伍状态', value: '未接入' },
{ label: '实时广播', value: '未接入' },
],
}
}
Page({
data: {
showDebugPanel: false,
showGameInfoPanel: false,
statusBarHeight: 0,
topInsetHeight: 12,
hudPanelIndex: 0,
configSourceText: '顺序赛配置',
gameInfoTitle: '当前游戏',
gameInfoSubtitle: '未开始',
gameInfoLocalRows: [],
gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
panelTimerText: '00:00:00',
panelMileageText: '0m',
panelActionTagText: '目标',
@@ -142,6 +214,7 @@ Page({
panelAccuracyUnitText: '',
punchButtonText: '打点',
punchButtonEnabled: false,
skipButtonEnabled: false,
punchHintText: '等待进入检查点范围',
punchFeedbackVisible: false,
punchFeedbackText: '',
@@ -161,6 +234,12 @@ Page({
compassTicks: buildCompassTicks(),
compassLabels: buildCompassLabels(),
...buildSideButtonVisibility('left'),
...buildSideButtonState({
sideButtonMode: 'left',
showGameInfoPanel: false,
skipButtonEnabled: false,
gameSessionStatus: 'idle',
}),
} as unknown as MapPageData,
onLoad() {
@@ -190,16 +269,34 @@ Page({
nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText
}
this.setData(nextData)
const mergedData = {
...this.data,
...nextData,
} as MapPageData
this.setData({
...nextData,
...buildSideButtonState(mergedData),
})
if (this.data.showGameInfoPanel) {
this.syncGameInfoPanelSnapshot()
}
},
})
this.setData({
...mapEngine.getInitialData(),
showDebugPanel: false,
showGameInfoPanel: false,
statusBarHeight,
topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
hudPanelIndex: 0,
configSourceText: '顺序赛配置',
gameInfoTitle: '当前游戏',
gameInfoSubtitle: '未开始',
gameInfoLocalRows: [],
gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
panelTimerText: '00:00:00',
panelMileageText: '0m',
panelActionTagText: '目标',
@@ -241,6 +338,7 @@ Page({
panelAccuracyUnitText: '',
punchButtonText: '打点',
punchButtonEnabled: false,
skipButtonEnabled: false,
punchHintText: '等待进入检查点范围',
punchFeedbackVisible: false,
punchFeedbackText: '',
@@ -260,13 +358,19 @@ Page({
compassTicks: buildCompassTicks(),
compassLabels: buildCompassLabels(),
...buildSideButtonVisibility('left'),
...buildSideButtonState({
sideButtonMode: 'left',
showGameInfoPanel: false,
skipButtonEnabled: false,
gameSessionStatus: 'idle',
}),
})
},
onReady() {
stageCanvasAttached = false
this.measureStageAndCanvas()
this.loadMapConfigFromRemote()
this.loadMapConfigFromRemote(CLASSIC_REMOTE_GAME_CONFIG_URL, '顺序赛配置')
},
onShow() {
@@ -289,13 +393,18 @@ Page({
stageCanvasAttached = false
},
loadMapConfigFromRemote() {
loadMapConfigFromRemote(configUrl: string, configLabel: string) {
const currentEngine = mapEngine
if (!currentEngine) {
return
}
loadRemoteMapConfig(REMOTE_GAME_CONFIG_URL)
this.setData({
configSourceText: configLabel,
configStatusText: `加载中: ${configLabel}`,
})
loadRemoteMapConfig(configUrl)
.then((config) => {
if (mapEngine !== currentEngine) {
return
@@ -605,16 +714,41 @@ Page({
}
},
handleSetClassicMode() {
handleLoadClassicConfig() {
this.loadMapConfigFromRemote(CLASSIC_REMOTE_GAME_CONFIG_URL, '顺序赛配置')
},
handleLoadScoreOConfig() {
this.loadMapConfigFromRemote(SCORE_O_REMOTE_GAME_CONFIG_URL, '积分赛配置')
},
handleForceExitGame() {
if (mapEngine) {
mapEngine.handleSetGameMode('classic-sequential')
mapEngine.handleForceExitGame()
}
},
handleSetScoreOMode() {
if (mapEngine) {
mapEngine.handleSetGameMode('score-o')
handleSkipAction() {
if (!mapEngine || !this.data.skipButtonEnabled) {
return
}
if (!mapEngine.shouldConfirmSkipAction()) {
mapEngine.handleSkipAction()
return
}
wx.showModal({
title: '确认跳点',
content: '确认跳过当前检查点并切换到下一个目标点?',
confirmText: '确认跳过',
cancelText: '取消',
success: (result) => {
if (result.confirm && mapEngine) {
mapEngine.handleSkipAction()
}
},
})
},
handleClearMapTestArtifacts() {
@@ -623,6 +757,48 @@ Page({
}
},
syncGameInfoPanelSnapshot() {
if (!mapEngine) {
return
}
const snapshot = mapEngine.getGameInfoSnapshot()
this.setData({
gameInfoTitle: snapshot.title,
gameInfoSubtitle: snapshot.subtitle,
gameInfoLocalRows: snapshot.localRows,
gameInfoGlobalRows: snapshot.globalRows,
})
},
handleOpenGameInfoPanel() {
this.syncGameInfoPanelSnapshot()
this.setData({
showDebugPanel: false,
showGameInfoPanel: true,
...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: true,
skipButtonEnabled: this.data.skipButtonEnabled,
gameSessionStatus: this.data.gameSessionStatus,
}),
})
},
handleCloseGameInfoPanel() {
this.setData({
showGameInfoPanel: false,
...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: false,
skipButtonEnabled: this.data.skipButtonEnabled,
gameSessionStatus: this.data.gameSessionStatus,
}),
})
},
handleGameInfoPanelTap() {},
handleOverlayTouch() {},
handlePunchAction() {
@@ -648,7 +824,16 @@ Page({
},
handleCycleSideButtons() {
this.setData(buildSideButtonVisibility(getNextSideButtonMode(this.data.sideButtonMode)))
const nextMode = getNextSideButtonMode(this.data.sideButtonMode)
this.setData({
...buildSideButtonVisibility(nextMode),
...buildSideButtonState({
sideButtonMode: nextMode,
showGameInfoPanel: this.data.showGameInfoPanel,
skipButtonEnabled: this.data.skipButtonEnabled,
gameSessionStatus: this.data.gameSessionStatus,
}),
})
},
handleToggleMapRotateMode() {
if (!mapEngine) {
@@ -665,12 +850,25 @@ Page({
handleToggleDebugPanel() {
this.setData({
showDebugPanel: !this.data.showDebugPanel,
showGameInfoPanel: false,
...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: false,
skipButtonEnabled: this.data.skipButtonEnabled,
gameSessionStatus: this.data.gameSessionStatus,
}),
})
},
handleCloseDebugPanel() {
this.setData({
showDebugPanel: false,
...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: this.data.showGameInfoPanel,
skipButtonEnabled: this.data.skipButtonEnabled,
gameSessionStatus: this.data.gameSessionStatus,
}),
})
},

View File

@@ -68,23 +68,21 @@
</view>
</view>
<cover-view class="map-side-toggle" wx:if="{{!showDebugPanel}}" style="top: {{topInsetHeight}}px;" bindtap="handleCycleSideButtons">
<cover-view class="map-side-toggle" wx:if="{{!showDebugPanel && !showGameInfoPanel}}" style="top: {{topInsetHeight}}px;" bindtap="handleCycleSideButtons">
<cover-view class="map-side-button map-side-button--icon">
<cover-image wx:if="{{sideButtonMode === 'left'}}" class="map-side-button__image" src="../../assets/btn_more2.png"></cover-image>
<cover-image wx:elif="{{sideButtonMode === 'hidden'}}" class="map-side-button__image" src="../../assets/btn_more1.png"></cover-image>
<cover-image wx:else class="map-side-button__image" src="../../assets/btn_more3.png"></cover-image>
<cover-image class="map-side-button__image" src="{{sideToggleIconSrc}}"></cover-image>
</cover-view>
</cover-view>
<cover-view class="map-side-column map-side-column--left map-side-column--left-group" wx:if="{{!showDebugPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
<cover-view class="map-side-column map-side-column--left map-side-column--left-group" wx:if="{{!showDebugPanel && !showGameInfoPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
<cover-view class="map-side-button map-side-button--icon" bindtap="handleToggleMapRotateMode"><cover-image class="map-side-button__rotate-image {{orientationMode === 'heading-up' ? 'map-side-button__rotate-image--active' : ''}}" src="../../assets/btn_map_rotate_cropped.png"></cover-image></cover-view>
<cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">1</cover-view></cover-view>
<cover-view class="map-side-button"><cover-view class="map-side-button__text">2</cover-view></cover-view>
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">3</cover-view></cover-view>
<cover-view class="map-side-button"><cover-view class="map-side-button__text">4</cover-view></cover-view>
<cover-view class="{{sideButton4Class}}" bindtap="handleForceExitGame"><cover-image class="map-side-button__action-image" src="../../assets/btn_exit.png"></cover-image></cover-view>
</cover-view>
<cover-view class="map-side-column map-side-column--right-main" wx:if="{{!showDebugPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
<cover-view class="map-side-column map-side-column--right-main" wx:if="{{!showDebugPanel && !showGameInfoPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
<cover-view class="map-side-button"><cover-view class="map-side-button__text">5</cover-view></cover-view>
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">6</cover-view></cover-view>
<cover-view class="map-side-button"><cover-view class="map-side-button__text">7</cover-view></cover-view>
@@ -93,24 +91,24 @@
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">10</cover-view></cover-view>
</cover-view>
<cover-view class="map-side-column map-side-column--right-sub" wx:if="{{!showDebugPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
<cover-view class="map-side-button"><cover-view class="map-side-button__text">11</cover-view></cover-view>
<cover-view class="map-side-column map-side-column--right-sub" wx:if="{{!showDebugPanel && !showGameInfoPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
<cover-view class="{{sideButton11Class}}" bindtap="handleOpenGameInfoPanel"><cover-image class="map-side-button__action-image" src="../../assets/btn_info.png"></cover-image></cover-view>
<cover-view class="map-side-button"><cover-view class="map-side-button__text">12</cover-view></cover-view>
<cover-view class="map-side-button"><cover-view class="map-side-button__text">13</cover-view></cover-view>
<cover-view class="map-side-button"><cover-view class="map-side-button__text">14</cover-view></cover-view>
<cover-view class="map-side-button"><cover-view class="map-side-button__text">15</cover-view></cover-view>
<cover-view class="map-side-button"><cover-view class="map-side-button__text">16</cover-view></cover-view>
<cover-view class="{{sideButton16Class}}" bindtap="handleSkipAction"><cover-image class="map-side-button__action-image" src="../../assets/btn_skip_cp.png"></cover-image></cover-view>
</cover-view>
<cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel}}" bindtap="handlePunchAction">
<cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel && !showGameInfoPanel}}" bindtap="handlePunchAction">
<cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view>
</cover-view>
<cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && showBottomDebugButton && gameSessionStatus === 'idle'}}" bindtap="handleStartGame">
<cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && showBottomDebugButton && gameSessionStatus === 'idle'}}" bindtap="handleStartGame">
<cover-view class="screen-button-layer__text screen-button-layer__text--start">开始</cover-view>
</cover-view>
<cover-view class="screen-button-layer screen-button-layer--bottom-left" wx:if="{{!showDebugPanel && showBottomDebugButton}}" bindtap="handleToggleDebugPanel">
<cover-view class="screen-button-layer screen-button-layer--bottom-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && showBottomDebugButton}}" bindtap="handleToggleDebugPanel">
<cover-view class="screen-button-layer__icon">
<cover-view class="screen-button-layer__line"></cover-view>
<cover-view class="screen-button-layer__stand"></cover-view>
@@ -118,7 +116,7 @@
<cover-view class="screen-button-layer__text">调试</cover-view>
</cover-view>
<swiper class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
<swiper wx:if="{{!showGameInfoPanel}}" class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
<swiper-item>
<view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
<view class="race-panel__tag race-panel__tag--top-left">{{panelActionTagText}}</view>
@@ -223,11 +221,50 @@
</view>
</swiper-item>
</swiper>
<view class="race-panel-pager" wx:if="{{!showDebugPanel}}">
<view class="race-panel-pager" wx:if="{{!showDebugPanel && !showGameInfoPanel}}">
<view class="race-panel-pager__dot {{hudPanelIndex === 0 ? 'race-panel-pager__dot--active' : ''}}"></view>
<view class="race-panel-pager__dot {{hudPanelIndex === 1 ? 'race-panel-pager__dot--active' : ''}}"></view>
</view>
<view class="game-info-modal" wx:if="{{showGameInfoPanel}}" bindtap="handleCloseGameInfoPanel">
<view class="game-info-modal__dialog" catchtap="handleGameInfoPanelTap">
<view class="game-info-modal__header">
<view class="game-info-modal__header-main">
<view class="game-info-modal__eyebrow">GAME INFO</view>
<view class="game-info-modal__title">{{gameInfoTitle}}</view>
<view class="game-info-modal__subtitle">{{gameInfoSubtitle}}</view>
</view>
<view class="game-info-modal__header-actions">
<view class="game-info-modal__close" bindtap="handleCloseGameInfoPanel">关闭</view>
</view>
</view>
<scroll-view class="game-info-modal__content" scroll-y enhanced show-scrollbar="true">
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__title">Local</view>
<view class="debug-section__desc">当前设备、本地玩法与实时运行状态</view>
</view>
<view class="info-panel__row" wx:for="{{gameInfoLocalRows}}" wx:key="label">
<text class="info-panel__label">{{item.label}}</text>
<text class="info-panel__value">{{item.value}}</text>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__title">Global</view>
<view class="debug-section__desc">联网后接入全局赛事数据,这里先占位</view>
</view>
<view class="info-panel__row" wx:for="{{gameInfoGlobalRows}}" wx:key="label">
<text class="info-panel__label">{{item.label}}</text>
<text class="info-panel__value">{{item.value}}</text>
</view>
</view>
</scroll-view>
</view>
</view>
<view class="debug-modal" wx:if="{{showDebugPanel}}" bindtap="handleCloseDebugPanel">
<view class="debug-modal__dialog" catchtap="handleDebugPanelTap">
<view class="debug-modal__header">
@@ -250,6 +287,10 @@
<text class="info-panel__label">Mode</text>
<text class="info-panel__value">{{gameModeText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Config</text>
<text class="info-panel__value">{{configSourceText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Game</text>
<text class="info-panel__value">{{gameSessionStatus}}</text>
@@ -267,8 +308,8 @@
<text class="info-panel__value">{{punchHintText}}</text>
</view>
<view class="control-row">
<view class="control-chip {{gameModeText === '顺序赛' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetClassicMode">顺序赛</view>
<view class="control-chip {{gameModeText === '积分赛' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetScoreOMode">积分赛</view>
<view class="control-chip {{configSourceText === '顺序赛配置' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleLoadClassicConfig">顺序赛配置</view>
<view class="control-chip {{configSourceText === '积分赛配置' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleLoadScoreOConfig">积分赛配置</view>
</view>
<view class="control-row">
<view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>

View File

@@ -299,6 +299,10 @@
box-sizing: border-box;
}
.map-side-button--default {
background: rgba(248, 251, 244, 0.96);
}
.map-side-button--icon {
width: 90rpx;
height: 90rpx;
@@ -327,6 +331,16 @@
background: rgba(229, 233, 230, 0.92);
}
.map-side-button--muted .map-side-button__action-image {
opacity: 0.46;
filter: grayscale(1);
}
.map-side-button--active {
background: rgba(255, 226, 88, 0.98);
box-shadow: 0 0 0 4rpx rgba(255, 241, 158, 0.18), 0 12rpx 28rpx rgba(120, 89, 0, 0.2);
}
.map-side-button__text {
font-size: 18rpx;
line-height: 1.1;
@@ -336,6 +350,16 @@
letter-spacing: 1rpx;
}
.map-side-button__action-image {
width: 100%;
height: 100%;
border-radius: 22rpx;
}
.map-side-button--active .map-side-button__action-image {
opacity: 1;
}
.compass-widget {
display: flex;
flex-direction: column;
@@ -1019,6 +1043,92 @@
.race-panel__chevron--offset {
right: 0;
}
.game-info-modal {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0 20rpx 28rpx;
box-sizing: border-box;
background: rgba(7, 18, 12, 0.34);
z-index: 31;
}
.game-info-modal__dialog {
width: 100%;
max-height: 72vh;
border-radius: 36rpx;
background: rgba(248, 251, 244, 0.98);
box-shadow: 0 20rpx 60rpx rgba(7, 18, 12, 0.24);
overflow: hidden;
}
.game-info-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
padding: 22rpx 28rpx 18rpx;
border-bottom: 1rpx solid rgba(22, 48, 32, 0.08);
}
.game-info-modal__header-main {
flex: 1;
min-width: 0;
}
.game-info-modal__header-actions {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.game-info-modal__eyebrow {
font-size: 22rpx;
font-weight: 800;
letter-spacing: 4rpx;
color: #5f7a65;
line-height: 1;
}
.game-info-modal__title {
margin-top: 12rpx;
font-size: 42rpx;
line-height: 1.08;
font-weight: 700;
color: #163020;
}
.game-info-modal__subtitle {
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.3;
color: #5f7a65;
}
.game-info-modal__close {
flex-shrink: 0;
min-width: 108rpx;
padding: 14rpx 22rpx;
border-radius: 999rpx;
background: #163020;
color: #f7fbf2;
font-size: 24rpx;
text-align: center;
}
.game-info-modal__content {
max-height: calc(72vh - 132rpx);
padding: 12rpx 24rpx 30rpx;
box-sizing: border-box;
}
.debug-section--info {
margin-top: 14rpx;
}
.debug-modal {
position: absolute;
inset: 0;

View File

@@ -22,6 +22,10 @@ export interface TileZoomBounds {
}
export interface RemoteMapConfig {
configTitle: string
configAppId: string
configSchemaVersion: string
configVersion: string
tileSource: string
minZoom: number
maxZoom: number
@@ -45,7 +49,13 @@ export interface RemoteMapConfig {
gameMode: 'classic-sequential' | 'score-o'
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
requiresFocusSelection: boolean
skipEnabled: boolean
skipRadiusMeters: number
skipRequiresConfirm: boolean
autoFinishOnLastControl: boolean
controlScoreOverrides: Record<string, number>
defaultControlScore: number | null
telemetryConfig: TelemetryConfig
audioConfig: GameAudioConfig
hapticsConfig: GameHapticsConfig
@@ -53,14 +63,25 @@ export interface RemoteMapConfig {
}
interface ParsedGameConfig {
title: string
appId: string
schemaVersion: string
version: string
mapRoot: string
mapMeta: string
course: string | null
cpRadiusMeters: number
defaultZoom: number | null
gameMode: 'classic-sequential' | 'score-o'
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
requiresFocusSelection: boolean
skipEnabled: boolean
skipRadiusMeters: number
skipRequiresConfirm: boolean
autoFinishOnLastControl: boolean
controlScoreOverrides: Record<string, number>
defaultControlScore: number | null
telemetryConfig: TelemetryConfig
audioConfig: GameAudioConfig
hapticsConfig: GameHapticsConfig
@@ -668,6 +689,18 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
const rawGame = parsed.game && typeof parsed.game === 'object' && !Array.isArray(parsed.game)
? parsed.game as Record<string, unknown>
: null
const rawApp = parsed.app && typeof parsed.app === 'object' && !Array.isArray(parsed.app)
? parsed.app as Record<string, unknown>
: null
const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map)
? parsed.map as Record<string, unknown>
: null
const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield)
? parsed.playfield as Record<string, unknown>
: null
const rawPlayfieldSource = rawPlayfield && rawPlayfield.source && typeof rawPlayfield.source === 'object' && !Array.isArray(rawPlayfield.source)
? rawPlayfield.source as Record<string, unknown>
: null
const normalizedGame: Record<string, unknown> = {}
if (rawGame) {
const gameKeys = Object.keys(rawGame)
@@ -690,41 +723,150 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
? (parsed as Record<string, unknown>).uieffects
: (parsed as Record<string, unknown>).ui
const mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
const rawSession = rawGame && rawGame.session && typeof rawGame.session === 'object' && !Array.isArray(rawGame.session)
? rawGame.session as Record<string, unknown>
: null
const rawPunch = rawGame && rawGame.punch && typeof rawGame.punch === 'object' && !Array.isArray(rawGame.punch)
? rawGame.punch as Record<string, unknown>
: null
const rawSequence = rawGame && rawGame.sequence && typeof rawGame.sequence === 'object' && !Array.isArray(rawGame.sequence)
? rawGame.sequence as Record<string, unknown>
: null
const rawSkip = rawSequence && rawSequence.skip && typeof rawSequence.skip === 'object' && !Array.isArray(rawSequence.skip)
? rawSequence.skip as Record<string, unknown>
: null
const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring)
? rawGame.scoring as Record<string, unknown>
: null
const mapRoot = rawMap && typeof rawMap.tiles === 'string'
? rawMap.tiles
: typeof normalized.map === 'string'
? normalized.map
: ''
const mapMeta = rawMap && typeof rawMap.mapmeta === 'string'
? rawMap.mapmeta
: typeof normalized.mapmeta === 'string'
? normalized.mapmeta
: ''
if (!mapRoot || !mapMeta) {
throw new Error('game.json 缺少 map 或 mapmeta 字段')
}
const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
const gameMode = parseGameMode(modeValue)
const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides)
? rawPlayfield.controlOverrides as Record<string, unknown>
: null
const controlScoreOverrides: Record<string, number> = {}
if (rawControlOverrides) {
const keys = Object.keys(rawControlOverrides)
for (const key of keys) {
const item = rawControlOverrides[key]
if (!item || typeof item !== 'object' || Array.isArray(item)) {
continue
}
const scoreValue = Number((item as Record<string, unknown>).score)
if (Number.isFinite(scoreValue)) {
controlScoreOverrides[key] = scoreValue
}
}
}
return {
title: rawApp && typeof rawApp.title === 'string' ? rawApp.title : '',
appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '',
schemaVersion: typeof parsed.schemaVersion === 'string' ? parsed.schemaVersion : '1',
version: typeof parsed.version === 'string' ? parsed.version : '',
mapRoot,
mapMeta,
course: typeof normalized.course === 'string' ? normalized.course : null,
cpRadiusMeters: parsePositiveNumber(normalized.cpradius, 5),
gameMode,
punchPolicy: parsePunchPolicy(normalizedGame.punchpolicy !== undefined ? normalizedGame.punchpolicy : normalized.punchpolicy),
punchRadiusMeters: parsePositiveNumber(
normalizedGame.punchradiusmeters !== undefined
? normalizedGame.punchradiusmeters
: normalizedGame.punchradius !== undefined
? normalizedGame.punchradius
: normalized.punchradiusmeters !== undefined
? normalized.punchradiusmeters
: normalized.punchradius,
course: rawPlayfieldSource && typeof rawPlayfieldSource.url === 'string'
? rawPlayfieldSource.url
: typeof normalized.course === 'string'
? normalized.course
: null,
cpRadiusMeters: parsePositiveNumber(
rawPlayfield && rawPlayfield.CPRadius !== undefined ? rawPlayfield.CPRadius : normalized.cpradius,
5,
),
autoFinishOnLastControl: parseBoolean(
normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
defaultZoom: rawMap && rawMap.initialView && typeof rawMap.initialView === 'object' && !Array.isArray(rawMap.initialView)
? parsePositiveNumber((rawMap.initialView as Record<string, unknown>).zoom, 17)
: null,
gameMode,
punchPolicy: parsePunchPolicy(
rawPunch && rawPunch.policy !== undefined
? rawPunch.policy
: normalizedGame.punchpolicy !== undefined
? normalizedGame.punchpolicy
: normalized.punchpolicy,
),
punchRadiusMeters: parsePositiveNumber(
rawPunch && rawPunch.radiusMeters !== undefined
? rawPunch.radiusMeters
: normalizedGame.punchradiusmeters !== undefined
? normalizedGame.punchradiusmeters
: normalizedGame.punchradius !== undefined
? normalizedGame.punchradius
: normalized.punchradiusmeters !== undefined
? normalized.punchradiusmeters
: normalized.punchradius,
5,
),
requiresFocusSelection: parseBoolean(
rawPunch && rawPunch.requiresFocusSelection !== undefined
? rawPunch.requiresFocusSelection
: normalizedGame.requiresfocusselection !== undefined
? normalizedGame.requiresfocusselection
: rawPunch && (rawPunch as Record<string, unknown>).requiresfocusselection !== undefined
? (rawPunch as Record<string, unknown>).requiresfocusselection
: normalized.requiresfocusselection,
false,
),
skipEnabled: parseBoolean(
rawSkip && rawSkip.enabled !== undefined
? rawSkip.enabled
: normalizedGame.skipenabled !== undefined
? normalizedGame.skipenabled
: normalized.skipenabled,
false,
),
skipRadiusMeters: parsePositiveNumber(
rawSkip && rawSkip.radiusMeters !== undefined
? rawSkip.radiusMeters
: normalizedGame.skipradiusmeters !== undefined
? normalizedGame.skipradiusmeters
: normalizedGame.skipradius !== undefined
? normalizedGame.skipradius
: normalized.skipradiusmeters !== undefined
? normalized.skipradiusmeters
: normalized.skipradius,
30,
),
skipRequiresConfirm: parseBoolean(
rawSkip && rawSkip.requiresConfirm !== undefined
? rawSkip.requiresConfirm
: normalizedGame.skiprequiresconfirm !== undefined
? normalizedGame.skiprequiresconfirm
: normalized.skiprequiresconfirm,
true,
),
autoFinishOnLastControl: parseBoolean(
rawSession && rawSession.autoFinishOnLastControl !== undefined
? rawSession.autoFinishOnLastControl
: normalizedGame.autofinishonlastcontrol !== undefined
? normalizedGame.autofinishonlastcontrol
: normalized.autofinishonlastcontrol,
true,
),
controlScoreOverrides,
defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined
? parsePositiveNumber(rawScoring.defaultControlScore, 10)
: null,
telemetryConfig: parseTelemetryConfig(rawTelemetry),
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
hapticsConfig: parseHapticsConfig(rawHaptics),
uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
declinationDeg: parseDeclinationValue(normalized.declination),
declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination),
}
}
@@ -755,17 +897,31 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
const gameMode = parseGameMode(config.gamemode)
return {
title: '',
appId: '',
schemaVersion: '1',
version: '',
mapRoot,
mapMeta,
course: typeof config.course === 'string' ? config.course : null,
cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
defaultZoom: null,
gameMode,
punchPolicy: parsePunchPolicy(config.punchpolicy),
punchRadiusMeters: parsePositiveNumber(
config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
5,
),
requiresFocusSelection: parseBoolean(config.requiresfocusselection, false),
skipEnabled: parseBoolean(config.skipenabled, false),
skipRadiusMeters: parsePositiveNumber(
config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius,
30,
),
skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, true),
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
controlScoreOverrides: {},
defaultControlScore: null,
telemetryConfig: parseTelemetryConfig({
heartRate: {
age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage,
@@ -1010,13 +1166,17 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
}
}
const defaultZoom = clamp(17, mapMeta.minZoom, mapMeta.maxZoom)
const defaultZoom = clamp(gameConfig.defaultZoom || 17, mapMeta.minZoom, mapMeta.maxZoom)
const boundsCorners = mapMeta.bounds ? getBoundsCorners(mapMeta.bounds, mapMeta.projection) : null
const centerWorldTile = boundsCorners
? lonLatToWorldTile(boundsCorners.center, defaultZoom)
: { x: 0, y: 0 }
return {
configTitle: gameConfig.title || '未命名配置',
configAppId: gameConfig.appId || '',
configSchemaVersion: gameConfig.schemaVersion || '1',
configVersion: gameConfig.version || '',
tileSource: resolveUrl(mapRootUrl, mapMeta.tilePathTemplate),
minZoom: mapMeta.minZoom,
maxZoom: mapMeta.maxZoom,
@@ -1040,7 +1200,13 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
gameMode: gameConfig.gameMode,
punchPolicy: gameConfig.punchPolicy,
punchRadiusMeters: gameConfig.punchRadiusMeters,
requiresFocusSelection: gameConfig.requiresFocusSelection,
skipEnabled: gameConfig.skipEnabled,
skipRadiusMeters: gameConfig.skipRadiusMeters,
skipRequiresConfirm: gameConfig.skipRequiresConfirm,
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
controlScoreOverrides: gameConfig.controlScoreOverrides,
defaultControlScore: gameConfig.defaultControlScore,
telemetryConfig: gameConfig.telemetryConfig,
audioConfig: gameConfig.audioConfig,
hapticsConfig: gameConfig.hapticsConfig,