feat: 收敛玩法运行时配置并加入故障恢复
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
55
miniprogram/game/core/gameModeDefaults.ts
Normal file
55
miniprogram/game/core/gameModeDefaults.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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[]
|
||||
|
||||
150
miniprogram/game/core/runtimeProfileCompiler.ts
Normal file
150
miniprogram/game/core/runtimeProfileCompiler.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
146
miniprogram/game/core/sessionRecovery.ts
Normal file
146
miniprogram/game/core/sessionRecovery.ts
Normal 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 {}
|
||||
}
|
||||
292
miniprogram/game/core/systemSettingsState.ts
Normal file
292
miniprogram/game/core/systemSettingsState.ts
Normal 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),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
36
miniprogram/game/telemetry/playerTelemetryProfile.ts
Normal file
36
miniprogram/game/telemetry/playerTelemetryProfile.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user