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

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
}