@@ -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 ,
}