feat: 收敛玩法运行时配置并加入故障恢复

This commit is contained in:
2026-04-01 13:04:26 +08:00
parent 1635a11780
commit 3ef841ecc7
73 changed files with 8820 additions and 2122 deletions

View File

@@ -5,6 +5,7 @@ export type AudioCueKey =
| 'control_completed:finish'
| 'punch_feedback:warning'
| 'guidance:searching'
| 'guidance:distant'
| 'guidance:approaching'
| 'guidance:ready'
@@ -21,7 +22,9 @@ export interface GameAudioConfig {
masterVolume: number
obeyMuteSwitch: boolean
backgroundAudioEnabled: boolean
distantDistanceMeters: number
approachDistanceMeters: number
readyDistanceMeters: number
cues: Record<AudioCueKey, AudioCueConfig>
}
@@ -38,7 +41,9 @@ export interface GameAudioConfigOverrides {
masterVolume?: number
obeyMuteSwitch?: boolean
backgroundAudioEnabled?: boolean
distantDistanceMeters?: number
approachDistanceMeters?: number
readyDistanceMeters?: number
cues?: Partial<Record<AudioCueKey, PartialAudioCueConfig>>
}
@@ -47,7 +52,9 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
masterVolume: 1,
obeyMuteSwitch: true,
backgroundAudioEnabled: true,
distantDistanceMeters: 80,
approachDistanceMeters: 20,
readyDistanceMeters: 5,
cues: {
session_started: {
src: '/assets/sounds/session-start.wav',
@@ -91,6 +98,13 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
loopGapMs: 1800,
backgroundMode: 'guidance',
},
'guidance:distant': {
src: '/assets/sounds/guidance-searching.wav',
volume: 0.34,
loop: true,
loopGapMs: 4800,
backgroundMode: 'guidance',
},
'guidance:approaching': {
src: '/assets/sounds/guidance-approaching.wav',
volume: 0.58,
@@ -129,6 +143,7 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null
'control_completed:finish': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:finish'] },
'punch_feedback:warning': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['punch_feedback:warning'] },
'guidance:searching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:searching'] },
'guidance:distant': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:distant'] },
'guidance:approaching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:approaching'] },
'guidance:ready': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:ready'] },
}
@@ -170,7 +185,15 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null
backgroundAudioEnabled: overrides && overrides.backgroundAudioEnabled !== undefined
? !!overrides.backgroundAudioEnabled
: true,
distantDistanceMeters: clampDistance(
Number(overrides && overrides.distantDistanceMeters),
DEFAULT_GAME_AUDIO_CONFIG.distantDistanceMeters,
),
approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters),
readyDistanceMeters: clampDistance(
Number(overrides && overrides.readyDistanceMeters),
DEFAULT_GAME_AUDIO_CONFIG.readyDistanceMeters,
),
cues,
}
}

View File

@@ -66,6 +66,11 @@ export class SoundDirector {
}
const hasFinishCompletion = effects.some((effect) => effect.type === 'control_completed' && effect.controlKind === 'finish')
if (hasFinishCompletion) {
this.stopGuidanceLoop()
this.play('control_completed:finish')
return
}
for (const effect of effects) {
if (effect.type === 'session_started') {
@@ -85,15 +90,19 @@ export class SoundDirector {
}
if (effect.type === 'guidance_state_changed') {
if (effect.guidanceState === 'searching') {
this.startGuidanceLoop('guidance:searching')
if (effect.guidanceState === 'distant') {
this.startGuidanceLoop('guidance:distant')
continue
}
if (effect.guidanceState === 'approaching') {
this.startGuidanceLoop('guidance:approaching')
continue
}
this.startGuidanceLoop('guidance:ready')
if (effect.guidanceState === 'ready') {
this.startGuidanceLoop('guidance:ready')
continue
}
this.stopGuidanceLoop()
continue
}
@@ -273,6 +282,7 @@ export class SoundDirector {
isGuidanceCue(key: AudioCueKey): boolean {
return key === 'guidance:searching'
|| key === 'guidance:distant'
|| key === 'guidance:approaching'
|| key === 'guidance:ready'
}

View File

@@ -11,6 +11,11 @@ import {
resolveContentCardCtaConfig,
} from '../experience/contentCard'
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
import {
getDefaultSkipRadiusMeters,
getGameModeDefaults,
resolveDefaultControlScore,
} from '../core/gameModeDefaults'
function sortBySequence<T extends { sequence: number | null }>(items: T[]): T[] {
return [...items].sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
@@ -86,18 +91,48 @@ export function buildGameDefinitionFromCourse(
course: OrienteeringCourseData,
controlRadiusMeters: number,
mode: GameDefinition['mode'] = 'classic-sequential',
autoFinishOnLastControl = true,
sessionCloseAfterMs?: number,
sessionCloseWarningMs?: number,
minCompletedControlsBeforeFinish?: number,
autoFinishOnLastControl?: boolean,
punchPolicy: PunchPolicyType = 'enter-confirm',
punchRadiusMeters = 5,
requiresFocusSelection = false,
skipEnabled = false,
skipRadiusMeters = 30,
skipRequiresConfirm = true,
requiresFocusSelection?: boolean,
skipEnabled?: boolean,
skipRadiusMeters?: number,
skipRequiresConfirm?: boolean,
controlScoreOverrides: Record<string, number> = {},
defaultControlContentOverride: GameControlDisplayContentOverride | null = null,
controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {},
defaultControlScore: number | null = null,
): GameDefinition {
const controls: GameControl[] = []
const modeDefaults = getGameModeDefaults(mode)
const resolvedSessionCloseAfterMs = sessionCloseAfterMs !== undefined
? sessionCloseAfterMs
: modeDefaults.sessionCloseAfterMs
const resolvedSessionCloseWarningMs = sessionCloseWarningMs !== undefined
? sessionCloseWarningMs
: modeDefaults.sessionCloseWarningMs
const resolvedMinCompletedControlsBeforeFinish = minCompletedControlsBeforeFinish !== undefined
? minCompletedControlsBeforeFinish
: modeDefaults.minCompletedControlsBeforeFinish
const resolvedRequiresFocusSelection = requiresFocusSelection !== undefined
? requiresFocusSelection
: modeDefaults.requiresFocusSelection
const resolvedSkipEnabled = skipEnabled !== undefined
? skipEnabled
: modeDefaults.skipEnabled
const resolvedSkipRadiusMeters = skipRadiusMeters !== undefined
? skipRadiusMeters
: getDefaultSkipRadiusMeters(mode, punchRadiusMeters)
const resolvedSkipRequiresConfirm = skipRequiresConfirm !== undefined
? skipRequiresConfirm
: modeDefaults.skipRequiresConfirm
const resolvedAutoFinishOnLastControl = autoFinishOnLastControl !== undefined
? autoFinishOnLastControl
: modeDefaults.autoFinishOnLastControl
const resolvedDefaultControlScore = resolveDefaultControlScore(mode, defaultControlScore)
for (let startIndex = 0; startIndex < course.layers.starts.length; startIndex += 1) {
const start = course.layers.starts[startIndex]
@@ -114,11 +149,11 @@ export function buildGameDefinitionFromCourse(
template: 'focus',
title: '比赛开始',
body: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
autoPopup: true,
autoPopup: false,
once: false,
priority: 1,
clickTitle: '比赛开始',
clickBody: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
clickTitle: null,
clickBody: null,
ctas: [],
contentExperience: null,
clickExperience: null,
@@ -131,7 +166,7 @@ export function buildGameDefinitionFromCourse(
const controlId = `control-${control.sequence}`
const score = controlId in controlScoreOverrides
? controlScoreOverrides[controlId]
: defaultControlScore
: resolvedDefaultControlScore
controls.push({
id: controlId,
code: label,
@@ -140,19 +175,22 @@ export function buildGameDefinitionFromCourse(
point: control.point,
sequence: control.sequence,
score,
displayContent: applyDisplayContentOverride({
template: 'story',
title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}` : buildDisplayBody(label, control.sequence),
autoPopup: true,
once: false,
priority: 1,
clickTitle: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
clickBody: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}` : buildDisplayBody(label, control.sequence),
ctas: [],
contentExperience: null,
clickExperience: null,
}, controlContentOverrides[controlId]),
displayContent: applyDisplayContentOverride(
applyDisplayContentOverride({
template: 'story',
title: score !== null ? `收集 ${label} (+${score})` : `收集 ${label}`,
body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}` : buildDisplayBody(label, control.sequence),
autoPopup: false,
once: false,
priority: 1,
clickTitle: null,
clickBody: null,
ctas: [],
contentExperience: null,
clickExperience: null,
}, defaultControlContentOverride || undefined),
controlContentOverrides[controlId],
),
})
}
@@ -172,11 +210,11 @@ export function buildGameDefinitionFromCourse(
template: 'focus',
title: '完成路线',
body: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
autoPopup: true,
autoPopup: false,
once: false,
priority: 2,
clickTitle: '完成路线',
clickBody: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
clickTitle: null,
clickBody: null,
ctas: [],
contentExperience: null,
clickExperience: null,
@@ -189,13 +227,16 @@ export function buildGameDefinitionFromCourse(
mode,
title: course.title || (mode === 'score-o' ? 'Score-O' : 'Classic Sequential'),
controlRadiusMeters,
sessionCloseAfterMs: resolvedSessionCloseAfterMs,
sessionCloseWarningMs: resolvedSessionCloseWarningMs,
minCompletedControlsBeforeFinish: resolvedMinCompletedControlsBeforeFinish,
punchRadiusMeters,
punchPolicy,
requiresFocusSelection,
skipEnabled,
skipRadiusMeters,
skipRequiresConfirm,
requiresFocusSelection: resolvedRequiresFocusSelection,
skipEnabled: resolvedSkipEnabled,
skipRadiusMeters: resolvedSkipRadiusMeters,
skipRequiresConfirm: resolvedSkipRequiresConfirm,
controls,
autoFinishOnLastControl,
autoFinishOnLastControl: resolvedAutoFinishOnLastControl,
}
}

View File

@@ -71,6 +71,9 @@ export interface GameDefinition {
mode: GameMode
title: string
controlRadiusMeters: number
sessionCloseAfterMs: number
sessionCloseWarningMs: number
minCompletedControlsBeforeFinish: number
punchRadiusMeters: number
punchPolicy: PunchPolicyType
requiresFocusSelection: boolean

View File

@@ -1,7 +1,8 @@
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: 'punch_requested'; at: number; lon: number | null; lat: number | null }
| { type: 'skip_requested'; at: number; lon: number | null; lat: number | null }
| { type: 'control_focused'; at: number; controlId: string | null }
| { type: 'session_ended'; at: number }
| { type: 'session_timed_out'; at: number }

View File

@@ -0,0 +1,55 @@
import { type GameMode } from './gameDefinition'
export interface GameModeDefaults {
sessionCloseAfterMs: number
sessionCloseWarningMs: number
minCompletedControlsBeforeFinish: number
requiresFocusSelection: boolean
skipEnabled: boolean
skipRequiresConfirm: boolean
autoFinishOnLastControl: boolean
defaultControlScore: number
}
const GAME_MODE_DEFAULTS: Record<GameMode, GameModeDefaults> = {
'classic-sequential': {
sessionCloseAfterMs: 2 * 60 * 60 * 1000,
sessionCloseWarningMs: 10 * 60 * 1000,
minCompletedControlsBeforeFinish: 0,
requiresFocusSelection: false,
skipEnabled: true,
skipRequiresConfirm: true,
autoFinishOnLastControl: false,
defaultControlScore: 1,
},
'score-o': {
sessionCloseAfterMs: 2 * 60 * 60 * 1000,
sessionCloseWarningMs: 10 * 60 * 1000,
minCompletedControlsBeforeFinish: 1,
requiresFocusSelection: false,
skipEnabled: false,
skipRequiresConfirm: true,
autoFinishOnLastControl: false,
defaultControlScore: 10,
},
}
export function getGameModeDefaults(mode: GameMode): GameModeDefaults {
return GAME_MODE_DEFAULTS[mode]
}
export function getDefaultSkipRadiusMeters(mode: GameMode, punchRadiusMeters: number): number {
if (mode === 'classic-sequential') {
return punchRadiusMeters * 2
}
return 30
}
export function resolveDefaultControlScore(mode: GameMode, configuredDefaultScore: number | null): number {
if (typeof configuredDefaultScore === 'number') {
return configuredDefaultScore
}
return getGameModeDefaults(mode).defaultControlScore
}

View File

@@ -5,9 +5,10 @@ export type GameEffect =
| { type: 'session_started' }
| { type: 'session_cancelled' }
| { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' }
| { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string; displayAutoPopup: boolean; displayOnce: boolean; displayPriority: number }
| { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string; displayAutoPopup: boolean; displayOnce: boolean; displayPriority: number; autoOpenQuiz: boolean }
| { type: 'guidance_state_changed'; guidanceState: GuidanceState; controlId: string | null }
| { type: 'session_finished' }
| { type: 'session_timed_out' }
export interface GameResult {
nextState: GameSessionState

View File

@@ -54,6 +54,36 @@ export class GameRuntime {
return result
}
restoreDefinition(definition: GameDefinition, state: GameSessionState): GameResult {
this.definition = definition
this.plugin = this.resolvePlugin(definition)
this.state = {
status: state.status,
endReason: state.endReason,
startedAt: state.startedAt,
endedAt: state.endedAt,
completedControlIds: state.completedControlIds.slice(),
skippedControlIds: state.skippedControlIds.slice(),
currentTargetControlId: state.currentTargetControlId,
inRangeControlId: state.inRangeControlId,
score: state.score,
guidanceState: state.guidanceState,
modeState: state.modeState
? JSON.parse(JSON.stringify(state.modeState)) as Record<string, unknown>
: null,
}
const result: GameResult = {
nextState: this.state,
presentation: this.plugin.buildPresentation(definition, this.state),
effects: [],
}
this.presentation = result.presentation
this.mapPresentation = result.presentation.map
this.hudPresentation = result.presentation.hud
this.lastResult = result
return result
}
startSession(startAt = Date.now()): GameResult {
return this.dispatch({ type: 'session_started', at: startAt })
}
@@ -62,6 +92,7 @@ export class GameRuntime {
if (!this.definition || !this.plugin || !this.state) {
const emptyState: GameSessionState = {
status: 'idle',
endReason: null,
startedAt: null,
endedAt: null,
completedControlIds: [],

View File

@@ -1,9 +1,11 @@
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
export type GuidanceState = 'searching' | 'approaching' | 'ready'
export type GameSessionEndReason = 'completed' | 'timed_out' | 'cancelled' | null
export type GuidanceState = 'searching' | 'distant' | 'approaching' | 'ready'
export type GameModeState = Record<string, unknown> | null
export interface GameSessionState {
status: GameSessionStatus
endReason: GameSessionEndReason
startedAt: number | null
endedAt: number | null
completedControlIds: string[]

View File

@@ -0,0 +1,150 @@
import { type RemoteMapConfig } from '../../utils/remoteMapConfig'
import { type GameAudioConfig } from '../audio/audioConfig'
import { type GameHapticsConfig, type GameUiEffectsConfig } from '../feedback/feedbackConfig'
import { getGameModeDefaults } from './gameModeDefaults'
import {
resolveSystemSettingsState,
type ResolvedSystemSettingsState,
} from './systemSettingsState'
import { type CourseStyleConfig } from '../presentation/courseStyleConfig'
import { type GpsMarkerStyleConfig } from '../presentation/gpsMarkerStyleConfig'
import { type TrackVisualizationConfig } from '../presentation/trackStyleConfig'
import { mergeTelemetrySources, type PlayerTelemetryProfile } from '../telemetry/playerTelemetryProfile'
import { type TelemetryConfig } from '../telemetry/telemetryConfig'
export interface RuntimeMapProfile {
title: string
tileSource: string
projectionModeText: string
magneticDeclinationText: string
cpRadiusMeters: number
projection: string
magneticDeclinationDeg: number
minZoom: number
maxZoom: number
initialZoom: number
initialCenterTileX: number
initialCenterTileY: number
tileBoundsByZoom: RemoteMapConfig['tileBoundsByZoom']
courseStatusText: string
}
export interface RuntimeGameProfile {
mode: RemoteMapConfig['gameMode']
sessionCloseAfterMs: number
sessionCloseWarningMs: number
minCompletedControlsBeforeFinish: number
punchPolicy: RemoteMapConfig['punchPolicy']
punchRadiusMeters: number
requiresFocusSelection: boolean
skipEnabled: boolean
skipRadiusMeters: number
skipRequiresConfirm: boolean
autoFinishOnLastControl: boolean
defaultControlScore: number | null
}
export interface RuntimePresentationProfile {
course: CourseStyleConfig
track: TrackVisualizationConfig
gpsMarker: GpsMarkerStyleConfig
}
export interface RuntimeFeedbackProfile {
audio: GameAudioConfig
haptics: GameHapticsConfig
uiEffects: GameUiEffectsConfig
}
export interface RuntimeTelemetryProfile {
config: TelemetryConfig
playerProfile: PlayerTelemetryProfile | null
}
export interface RuntimeSettingsProfile extends ResolvedSystemSettingsState {
lockLifetimeActive: boolean
}
export interface CompiledRuntimeProfile {
map: RuntimeMapProfile
game: RuntimeGameProfile
settings: RuntimeSettingsProfile
telemetry: RuntimeTelemetryProfile
presentation: RuntimePresentationProfile
feedback: RuntimeFeedbackProfile
}
export interface CompileRuntimeProfileOptions {
playerTelemetryProfile?: PlayerTelemetryProfile | null
settingsLockLifetimeActive?: boolean
storedSettingsKey?: string
}
export function compileRuntimeProfile(
config: RemoteMapConfig,
options?: CompileRuntimeProfileOptions,
): CompiledRuntimeProfile {
const modeDefaults = getGameModeDefaults(config.gameMode)
const lockLifetimeActive = !!(options && options.settingsLockLifetimeActive === true)
const playerTelemetryProfile = options && options.playerTelemetryProfile
? Object.assign({}, options.playerTelemetryProfile)
: null
const settings = resolveSystemSettingsState(
config.systemSettingsConfig,
options && options.storedSettingsKey ? options.storedSettingsKey : undefined,
lockLifetimeActive,
)
return {
map: {
title: config.configTitle,
tileSource: config.tileSource,
projectionModeText: config.projectionModeText,
magneticDeclinationText: config.magneticDeclinationText,
cpRadiusMeters: config.cpRadiusMeters,
projection: config.projection,
magneticDeclinationDeg: config.magneticDeclinationDeg,
minZoom: config.minZoom,
maxZoom: config.maxZoom,
initialZoom: config.defaultZoom,
initialCenterTileX: config.initialCenterTileX,
initialCenterTileY: config.initialCenterTileY,
tileBoundsByZoom: config.tileBoundsByZoom,
courseStatusText: config.courseStatusText,
},
game: {
mode: config.gameMode,
sessionCloseAfterMs: config.sessionCloseAfterMs || modeDefaults.sessionCloseAfterMs,
sessionCloseWarningMs: config.sessionCloseWarningMs || modeDefaults.sessionCloseWarningMs,
minCompletedControlsBeforeFinish: config.minCompletedControlsBeforeFinish,
punchPolicy: config.punchPolicy,
punchRadiusMeters: config.punchRadiusMeters,
requiresFocusSelection: config.requiresFocusSelection,
skipEnabled: config.skipEnabled,
skipRadiusMeters: config.skipRadiusMeters,
skipRequiresConfirm: config.skipRequiresConfirm,
autoFinishOnLastControl: config.autoFinishOnLastControl,
defaultControlScore: config.defaultControlScore,
},
settings: {
values: settings.values,
locks: settings.locks,
lockLifetimeActive,
},
telemetry: {
config: mergeTelemetrySources(config.telemetryConfig, playerTelemetryProfile),
playerProfile: playerTelemetryProfile,
},
presentation: {
course: config.courseStyleConfig,
track: config.trackStyleConfig,
gpsMarker: config.gpsMarkerStyleConfig,
},
feedback: {
audio: config.audioConfig,
haptics: config.hapticsConfig,
uiEffects: config.uiEffectsConfig,
},
}
}

View File

@@ -0,0 +1,146 @@
import { type LonLatPoint } from '../../utils/projection'
import { type GameLaunchEnvelope } from '../../utils/gameLaunch'
import { type GameSessionState } from './gameSessionState'
export interface RecoveryTelemetrySnapshot {
distanceMeters: number
currentSpeedKmh: number | null
averageSpeedKmh: number | null
heartRateBpm: number | null
caloriesKcal: number | null
lastGpsPoint: LonLatPoint | null
lastGpsAt: number | null
lastGpsAccuracyMeters: number | null
}
export interface RecoveryViewportSnapshot {
zoom: number
centerTileX: number
centerTileY: number
rotationDeg: number
gpsLockEnabled: boolean
hasGpsCenteredOnce: boolean
}
export interface RecoveryRuntimeSnapshot {
gameState: GameSessionState
telemetry: RecoveryTelemetrySnapshot
viewport: RecoveryViewportSnapshot
currentGpsPoint: LonLatPoint | null
currentGpsAccuracyMeters: number | null
currentGpsInsideMap: boolean
bonusScore: number
quizCorrectCount: number
quizWrongCount: number
quizTimeoutCount: number
}
export interface SessionRecoverySnapshot {
schemaVersion: 1
savedAt: number
launchEnvelope: GameLaunchEnvelope
configAppId: string
configVersion: string
runtime: RecoveryRuntimeSnapshot
}
const SESSION_RECOVERY_STORAGE_KEY = 'cmr.sessionRecovery.v1'
function cloneLonLatPoint(point: LonLatPoint | null): LonLatPoint | null {
if (!point) {
return null
}
return {
lon: point.lon,
lat: point.lat,
}
}
function cloneGameSessionState(state: GameSessionState): GameSessionState {
return {
status: state.status,
endReason: state.endReason,
startedAt: state.startedAt,
endedAt: state.endedAt,
completedControlIds: state.completedControlIds.slice(),
skippedControlIds: state.skippedControlIds.slice(),
currentTargetControlId: state.currentTargetControlId,
inRangeControlId: state.inRangeControlId,
score: state.score,
guidanceState: state.guidanceState,
modeState: state.modeState
? JSON.parse(JSON.stringify(state.modeState)) as Record<string, unknown>
: null,
}
}
export function cloneSessionRecoverySnapshot(snapshot: SessionRecoverySnapshot): SessionRecoverySnapshot {
return {
schemaVersion: 1,
savedAt: snapshot.savedAt,
launchEnvelope: JSON.parse(JSON.stringify(snapshot.launchEnvelope)) as GameLaunchEnvelope,
configAppId: snapshot.configAppId,
configVersion: snapshot.configVersion,
runtime: {
gameState: cloneGameSessionState(snapshot.runtime.gameState),
telemetry: {
distanceMeters: snapshot.runtime.telemetry.distanceMeters,
currentSpeedKmh: snapshot.runtime.telemetry.currentSpeedKmh,
averageSpeedKmh: snapshot.runtime.telemetry.averageSpeedKmh,
heartRateBpm: snapshot.runtime.telemetry.heartRateBpm,
caloriesKcal: snapshot.runtime.telemetry.caloriesKcal,
lastGpsPoint: cloneLonLatPoint(snapshot.runtime.telemetry.lastGpsPoint),
lastGpsAt: snapshot.runtime.telemetry.lastGpsAt,
lastGpsAccuracyMeters: snapshot.runtime.telemetry.lastGpsAccuracyMeters,
},
viewport: {
zoom: snapshot.runtime.viewport.zoom,
centerTileX: snapshot.runtime.viewport.centerTileX,
centerTileY: snapshot.runtime.viewport.centerTileY,
rotationDeg: snapshot.runtime.viewport.rotationDeg,
gpsLockEnabled: snapshot.runtime.viewport.gpsLockEnabled,
hasGpsCenteredOnce: snapshot.runtime.viewport.hasGpsCenteredOnce,
},
currentGpsPoint: cloneLonLatPoint(snapshot.runtime.currentGpsPoint),
currentGpsAccuracyMeters: snapshot.runtime.currentGpsAccuracyMeters,
currentGpsInsideMap: snapshot.runtime.currentGpsInsideMap,
bonusScore: snapshot.runtime.bonusScore,
quizCorrectCount: snapshot.runtime.quizCorrectCount,
quizWrongCount: snapshot.runtime.quizWrongCount,
quizTimeoutCount: snapshot.runtime.quizTimeoutCount,
},
}
}
function normalizeSessionRecoverySnapshot(raw: unknown): SessionRecoverySnapshot | null {
if (!raw || typeof raw !== 'object') {
return null
}
const candidate = raw as SessionRecoverySnapshot
if (candidate.schemaVersion !== 1 || !candidate.runtime || !candidate.runtime.gameState) {
return null
}
return cloneSessionRecoverySnapshot(candidate)
}
export function loadSessionRecoverySnapshot(): SessionRecoverySnapshot | null {
try {
return normalizeSessionRecoverySnapshot(wx.getStorageSync(SESSION_RECOVERY_STORAGE_KEY))
} catch {
return null
}
}
export function saveSessionRecoverySnapshot(snapshot: SessionRecoverySnapshot): void {
try {
wx.setStorageSync(SESSION_RECOVERY_STORAGE_KEY, cloneSessionRecoverySnapshot(snapshot))
} catch {}
}
export function clearSessionRecoverySnapshot(): void {
try {
wx.removeStorageSync(SESSION_RECOVERY_STORAGE_KEY)
} catch {}
}

View File

@@ -0,0 +1,292 @@
import { type AnimationLevel } from '../../utils/animationLevel'
import { type TrackColorPreset, type TrackDisplayMode, type TrackStyleProfile, type TrackTailLengthPreset } from '../presentation/trackStyleConfig'
import { type GpsMarkerColorPreset, type GpsMarkerSizePreset, type GpsMarkerStyleId } from '../presentation/gpsMarkerStyleConfig'
export type SideButtonPlacement = 'left' | 'right'
export type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center'
export type UserNorthReferenceMode = 'magnetic' | 'true'
export type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive'
export type SettingLockKey =
| 'lockAnimationLevel'
| 'lockTrackMode'
| 'lockTrackTailLength'
| 'lockTrackColor'
| 'lockTrackStyle'
| 'lockGpsMarkerVisible'
| 'lockGpsMarkerStyle'
| 'lockGpsMarkerSize'
| 'lockGpsMarkerColor'
| 'lockSideButtonPlacement'
| 'lockAutoRotate'
| 'lockCompassTuning'
| 'lockScaleRulerVisible'
| 'lockScaleRulerAnchor'
| 'lockNorthReference'
| 'lockHeartRateDevice'
export type StoredUserSettings = {
animationLevel?: AnimationLevel
trackDisplayMode?: TrackDisplayMode
trackTailLength?: TrackTailLengthPreset
trackColorPreset?: TrackColorPreset
trackStyleProfile?: TrackStyleProfile
gpsMarkerVisible?: boolean
gpsMarkerStyle?: GpsMarkerStyleId
gpsMarkerSize?: GpsMarkerSizePreset
gpsMarkerColorPreset?: GpsMarkerColorPreset
autoRotateEnabled?: boolean
compassTuningProfile?: CompassTuningProfile
northReferenceMode?: UserNorthReferenceMode
sideButtonPlacement?: SideButtonPlacement
showCenterScaleRuler?: boolean
centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode
}
export interface SystemSettingsConfig {
values: Partial<StoredUserSettings>
locks: Partial<Record<SettingLockKey, boolean>>
}
export type ResolvedSystemSettingsState = {
values: Required<StoredUserSettings>
locks: Record<SettingLockKey, boolean>
}
export const USER_SETTINGS_STORAGE_KEY = 'cmr_user_settings_v1'
export const DEFAULT_STORED_USER_SETTINGS: Required<StoredUserSettings> = {
animationLevel: 'standard',
trackDisplayMode: 'full',
trackTailLength: 'medium',
trackColorPreset: 'mint',
trackStyleProfile: 'neon',
gpsMarkerVisible: true,
gpsMarkerStyle: 'beacon',
gpsMarkerSize: 'medium',
gpsMarkerColorPreset: 'cyan',
autoRotateEnabled: true,
compassTuningProfile: 'balanced',
northReferenceMode: 'magnetic',
sideButtonPlacement: 'left',
showCenterScaleRuler: false,
centerScaleRulerAnchorMode: 'screen-center',
}
export const DEFAULT_SETTING_LOCKS: Record<SettingLockKey, boolean> = {
lockAnimationLevel: false,
lockTrackMode: false,
lockTrackTailLength: false,
lockTrackColor: false,
lockTrackStyle: false,
lockGpsMarkerVisible: false,
lockGpsMarkerStyle: false,
lockGpsMarkerSize: false,
lockGpsMarkerColor: false,
lockSideButtonPlacement: false,
lockAutoRotate: false,
lockCompassTuning: false,
lockScaleRulerVisible: false,
lockScaleRulerAnchor: false,
lockNorthReference: false,
lockHeartRateDevice: false,
}
export const SETTING_LOCK_VALUE_MAP: Record<SettingLockKey, keyof StoredUserSettings | null> = {
lockAnimationLevel: 'animationLevel',
lockTrackMode: 'trackDisplayMode',
lockTrackTailLength: 'trackTailLength',
lockTrackColor: 'trackColorPreset',
lockTrackStyle: 'trackStyleProfile',
lockGpsMarkerVisible: 'gpsMarkerVisible',
lockGpsMarkerStyle: 'gpsMarkerStyle',
lockGpsMarkerSize: 'gpsMarkerSize',
lockGpsMarkerColor: 'gpsMarkerColorPreset',
lockSideButtonPlacement: 'sideButtonPlacement',
lockAutoRotate: 'autoRotateEnabled',
lockCompassTuning: 'compassTuningProfile',
lockScaleRulerVisible: 'showCenterScaleRuler',
lockScaleRulerAnchor: 'centerScaleRulerAnchorMode',
lockNorthReference: 'northReferenceMode',
lockHeartRateDevice: null,
}
function normalizeStoredUserSettings(raw: unknown): StoredUserSettings {
if (!raw || typeof raw !== 'object') {
return {}
}
const normalized = raw as Record<string, unknown>
const settings: StoredUserSettings = {}
if (normalized.animationLevel === 'standard' || normalized.animationLevel === 'lite') {
settings.animationLevel = normalized.animationLevel
}
if (normalized.trackDisplayMode === 'none' || normalized.trackDisplayMode === 'full' || normalized.trackDisplayMode === 'tail') {
settings.trackDisplayMode = normalized.trackDisplayMode
}
if (normalized.trackTailLength === 'short' || normalized.trackTailLength === 'medium' || normalized.trackTailLength === 'long') {
settings.trackTailLength = normalized.trackTailLength
}
if (normalized.trackStyleProfile === 'classic' || normalized.trackStyleProfile === 'neon') {
settings.trackStyleProfile = normalized.trackStyleProfile
}
if (typeof normalized.gpsMarkerVisible === 'boolean') {
settings.gpsMarkerVisible = normalized.gpsMarkerVisible
}
if (
normalized.gpsMarkerStyle === 'dot'
|| normalized.gpsMarkerStyle === 'beacon'
|| normalized.gpsMarkerStyle === 'disc'
|| normalized.gpsMarkerStyle === 'badge'
) {
settings.gpsMarkerStyle = normalized.gpsMarkerStyle
}
if (normalized.gpsMarkerSize === 'small' || normalized.gpsMarkerSize === 'medium' || normalized.gpsMarkerSize === 'large') {
settings.gpsMarkerSize = normalized.gpsMarkerSize
}
if (
normalized.gpsMarkerColorPreset === 'mint'
|| normalized.gpsMarkerColorPreset === 'cyan'
|| normalized.gpsMarkerColorPreset === 'sky'
|| normalized.gpsMarkerColorPreset === 'blue'
|| normalized.gpsMarkerColorPreset === 'violet'
|| normalized.gpsMarkerColorPreset === 'pink'
|| normalized.gpsMarkerColorPreset === 'orange'
|| normalized.gpsMarkerColorPreset === 'yellow'
) {
settings.gpsMarkerColorPreset = normalized.gpsMarkerColorPreset
}
if (
normalized.trackColorPreset === 'mint'
|| normalized.trackColorPreset === 'cyan'
|| normalized.trackColorPreset === 'sky'
|| normalized.trackColorPreset === 'blue'
|| normalized.trackColorPreset === 'violet'
|| normalized.trackColorPreset === 'pink'
|| normalized.trackColorPreset === 'orange'
|| normalized.trackColorPreset === 'yellow'
) {
settings.trackColorPreset = normalized.trackColorPreset
}
if (normalized.northReferenceMode === 'magnetic' || normalized.northReferenceMode === 'true') {
settings.northReferenceMode = normalized.northReferenceMode
}
if (typeof normalized.autoRotateEnabled === 'boolean') {
settings.autoRotateEnabled = normalized.autoRotateEnabled
}
if (normalized.compassTuningProfile === 'smooth' || normalized.compassTuningProfile === 'balanced' || normalized.compassTuningProfile === 'responsive') {
settings.compassTuningProfile = normalized.compassTuningProfile
}
if (normalized.sideButtonPlacement === 'left' || normalized.sideButtonPlacement === 'right') {
settings.sideButtonPlacement = normalized.sideButtonPlacement
}
if (typeof normalized.showCenterScaleRuler === 'boolean') {
settings.showCenterScaleRuler = normalized.showCenterScaleRuler
}
if (normalized.centerScaleRulerAnchorMode === 'screen-center' || normalized.centerScaleRulerAnchorMode === 'compass-center') {
settings.centerScaleRulerAnchorMode = normalized.centerScaleRulerAnchorMode
}
return settings
}
export function loadStoredUserSettings(storageKey = USER_SETTINGS_STORAGE_KEY): StoredUserSettings {
try {
return normalizeStoredUserSettings(wx.getStorageSync(storageKey))
} catch {
return {}
}
}
export function persistStoredUserSettings(
settings: StoredUserSettings,
storageKey = USER_SETTINGS_STORAGE_KEY,
): void {
try {
wx.setStorageSync(storageKey, settings)
} catch {}
}
export function mergeStoredUserSettings(
current: StoredUserSettings,
patch: Partial<StoredUserSettings>,
): StoredUserSettings {
return {
...current,
...patch,
}
}
export function buildInitialSystemSettingsState(
stored: StoredUserSettings,
config?: Partial<SystemSettingsConfig>,
): ResolvedSystemSettingsState {
const values = {
...DEFAULT_STORED_USER_SETTINGS,
...(config && config.values ? config.values : {}),
}
const locks = {
...DEFAULT_SETTING_LOCKS,
...(config && config.locks ? config.locks : {}),
}
const resolvedValues: Required<StoredUserSettings> = {
...values,
}
for (const [lockKey, isLocked] of Object.entries(locks) as Array<[SettingLockKey, boolean]>) {
const valueKey = SETTING_LOCK_VALUE_MAP[lockKey]
if (!valueKey) {
continue
}
if (!isLocked && stored[valueKey] !== undefined) {
;(resolvedValues as Record<string, unknown>)[valueKey] = stored[valueKey]
}
}
for (const [key, value] of Object.entries(stored) as Array<[keyof StoredUserSettings, StoredUserSettings[keyof StoredUserSettings]]>) {
const matchingLockKey = (Object.keys(SETTING_LOCK_VALUE_MAP) as SettingLockKey[])
.find((lockKey) => SETTING_LOCK_VALUE_MAP[lockKey] === key)
if (matchingLockKey && locks[matchingLockKey]) {
continue
}
if (value !== undefined) {
;(resolvedValues as Record<string, unknown>)[key] = value
}
}
return {
values: resolvedValues,
locks,
}
}
export function buildRuntimeSettingLocks(
locks: Partial<Record<SettingLockKey, boolean>> | undefined,
runtimeActive: boolean,
): Partial<Record<SettingLockKey, boolean>> {
const sourceLocks = locks || {}
if (runtimeActive) {
return { ...sourceLocks }
}
const unlocked: Partial<Record<SettingLockKey, boolean>> = {}
for (const key of Object.keys(sourceLocks) as SettingLockKey[]) {
unlocked[key] = false
}
return unlocked
}
export function resolveSystemSettingsState(
config?: Partial<SystemSettingsConfig>,
storageKey = USER_SETTINGS_STORAGE_KEY,
runtimeActive = false,
): ResolvedSystemSettingsState {
return buildInitialSystemSettingsState(
loadStoredUserSettings(storageKey),
{
values: config && config.values ? config.values : {},
locks: buildRuntimeSettingLocks(config && config.locks ? config.locks : {}, runtimeActive),
},
)
}

View File

@@ -33,7 +33,7 @@ export interface ContentCardActionViewModel {
export const DEFAULT_CONTENT_CARD_QUIZ_CONFIG: ContentCardQuizConfig = {
bonusScore: 1,
countdownSeconds: 12,
countdownSeconds: 10,
minValue: 10,
maxValue: 999,
allowSubtraction: true,

View File

@@ -3,11 +3,13 @@ import { type AnimationLevel } from '../../utils/animationLevel'
export type FeedbackCueKey =
| 'session_started'
| 'session_finished'
| 'hint:changed'
| 'control_completed:start'
| 'control_completed:control'
| 'control_completed:finish'
| 'punch_feedback:warning'
| 'guidance:searching'
| 'guidance:distant'
| 'guidance:approaching'
| 'guidance:ready'
@@ -83,12 +85,14 @@ export const DEFAULT_GAME_HAPTICS_CONFIG: GameHapticsConfig = {
cues: {
session_started: { enabled: false, pattern: 'short' },
session_finished: { enabled: true, pattern: 'long' },
'hint:changed': { enabled: true, pattern: 'short' },
'control_completed:start': { enabled: true, pattern: 'short' },
'control_completed:control': { enabled: true, pattern: 'short' },
'control_completed:finish': { enabled: true, pattern: 'long' },
'punch_feedback:warning': { enabled: true, pattern: 'short' },
'guidance:searching': { enabled: false, pattern: 'short' },
'guidance:approaching': { enabled: false, pattern: 'short' },
'guidance:distant': { enabled: true, pattern: 'short' },
'guidance:approaching': { enabled: true, pattern: 'short' },
'guidance:ready': { enabled: true, pattern: 'short' },
},
}
@@ -98,11 +102,13 @@ export const DEFAULT_GAME_UI_EFFECTS_CONFIG: GameUiEffectsConfig = {
cues: {
session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
'hint:changed': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 },
'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 },
'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', hudProgressMotion: 'finish', hudDistanceMotion: 'success', durationMs: 680 },
'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 560 },
'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
'guidance:distant': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 900 },
},
@@ -137,11 +143,13 @@ export function mergeGameHapticsConfig(overrides?: GameHapticsConfigOverrides |
const cues: GameHapticsConfig['cues'] = {
session_started: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
session_finished: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
'hint:changed': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['hint:changed'], overrides && overrides.cues ? overrides.cues['hint:changed'] : undefined),
'control_completed:start': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
'control_completed:control': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
'control_completed:finish': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
'punch_feedback:warning': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
'guidance:searching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
'guidance:distant': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:distant'], overrides && overrides.cues ? overrides.cues['guidance:distant'] : undefined),
'guidance:approaching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
'guidance:ready': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
}
@@ -156,11 +164,13 @@ export function mergeGameUiEffectsConfig(overrides?: GameUiEffectsConfigOverride
const cues: GameUiEffectsConfig['cues'] = {
session_started: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
session_finished: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
'hint:changed': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['hint:changed'], overrides && overrides.cues ? overrides.cues['hint:changed'] : undefined),
'control_completed:start': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
'control_completed:control': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
'control_completed:finish': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
'punch_feedback:warning': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
'guidance:searching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
'guidance:distant': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:distant'], overrides && overrides.cues ? overrides.cues['guidance:distant'] : undefined),
'guidance:approaching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
'guidance:ready': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
}

View File

@@ -1,10 +1,11 @@
import { DEFAULT_GAME_AUDIO_CONFIG, type GameAudioConfig } from '../audio/audioConfig'
import { DEFAULT_GAME_AUDIO_CONFIG, type AudioCueKey, type GameAudioConfig } from '../audio/audioConfig'
import { SoundDirector } from '../audio/soundDirector'
import { type GameEffect } from '../core/gameResult'
import { type AnimationLevel } from '../../utils/animationLevel'
import {
DEFAULT_GAME_HAPTICS_CONFIG,
DEFAULT_GAME_UI_EFFECTS_CONFIG,
type FeedbackCueKey,
type GameHapticsConfig,
type GameUiEffectsConfig,
} from './feedbackConfig'
@@ -61,12 +62,20 @@ export class FeedbackDirector {
this.soundDirector.setAppAudioMode(mode)
}
playAudioCue(key: AudioCueKey): void {
this.soundDirector.play(key)
}
playHapticCue(key: FeedbackCueKey): void {
this.hapticsDirector.trigger(key)
}
handleEffects(effects: GameEffect[]): void {
this.soundDirector.handleEffects(effects)
this.hapticsDirector.handleEffects(effects)
this.uiEffectDirector.handleEffects(effects)
if (effects.some((effect) => effect.type === 'session_finished')) {
if (effects.some((effect) => effect.type === 'session_finished' || effect.type === 'session_timed_out')) {
this.host.stopLocationTracking()
}
}

View File

@@ -66,6 +66,10 @@ export class HapticsDirector {
this.trigger('guidance:searching')
continue
}
if (effect.guidanceState === 'distant') {
this.trigger('guidance:distant')
continue
}
if (effect.guidanceState === 'approaching') {
this.trigger('guidance:approaching')
continue

View File

@@ -258,17 +258,19 @@ export class UiEffectDirector {
'success',
cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '',
)
this.host.showContentCard(
effect.displayTitle,
effect.displayBody,
cue ? this.getContentCardMotionClass(cue.contentCardMotion) : '',
{
contentKey: effect.controlId,
autoPopup: effect.displayAutoPopup,
once: effect.displayOnce,
priority: effect.displayPriority,
},
)
if (effect.controlKind !== 'finish' && effect.displayAutoPopup) {
this.host.showContentCard(
effect.displayTitle,
effect.displayBody,
cue ? this.getContentCardMotionClass(cue.contentCardMotion) : '',
{
contentKey: effect.controlId,
autoPopup: effect.displayAutoPopup,
once: effect.displayOnce,
priority: effect.displayPriority,
},
)
}
if (cue && cue.mapPulseMotion !== 'none') {
this.host.showMapPulse(effect.controlId, this.getMapPulseMotionClass(cue.mapPulseMotion))
}

View File

@@ -72,15 +72,15 @@ export const DEFAULT_COURSE_STYLE_CONFIG: CourseStyleConfig = {
},
scoreO: {
controls: {
default: { style: 'badge', colorHex: '#cc006b', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02 },
focused: { style: 'pulse-core', colorHex: '#fff0fa', sizeScale: 1.12, accentRingScale: 1.36, glowStrength: 1, labelScale: 1.12, labelColorHex: '#fffafc' },
collected: { style: 'solid-dot', colorHex: '#d6dae0', sizeScale: 0.82, labelScale: 0.92 },
default: { style: 'badge', colorHex: '#cc006b', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02, labelColorHex: '#ffffff' },
focused: { style: 'badge', colorHex: '#cc006b', sizeScale: 1.1, accentRingScale: 1.34, glowStrength: 0.92, labelScale: 1.08, labelColorHex: '#ffffff' },
collected: { style: 'badge', colorHex: '#9aa3ad', sizeScale: 0.86, accentRingScale: 1.08, labelScale: 0.94, labelColorHex: '#ffffff' },
start: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.02, accentRingScale: 1.24, labelScale: 1.02 },
finish: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.06, accentRingScale: 1.28, glowStrength: 0.26, labelScale: 1.04, labelColorHex: '#fff4de' },
scoreBands: [
{ min: 0, max: 19, style: 'badge', colorHex: '#56ccf2', sizeScale: 0.88, accentRingScale: 1.06, labelScale: 0.94 },
{ min: 20, max: 49, style: 'badge', colorHex: '#f2c94c', sizeScale: 1.02, accentRingScale: 1.18, labelScale: 1.02 },
{ min: 50, max: 999999, style: 'badge', colorHex: '#eb5757', sizeScale: 1.14, accentRingScale: 1.32, glowStrength: 0.72, labelScale: 1.1 },
{ min: 0, max: 19, style: 'badge', colorHex: '#56ccf2', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02, labelColorHex: '#ffffff' },
{ min: 20, max: 49, style: 'badge', colorHex: '#f2c94c', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02, labelColorHex: '#ffffff' },
{ min: 50, max: 999999, style: 'badge', colorHex: '#eb5757', sizeScale: 0.96, accentRingScale: 1.1, glowStrength: 0.72, labelScale: 1.02, labelColorHex: '#ffffff' },
],
},
},

View File

@@ -1,6 +1,7 @@
export interface HudPresentationState {
actionTagText: string
distanceTagText: string
targetSummaryText: string
hudTargetControlId: string | null
progressText: string
punchableControlId: string | null
@@ -12,6 +13,7 @@ export interface HudPresentationState {
export const EMPTY_HUD_PRESENTATION_STATE: HudPresentationState = {
actionTagText: '目标',
distanceTagText: '点距',
targetSummaryText: '等待选择目标',
hudTargetControlId: null,
progressText: '0/0',
punchableControlId: null,

View File

@@ -1,14 +1,28 @@
import { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from './hudPresentationState'
import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from './mapPresentationState'
export interface GameTargetingPresentationState {
punchableControlId: string | null
guidanceControlId: string | null
hudControlId: string | null
highlightedControlId: string | null
}
export interface GamePresentationState {
map: MapPresentationState
hud: HudPresentationState
targeting: GameTargetingPresentationState
}
export const EMPTY_GAME_PRESENTATION_STATE: GamePresentationState = {
map: EMPTY_MAP_PRESENTATION_STATE,
hud: EMPTY_HUD_PRESENTATION_STATE,
targeting: {
punchableControlId: null,
guidanceControlId: null,
hudControlId: null,
highlightedControlId: null,
},
}

View File

@@ -15,6 +15,15 @@ export interface ResultSummarySnapshot {
rows: ResultSummaryRow[]
}
export interface ResultSummaryMetrics {
totalScore?: number
baseScore?: number
bonusScore?: number
quizCorrectCount?: number
quizWrongCount?: number
quizTimeoutCount?: number
}
function resolveTitle(definition: GameDefinition | null, mapTitle: string): string {
if (mapTitle) {
return mapTitle
@@ -25,11 +34,19 @@ function resolveTitle(definition: GameDefinition | null, mapTitle: string): stri
return '本局结果'
}
function buildHeroValue(definition: GameDefinition | null, sessionState: GameSessionState, telemetryPresentation: TelemetryPresentation): string {
function buildHeroValue(
definition: GameDefinition | null,
sessionState: GameSessionState,
telemetryPresentation: TelemetryPresentation,
metrics?: ResultSummaryMetrics,
): string {
const totalScore = metrics && typeof metrics.totalScore === 'number'
? metrics.totalScore
: sessionState.score
if (definition && definition.mode === 'score-o') {
return `${sessionState.score}`
return `${totalScore}`
}
return telemetryPresentation.timerText
return telemetryPresentation.elapsedTimerText
}
function buildHeroLabel(definition: GameDefinition | null): string {
@@ -40,6 +57,9 @@ function buildSubtitle(sessionState: GameSessionState): string {
if (sessionState.status === 'finished') {
return '本局已完成'
}
if (sessionState.endReason === 'timed_out') {
return '本局超时结束'
}
if (sessionState.status === 'failed') {
return '本局已结束'
}
@@ -51,9 +71,11 @@ export function buildResultSummarySnapshot(
sessionState: GameSessionState | null,
telemetryPresentation: TelemetryPresentation,
mapTitle: string,
metrics?: ResultSummaryMetrics,
): ResultSummarySnapshot {
const resolvedSessionState: GameSessionState = sessionState || {
status: 'idle',
endReason: null,
startedAt: null,
endedAt: null,
completedControlIds: [],
@@ -71,21 +93,45 @@ export function buildResultSummarySnapshot(
const averageHeartRateText = telemetryPresentation.heartRateValueText !== '--'
? `${telemetryPresentation.heartRateValueText} ${telemetryPresentation.heartRateUnitText || 'bpm'}`
: '--'
const totalScore = metrics && typeof metrics.totalScore === 'number' ? metrics.totalScore : resolvedSessionState.score
const baseScore = metrics && typeof metrics.baseScore === 'number' ? metrics.baseScore : resolvedSessionState.score
const bonusScore = metrics && typeof metrics.bonusScore === 'number' ? metrics.bonusScore : 0
const quizCorrectCount = metrics && typeof metrics.quizCorrectCount === 'number' ? metrics.quizCorrectCount : 0
const quizWrongCount = metrics && typeof metrics.quizWrongCount === 'number' ? metrics.quizWrongCount : 0
const quizTimeoutCount = metrics && typeof metrics.quizTimeoutCount === 'number' ? metrics.quizTimeoutCount : 0
const includeQuizRows = bonusScore > 0 || quizCorrectCount > 0 || quizWrongCount > 0 || quizTimeoutCount > 0
const rows: ResultSummaryRow[] = [
{
label: '状态',
value: resolvedSessionState.endReason === 'timed_out'
? '超时结束'
: resolvedSessionState.status === 'finished'
? '完成'
: (resolvedSessionState.status === 'failed' ? '结束' : '进行中'),
},
{ label: '完成点数', value: totalControlCount > 0 ? `${resolvedSessionState.completedControlIds.length}/${totalControlCount}` : `${resolvedSessionState.completedControlIds.length}` },
{ label: '跳过点数', value: `${skippedCount}` },
{ label: '总分', value: `${totalScore}` },
]
if (includeQuizRows) {
rows.push({ label: '基础积分', value: `${baseScore}` })
rows.push({ label: '答题奖励积分', value: `${bonusScore}` })
rows.push({ label: '答题正确数', value: `${quizCorrectCount}` })
rows.push({ label: '答题错误数', value: `${quizWrongCount}` })
rows.push({ label: '答题超时数', value: `${quizTimeoutCount}` })
}
rows.push({ label: '累计里程', value: telemetryPresentation.mileageText })
rows.push({ label: '平均速度', value: `${telemetryPresentation.averageSpeedValueText}${telemetryPresentation.averageSpeedUnitText}` })
rows.push({ label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` })
rows.push({ label: '平均心率', value: averageHeartRateText })
return {
title: resolveTitle(definition, mapTitle),
subtitle: buildSubtitle(resolvedSessionState),
heroLabel: buildHeroLabel(definition),
heroValue: buildHeroValue(definition, resolvedSessionState, telemetryPresentation),
rows: [
{ label: '状态', value: resolvedSessionState.status === 'finished' ? '完成' : (resolvedSessionState.status === 'failed' ? '结束' : '进行中') },
{ label: '完成点数', value: totalControlCount > 0 ? `${resolvedSessionState.completedControlIds.length}/${totalControlCount}` : `${resolvedSessionState.completedControlIds.length}` },
{ label: '跳过点数', value: `${skippedCount}` },
{ label: '累计里程', value: telemetryPresentation.mileageText },
{ label: '平均速度', value: `${telemetryPresentation.averageSpeedValueText}${telemetryPresentation.averageSpeedUnitText}` },
{ label: '当前得分', value: `${resolvedSessionState.score}` },
{ label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` },
{ label: '平均心率', value: averageHeartRateText },
],
heroValue: buildHeroValue(definition, resolvedSessionState, telemetryPresentation, metrics),
rows,
}
}

View File

@@ -79,15 +79,23 @@ function getTargetText(control: GameControl): string {
}
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
if (distanceMeters <= definition.punchRadiusMeters) {
const audioConfig = definition.audioConfig || DEFAULT_GAME_AUDIO_CONFIG
const readyDistanceMeters = Math.max(definition.punchRadiusMeters, audioConfig.readyDistanceMeters)
const approachDistanceMeters = Math.max(readyDistanceMeters, audioConfig.approachDistanceMeters)
const distantDistanceMeters = Math.max(approachDistanceMeters, audioConfig.distantDistanceMeters)
if (distanceMeters <= readyDistanceMeters) {
return 'ready'
}
const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
if (distanceMeters <= approachDistanceMeters) {
return 'approaching'
}
if (distanceMeters <= distantDistanceMeters) {
return 'distant'
}
return 'searching'
}
@@ -129,6 +137,29 @@ function buildPunchHintText(definition: GameDefinition, state: GameSessionState,
: `${targetText}内,可点击打点`
}
function buildTargetSummaryText(state: GameSessionState, currentTarget: GameControl | null): string {
if (state.status === 'finished') {
return '本局已完成'
}
if (!currentTarget) {
return '等待路线初始化'
}
if (currentTarget.kind === 'start') {
return `${currentTarget.label} / 先打开始点`
}
if (currentTarget.kind === 'finish') {
return `${currentTarget.label} / 前往终点`
}
const sequenceText = typeof currentTarget.sequence === 'number'
? `${currentTarget.sequence}`
: '当前目标点'
return `${sequenceText} / ${currentTarget.label}`
}
function buildSkipFeedbackText(currentTarget: GameControl): string {
if (currentTarget.kind === 'start') {
return '开始点不可跳过'
@@ -193,6 +224,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
const hudPresentation: HudPresentationState = {
actionTagText: '目标',
distanceTagText: '点距',
targetSummaryText: buildTargetSummaryText(state, currentTarget),
hudTargetControlId: currentTarget ? currentTarget.id : null,
progressText: '0/0',
punchButtonText,
@@ -200,6 +232,12 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
punchButtonEnabled,
punchHintText: buildPunchHintText(definition, state, currentTarget),
}
const targetingPresentation = {
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
guidanceControlId: currentTarget ? currentTarget.id : null,
hudControlId: currentTarget ? currentTarget.id : null,
highlightedControlId: running && currentTarget ? currentTarget.id : null,
}
if (!scoringControls.length) {
return {
@@ -223,6 +261,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
skippedControlSequences: [],
},
hud: hudPresentation,
targeting: targetingPresentation,
}
}
@@ -255,6 +294,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
...hudPresentation,
progressText: `${completedControls.length}/${scoringControls.length}`,
},
targeting: targetingPresentation,
}
}
@@ -283,6 +323,9 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
const allowAutoPopup = punchPolicy === 'enter'
? false
: (control.displayContent ? control.displayContent.autoPopup : true)
const autoOpenQuiz = control.kind === 'control'
&& !!control.displayContent
&& control.displayContent.ctas.some((item) => item.type === 'quiz')
if (control.kind === 'start') {
return {
type: 'control_completed',
@@ -295,6 +338,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
autoOpenQuiz: false,
}
}
@@ -310,6 +354,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 2,
autoOpenQuiz: false,
}
}
@@ -328,6 +373,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
autoOpenQuiz,
}
}
@@ -340,6 +386,7 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl)
const nextState: GameSessionState = {
...state,
endReason: finished ? 'completed' : state.endReason,
startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt,
completedControlIds,
skippedControlIds: currentTarget.id === state.currentTargetControlId
@@ -410,6 +457,7 @@ export class ClassicSequentialRule implements RulePlugin {
initialize(definition: GameDefinition): GameSessionState {
return {
status: 'idle',
endReason: null,
startedAt: null,
endedAt: null,
completedControlIds: [],
@@ -434,6 +482,7 @@ export class ClassicSequentialRule implements RulePlugin {
const nextState: GameSessionState = {
...state,
status: 'running',
endReason: null,
startedAt: null,
endedAt: null,
inRangeControlId: null,
@@ -454,6 +503,7 @@ export class ClassicSequentialRule implements RulePlugin {
const nextState: GameSessionState = {
...state,
status: 'finished',
endReason: 'completed',
endedAt: event.at,
guidanceState: 'searching',
modeState: {
@@ -468,6 +518,25 @@ export class ClassicSequentialRule implements RulePlugin {
}
}
if (event.type === 'session_timed_out') {
const nextState: GameSessionState = {
...state,
status: 'failed',
endReason: 'timed_out',
endedAt: event.at,
guidanceState: 'searching',
modeState: {
mode: 'classic-sequential',
phase: 'done',
},
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [{ type: 'session_timed_out' }],
}
}
if (state.status !== 'running' || !state.currentTargetControlId) {
return {
nextState: state,

View File

@@ -12,6 +12,7 @@ import { type RulePlugin } from './rulePlugin'
type ScoreOModeState = {
phase: 'start' | 'controls' | 'finish' | 'done'
focusedControlId: string | null
guidanceControlId: string | null
}
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
@@ -46,6 +47,7 @@ function getModeState(state: GameSessionState): ScoreOModeState {
return {
phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start',
focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null,
guidanceControlId: rawModeState && typeof rawModeState.guidanceControlId === 'string' ? rawModeState.guidanceControlId : null,
}
}
@@ -56,12 +58,27 @@ function withModeState(state: GameSessionState, modeState: ScoreOModeState): Gam
}
}
function hasCompletedEnoughControlsForFinish(definition: GameDefinition, state: GameSessionState): boolean {
const completedScoreControls = getScoreControls(definition)
.filter((control) => state.completedControlIds.includes(control.id))
.length
return completedScoreControls >= definition.minCompletedControlsBeforeFinish
}
function canFocusFinish(definition: GameDefinition, state: GameSessionState): boolean {
const startControl = getStartControl(definition)
const finishControl = getFinishControl(definition)
const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
return completedStart && !completedFinish
return completedStart && !completedFinish && hasCompletedEnoughControlsForFinish(definition, state)
}
function isFinishPunchAvailable(
definition: GameDefinition,
state: GameSessionState,
_modeState: ScoreOModeState,
): boolean {
return canFocusFinish(definition, state)
}
function getNearestRemainingControl(
@@ -91,6 +108,38 @@ function getNearestRemainingControl(
return nearestControl
}
function getNearestGuidanceTarget(
definition: GameDefinition,
state: GameSessionState,
modeState: ScoreOModeState,
referencePoint: LonLatPoint,
): GameControl | null {
const candidates = getRemainingScoreControls(definition, state).slice()
if (isFinishPunchAvailable(definition, state, modeState)) {
const finishControl = getFinishControl(definition)
if (finishControl && !state.completedControlIds.includes(finishControl.id)) {
candidates.push(finishControl)
}
}
if (!candidates.length) {
return null
}
let nearestControl = candidates[0]
let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point)
for (let index = 1; index < candidates.length; index += 1) {
const control = candidates[index]
const distance = getApproxDistanceMeters(referencePoint, control.point)
if (distance < nearestDistance) {
nearestControl = control
nearestDistance = distance
}
}
return nearestControl
}
function getFocusedTarget(
definition: GameDefinition,
state: GameSessionState,
@@ -118,6 +167,7 @@ function getFocusedTarget(
function resolveInteractiveTarget(
definition: GameDefinition,
state: GameSessionState,
modeState: ScoreOModeState,
primaryTarget: GameControl | null,
focusedTarget: GameControl | null,
@@ -126,11 +176,23 @@ function resolveInteractiveTarget(
return primaryTarget
}
if (modeState.phase === 'finish') {
return primaryTarget
}
if (definition.requiresFocusSelection) {
return focusedTarget
}
return focusedTarget || primaryTarget
if (focusedTarget) {
return focusedTarget
}
if (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState)) {
return getFinishControl(definition)
}
return primaryTarget
}
function getNearestInRangeControl(
@@ -157,15 +219,23 @@ function getNearestInRangeControl(
}
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
if (distanceMeters <= definition.punchRadiusMeters) {
const audioConfig = definition.audioConfig || DEFAULT_GAME_AUDIO_CONFIG
const readyDistanceMeters = Math.max(definition.punchRadiusMeters, audioConfig.readyDistanceMeters)
const approachDistanceMeters = Math.max(readyDistanceMeters, audioConfig.approachDistanceMeters)
const distantDistanceMeters = Math.max(approachDistanceMeters, audioConfig.distantDistanceMeters)
if (distanceMeters <= readyDistanceMeters) {
return 'ready'
}
const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
if (distanceMeters <= approachDistanceMeters) {
return 'approaching'
}
if (distanceMeters <= distantDistanceMeters) {
return 'distant'
}
return 'searching'
}
@@ -210,13 +280,11 @@ function buildPunchHintText(
const modeState = getModeState(state)
if (modeState.phase === 'controls' || modeState.phase === 'finish') {
if (definition.requiresFocusSelection && !focusedTarget) {
return modeState.phase === 'finish'
? '点击地图选中终点后结束比赛'
: '点击地图选中一个目标点'
if (modeState.phase === 'controls' && definition.requiresFocusSelection && !focusedTarget) {
return '点击地图选中一个目标点'
}
const displayTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
const displayTarget = resolveInteractiveTarget(definition, state, modeState, primaryTarget, focusedTarget)
const targetLabel = getDisplayTargetLabel(displayTarget)
if (displayTarget && state.inRangeControlId === displayTarget.id) {
return definition.punchPolicy === 'enter'
@@ -241,10 +309,55 @@ function buildPunchHintText(
: `进入${targetLabel}后点击打点`
}
function buildTargetSummaryText(
definition: GameDefinition,
state: GameSessionState,
primaryTarget: GameControl | null,
focusedTarget: GameControl | null,
): string {
if (state.status === 'idle') {
return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点'
}
if (state.status === 'finished') {
return '本局已完成'
}
const modeState = getModeState(state)
if (modeState.phase === 'start') {
return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点'
}
if (modeState.phase === 'finish') {
return primaryTarget ? `${primaryTarget.label} / 可随时结束` : '可前往终点结束'
}
if (focusedTarget && focusedTarget.kind === 'control') {
return `${focusedTarget.label} / ${getControlScore(focusedTarget)} 分目标`
}
if (focusedTarget && focusedTarget.kind === 'finish') {
return `${focusedTarget.label} / 结束比赛`
}
if (definition.requiresFocusSelection) {
return '请选择目标点'
}
if (primaryTarget && primaryTarget.kind === 'control') {
return `${primaryTarget.label} / ${getControlScore(primaryTarget)} 分目标`
}
return primaryTarget ? primaryTarget.label : '自由打点'
}
function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect {
const allowAutoPopup = punchPolicy === 'enter'
? false
: (control.displayContent ? control.displayContent.autoPopup : true)
const autoOpenQuiz = control.kind === 'control'
&& !!control.displayContent
&& control.displayContent.ctas.some((item) => item.type === 'quiz')
if (control.kind === 'start') {
return {
type: 'control_completed',
@@ -257,6 +370,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
autoOpenQuiz: false,
}
}
@@ -272,6 +386,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 2,
autoOpenQuiz: false,
}
}
@@ -287,9 +402,50 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
autoOpenQuiz,
}
}
function resolvePunchableControl(
definition: GameDefinition,
state: GameSessionState,
modeState: ScoreOModeState,
focusedTarget: GameControl | null,
): GameControl | null {
if (!state.inRangeControlId) {
return null
}
const inRangeControl = definition.controls.find((control) => control.id === state.inRangeControlId) || null
if (!inRangeControl) {
return null
}
if (modeState.phase === 'start') {
return inRangeControl.kind === 'start' ? inRangeControl : null
}
if (modeState.phase === 'finish') {
return inRangeControl.kind === 'finish' ? inRangeControl : null
}
if (modeState.phase === 'controls') {
if (inRangeControl.kind === 'finish' && isFinishPunchAvailable(definition, state, modeState)) {
return inRangeControl
}
if (definition.requiresFocusSelection) {
return focusedTarget && inRangeControl.id === focusedTarget.id ? inRangeControl : null
}
if (inRangeControl.kind === 'control') {
return inRangeControl
}
}
return null
}
function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
const modeState = getModeState(state)
const running = state.status === 'running'
@@ -315,14 +471,35 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
.filter((control) => typeof control.sequence === 'number')
.map((control) => control.sequence as number)
const revealFullCourse = completedStart
const interactiveTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
const punchableControl = resolvePunchableControl(definition, state, modeState, focusedTarget)
const guidanceControl = modeState.guidanceControlId
? definition.controls.find((control) => control.id === modeState.guidanceControlId) || null
: null
const punchButtonEnabled = running
&& definition.punchPolicy === 'enter-confirm'
&& !!interactiveTarget
&& state.inRangeControlId === interactiveTarget.id
&& !!punchableControl
const hudTargetControlId = modeState.phase === 'finish'
? (primaryTarget ? primaryTarget.id : null)
: focusedTarget
? focusedTarget.id
: modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState)
? (getFinishControl(definition) ? getFinishControl(definition)!.id : null)
: definition.requiresFocusSelection
? null
: primaryTarget
? primaryTarget.id
: null
const highlightedControlId = focusedTarget
? focusedTarget.id
: punchableControl
? punchableControl.id
: guidanceControl
? guidanceControl.id
: null
const showMultiTargetLabels = completedStart && modeState.phase !== 'start'
const mapPresentation: MapPresentationState = {
controlVisualMode: modeState.phase === 'controls' ? 'multi-target' : 'single-target',
controlVisualMode: showMultiTargetLabels ? 'multi-target' : 'single-target',
showCourseLegs: false,
guidanceLegAnimationEnabled: false,
focusableControlIds: canSelectFinish
@@ -336,7 +513,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
activeControlSequences,
activeStart: running && modeState.phase === 'start',
completedStart,
activeFinish: running && modeState.phase === 'finish',
activeFinish: running && (modeState.phase === 'finish' || (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState))),
focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish',
completedFinish,
revealFullCourse,
@@ -351,41 +528,48 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
const hudPresentation: HudPresentationState = {
actionTagText: modeState.phase === 'start'
? '目标'
: modeState.phase === 'finish'
? '终点'
: focusedTarget && focusedTarget.kind === 'finish'
? '终点'
: modeState.phase === 'finish'
? '终点'
: focusedTarget
? '目标'
: '自由',
distanceTagText: modeState.phase === 'start'
? '点距'
: modeState.phase === 'finish'
? '终点距'
: focusedTarget && focusedTarget.kind === 'finish'
? '终点距'
: focusedTarget
? '选中点距'
: modeState.phase === 'finish'
? '终点距'
: '最近点距',
hudTargetControlId: focusedTarget
? focusedTarget.id
: primaryTarget
? primaryTarget.id
: null,
: '目标距',
targetSummaryText: buildTargetSummaryText(definition, state, primaryTarget, focusedTarget),
hudTargetControlId,
progressText: `已收集 ${completedControls.length}/${scoreControls.length}`,
punchableControlId: punchButtonEnabled && interactiveTarget ? interactiveTarget.id : null,
punchableControlId: punchableControl ? punchableControl.id : null,
punchButtonEnabled,
punchButtonText: modeState.phase === 'start'
? '开始打卡'
: (punchableControl && punchableControl.kind === 'finish')
? '结束打卡'
: modeState.phase === 'finish'
? '结束打卡'
: focusedTarget && focusedTarget.kind === 'finish'
? '结束打卡'
: modeState.phase === 'finish'
? '结束打卡'
: '打点',
: '打点',
punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget),
}
return {
map: mapPresentation,
hud: hudPresentation,
targeting: {
punchableControlId: punchableControl ? punchableControl.id : null,
guidanceControlId: guidanceControl ? guidanceControl.id : null,
hudControlId: hudTargetControlId,
highlightedControlId,
},
}
}
@@ -402,6 +586,7 @@ function applyCompletion(
const previousModeState = getModeState(state)
const nextStateDraft: GameSessionState = {
...state,
endReason: control.kind === 'finish' ? 'completed' : state.endReason,
startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt,
endedAt: control.kind === 'finish' ? at : state.endedAt,
completedControlIds,
@@ -424,15 +609,16 @@ function applyCompletion(
phase = remainingControls.length ? 'controls' : 'finish'
}
const nextModeState: ScoreOModeState = {
phase,
focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId,
}
const nextPrimaryTarget = phase === 'controls'
? getNearestRemainingControl(definition, nextStateDraft, referencePoint)
: phase === 'finish'
? getFinishControl(definition)
: null
const nextModeState: ScoreOModeState = {
phase,
focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId,
guidanceControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
}
const nextState = withModeState({
...nextStateDraft,
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
@@ -459,6 +645,7 @@ export class ScoreORule implements RulePlugin {
const startControl = getStartControl(definition)
return {
status: 'idle',
endReason: null,
startedAt: null,
endedAt: null,
completedControlIds: [],
@@ -470,6 +657,7 @@ export class ScoreORule implements RulePlugin {
modeState: {
phase: 'start',
focusedControlId: null,
guidanceControlId: startControl ? startControl.id : null,
},
}
}
@@ -484,6 +672,7 @@ export class ScoreORule implements RulePlugin {
const nextState = withModeState({
...state,
status: 'running',
endReason: null,
startedAt: null,
endedAt: null,
currentTargetControlId: startControl ? startControl.id : null,
@@ -492,6 +681,7 @@ export class ScoreORule implements RulePlugin {
}, {
phase: 'start',
focusedControlId: null,
guidanceControlId: startControl ? startControl.id : null,
})
return {
nextState,
@@ -504,11 +694,13 @@ export class ScoreORule implements RulePlugin {
const nextState = withModeState({
...state,
status: 'finished',
endReason: 'completed',
endedAt: event.at,
guidanceState: 'searching',
}, {
phase: 'done',
focusedControlId: null,
guidanceControlId: null,
})
return {
nextState,
@@ -517,6 +709,25 @@ export class ScoreORule implements RulePlugin {
}
}
if (event.type === 'session_timed_out') {
const nextState = withModeState({
...state,
status: 'failed',
endReason: 'timed_out',
endedAt: event.at,
guidanceState: 'searching',
}, {
phase: 'done',
focusedControlId: null,
guidanceControlId: null,
})
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [{ type: 'session_timed_out' }],
}
}
if (state.status !== 'running') {
return {
nextState: state,
@@ -533,25 +744,30 @@ export class ScoreORule implements RulePlugin {
if (event.type === 'gps_updated') {
const referencePoint = { lon: event.lon, lat: event.lat }
const remainingControls = getRemainingScoreControls(definition, state)
const focusedTarget = getFocusedTarget(definition, state, remainingControls)
const nextStateBase = withModeState(state, modeState)
const focusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls)
let nextPrimaryTarget = targetControl
let guidanceTarget = targetControl
let punchTarget: GameControl | null = null
if (modeState.phase === 'controls') {
nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint)
guidanceTarget = focusedTarget || nextPrimaryTarget
guidanceTarget = getNearestGuidanceTarget(definition, state, modeState, referencePoint)
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
punchTarget = focusedTarget
} else if (!definition.requiresFocusSelection) {
} else if (isFinishPunchAvailable(definition, state, modeState)) {
const finishControl = getFinishControl(definition)
if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) {
punchTarget = finishControl
}
}
if (!punchTarget && !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) {
guidanceTarget = nextPrimaryTarget
if (nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) {
punchTarget = nextPrimaryTarget
}
} else if (targetControl) {
@@ -565,15 +781,19 @@ export class ScoreORule implements RulePlugin {
? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint))
: 'searching'
const nextState: GameSessionState = {
...state,
...nextStateBase,
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
inRangeControlId: punchTarget ? punchTarget.id : null,
guidanceState,
}
const nextStateWithMode = withModeState(nextState, {
...modeState,
guidanceControlId: guidanceTarget ? guidanceTarget.id : null,
})
const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null)
if (definition.punchPolicy === 'enter' && punchTarget) {
const completionResult = applyCompletion(definition, nextState, punchTarget, event.at, referencePoint)
const completionResult = applyCompletion(definition, nextStateWithMode, punchTarget, event.at, referencePoint)
return {
...completionResult,
effects: [...guidanceEffects, ...completionResult.effects],
@@ -581,8 +801,8 @@ export class ScoreORule implements RulePlugin {
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
nextState: nextStateWithMode,
presentation: buildPresentation(definition, nextStateWithMode),
effects: guidanceEffects,
}
}
@@ -612,6 +832,7 @@ export class ScoreORule implements RulePlugin {
}, {
...modeState,
focusedControlId: nextFocusedControlId,
guidanceControlId: modeState.guidanceControlId,
})
return {
nextState,
@@ -622,11 +843,19 @@ export class ScoreORule implements RulePlugin {
if (event.type === 'punch_requested') {
const focusedTarget = getFocusedTarget(definition, state)
if (definition.requiresFocusSelection && (modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
let stateForPunch = state
const finishControl = getFinishControl(definition)
const finishInRange = !!(
finishControl
&& event.lon !== null
&& event.lat !== null
&& getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters
)
if (definition.requiresFocusSelection && modeState.phase === 'controls' && !focusedTarget && !finishInRange) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: modeState.phase === 'finish' ? '请先选中终点' : '请先选中目标点', tone: 'warning' }],
effects: [{ type: 'punch_feedback', text: '请先选中目标点', tone: 'warning' }],
}
}
@@ -635,13 +864,43 @@ export class ScoreORule implements RulePlugin {
controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
}
if (!controlToPunch || (definition.requiresFocusSelection && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
if (!controlToPunch && event.lon !== null && event.lat !== null) {
const referencePoint = { lon: event.lon, lat: event.lat }
const nextStateBase = withModeState(state, modeState)
stateForPunch = nextStateBase
const remainingControls = getRemainingScoreControls(definition, state)
const resolvedFocusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls)
if (resolvedFocusedTarget && getApproxDistanceMeters(resolvedFocusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
controlToPunch = resolvedFocusedTarget
} else if (isFinishPunchAvailable(definition, state, modeState)) {
const finishControl = getFinishControl(definition)
if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) {
controlToPunch = finishControl
}
}
if (!controlToPunch && !definition.requiresFocusSelection && modeState.phase === 'controls') {
controlToPunch = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
}
}
if (!controlToPunch || (definition.requiresFocusSelection && modeState.phase === 'controls' && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
const isFinishLockedAttempt = !!(
finishControl
&& event.lon !== null
&& event.lat !== null
&& getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters
&& !hasCompletedEnoughControlsForFinish(definition, state)
)
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{
type: 'punch_feedback',
text: focusedTarget
text: isFinishLockedAttempt
? `至少完成 ${definition.minCompletedControlsBeforeFinish} 个积分点后才能结束`
: focusedTarget
? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围`
: modeState.phase === 'start'
? '未进入开始点打卡范围'
@@ -651,7 +910,7 @@ export class ScoreORule implements RulePlugin {
}
}
return applyCompletion(definition, state, controlToPunch, event.at, this.getReferencePoint(definition, state, controlToPunch))
return applyCompletion(definition, stateForPunch, controlToPunch, event.at, this.getReferencePoint(definition, stateForPunch, controlToPunch))
}
return {

View File

@@ -0,0 +1,36 @@
import { mergeTelemetryConfig, type TelemetryConfig } from './telemetryConfig'
export interface PlayerTelemetryProfile {
heartRateAge?: number
restingHeartRateBpm?: number
userWeightKg?: number
source?: 'server' | 'device' | 'manual'
updatedAt?: number
}
function pickTelemetryValue<T extends keyof TelemetryConfig>(
key: T,
activityConfig: Partial<TelemetryConfig> | null | undefined,
playerProfile: PlayerTelemetryProfile | null | undefined,
): TelemetryConfig[T] | undefined {
if (playerProfile && playerProfile[key] !== undefined) {
return playerProfile[key] as TelemetryConfig[T]
}
if (activityConfig && activityConfig[key] !== undefined) {
return activityConfig[key] as TelemetryConfig[T]
}
return undefined
}
export function mergeTelemetrySources(
activityConfig?: Partial<TelemetryConfig> | null,
playerProfile?: PlayerTelemetryProfile | null,
): TelemetryConfig {
return mergeTelemetryConfig({
heartRateAge: pickTelemetryValue('heartRateAge', activityConfig, playerProfile),
restingHeartRateBpm: pickTelemetryValue('restingHeartRateBpm', activityConfig, playerProfile),
userWeightKg: pickTelemetryValue('userWeightKg', activityConfig, playerProfile),
})
}

View File

@@ -1,5 +1,7 @@
export interface TelemetryPresentation {
timerText: string
elapsedTimerText: string
timerMode: 'elapsed' | 'countdown'
mileageText: string
distanceToTargetValueText: string
distanceToTargetUnitText: string
@@ -19,6 +21,8 @@ export interface TelemetryPresentation {
export const EMPTY_TELEMETRY_PRESENTATION: TelemetryPresentation = {
timerText: '00:00:00',
elapsedTimerText: '00:00:00',
timerMode: 'elapsed',
mileageText: '0m',
distanceToTargetValueText: '--',
distanceToTargetUnitText: '',

View File

@@ -4,10 +4,10 @@ import {
getHeartRateToneLabel,
getHeartRateToneRangeText,
getSpeedToneRangeText,
mergeTelemetryConfig,
type HeartRateTone,
type TelemetryConfig,
} from './telemetryConfig'
import { mergeTelemetrySources, type PlayerTelemetryProfile } from './playerTelemetryProfile'
import { type GameSessionState } from '../core/gameSessionState'
import { type TelemetryEvent } from './telemetryEvent'
import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation'
@@ -17,6 +17,7 @@ import {
type HeadingConfidence,
type TelemetryState,
} from './telemetryState'
import { type RecoveryTelemetrySnapshot } from '../core/sessionRecovery'
const SPEED_SMOOTHING_ALPHA = 0.35
const DEVICE_HEADING_SMOOTHING_ALPHA = 0.28
const ACCELEROMETER_SMOOTHING_ALPHA = 0.2
@@ -109,6 +110,10 @@ function formatElapsedTimerText(totalMs: number): string {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
function formatCountdownTimerText(remainingMs: number): string {
return formatElapsedTimerText(Math.max(0, remainingMs))
}
function formatDistanceText(distanceMeters: number): string {
if (distanceMeters >= 1000) {
return `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}km`
@@ -419,10 +424,18 @@ function shouldTrackCalories(state: TelemetryState): boolean {
export class TelemetryRuntime {
state: TelemetryState
config: TelemetryConfig
activityConfig: TelemetryConfig
playerProfile: PlayerTelemetryProfile | null
sessionCloseAfterMs: number
sessionCloseWarningMs: number
constructor() {
this.state = { ...EMPTY_TELEMETRY_STATE }
this.config = { ...DEFAULT_TELEMETRY_CONFIG }
this.activityConfig = { ...DEFAULT_TELEMETRY_CONFIG }
this.playerProfile = null
this.sessionCloseAfterMs = 2 * 60 * 60 * 1000
this.sessionCloseWarningMs = 10 * 60 * 1000
}
reset(): void {
@@ -440,10 +453,102 @@ export class TelemetryRuntime {
}
configure(config?: Partial<TelemetryConfig> | null): void {
this.config = mergeTelemetryConfig(config)
this.activityConfig = mergeTelemetrySources(config, null)
this.syncEffectiveConfig()
}
applyCompiledProfile(
config: TelemetryConfig,
playerProfile?: PlayerTelemetryProfile | null,
): void {
this.activityConfig = { ...config }
this.playerProfile = playerProfile ? { ...playerProfile } : null
this.config = { ...config }
}
setPlayerProfile(profile?: PlayerTelemetryProfile | null): void {
this.playerProfile = profile ? { ...profile } : null
this.syncEffectiveConfig()
}
clearPlayerProfile(): void {
this.playerProfile = null
this.syncEffectiveConfig()
}
exportRecoveryState(): RecoveryTelemetrySnapshot {
return {
distanceMeters: this.state.distanceMeters,
currentSpeedKmh: this.state.currentSpeedKmh,
averageSpeedKmh: this.state.averageSpeedKmh,
heartRateBpm: this.state.heartRateBpm,
caloriesKcal: this.state.caloriesKcal,
lastGpsPoint: this.state.lastGpsPoint
? {
lon: this.state.lastGpsPoint.lon,
lat: this.state.lastGpsPoint.lat,
}
: null,
lastGpsAt: this.state.lastGpsAt,
lastGpsAccuracyMeters: this.state.lastGpsAccuracyMeters,
}
}
restoreRecoveryState(
definition: GameDefinition,
gameState: GameSessionState,
snapshot: RecoveryTelemetrySnapshot,
hudTargetControlId?: string | null,
): void {
const targetControlId = hudTargetControlId || null
const targetControl = targetControlId
? definition.controls.find((control) => control.id === targetControlId) || null
: null
this.sessionCloseAfterMs = definition.sessionCloseAfterMs
this.sessionCloseWarningMs = definition.sessionCloseWarningMs
this.state = {
...EMPTY_TELEMETRY_STATE,
accelerometer: this.state.accelerometer,
accelerometerUpdatedAt: this.state.accelerometerUpdatedAt,
accelerometerSampleCount: this.state.accelerometerSampleCount,
gyroscope: this.state.gyroscope,
deviceMotion: this.state.deviceMotion,
deviceHeadingDeg: this.state.deviceHeadingDeg,
devicePose: this.state.devicePose,
headingConfidence: this.state.headingConfidence,
sessionStatus: gameState.status,
sessionStartedAt: gameState.startedAt,
sessionEndedAt: gameState.endedAt,
elapsedMs: gameState.startedAt === null
? 0
: Math.max(0, ((gameState.endedAt || Date.now()) - gameState.startedAt)),
distanceMeters: snapshot.distanceMeters,
currentSpeedKmh: snapshot.currentSpeedKmh,
averageSpeedKmh: snapshot.averageSpeedKmh,
distanceToTargetMeters: targetControl && snapshot.lastGpsPoint
? getApproxDistanceMeters(snapshot.lastGpsPoint, targetControl.point)
: null,
targetControlId: targetControl ? targetControl.id : null,
targetPoint: targetControl ? targetControl.point : null,
lastGpsPoint: snapshot.lastGpsPoint
? {
lon: snapshot.lastGpsPoint.lon,
lat: snapshot.lastGpsPoint.lat,
}
: null,
lastGpsAt: snapshot.lastGpsAt,
lastGpsAccuracyMeters: snapshot.lastGpsAccuracyMeters,
heartRateBpm: snapshot.heartRateBpm,
caloriesKcal: snapshot.caloriesKcal,
calorieTrackingAt: snapshot.lastGpsAt,
}
this.recomputeDerivedState()
}
loadDefinition(_definition: GameDefinition): void {
this.sessionCloseAfterMs = _definition.sessionCloseAfterMs
this.sessionCloseWarningMs = _definition.sessionCloseWarningMs
this.reset()
}
@@ -632,6 +737,15 @@ export class TelemetryRuntime {
this.syncCalorieAccumulation(now)
this.alignCalorieTracking(now)
this.recomputeDerivedState(now)
const elapsedTimerText = formatElapsedTimerText(this.state.elapsedMs)
const countdownActive = this.state.sessionStatus === 'running'
&& this.state.sessionEndedAt === null
&& this.state.sessionStartedAt !== null
&& this.sessionCloseAfterMs > 0
&& (this.sessionCloseAfterMs - this.state.elapsedMs) <= this.sessionCloseWarningMs
const countdownRemainingMs = countdownActive
? Math.max(0, this.sessionCloseAfterMs - this.state.elapsedMs)
: 0
const targetDistance = formatTargetDistance(this.state.distanceToTargetMeters)
const hasHeartRate = hasHeartRateSignal(this.state)
const heartRateTone = hasHeartRate
@@ -643,7 +757,9 @@ export class TelemetryRuntime {
return {
...EMPTY_TELEMETRY_PRESENTATION,
timerText: formatElapsedTimerText(this.state.elapsedMs),
timerText: countdownActive ? formatCountdownTimerText(countdownRemainingMs) : elapsedTimerText,
elapsedTimerText,
timerMode: countdownActive ? 'countdown' : 'elapsed',
mileageText: formatDistanceText(this.state.distanceMeters),
distanceToTargetValueText: targetDistance.valueText,
distanceToTargetUnitText: targetDistance.unitText,
@@ -716,4 +832,8 @@ export class TelemetryRuntime {
}
}
}
private syncEffectiveConfig(): void {
this.config = mergeTelemetrySources(this.activityConfig, this.playerProfile)
}
}