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

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

View File

@@ -1,6 +1,8 @@
// app.ts
App<IAppOption>({
globalData: {},
globalData: {
telemetryPlayerProfile: null,
},
onLaunch() {
// 展示本地存储能力
const logs = wx.getStorageSync('logs') || []
@@ -14,4 +16,4 @@ App<IAppOption>({
},
})
},
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -126,7 +126,7 @@ export class CourseLabelRenderer {
const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO)
const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO)
if (scene.controlVisualMode === 'multi-target') {
if (scene.gameMode === 'score-o' || scene.controlVisualMode === 'multi-target') {
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
@@ -139,7 +139,7 @@ export class CourseLabelRenderer {
ctx.fillStyle = this.getScoreLabelColor(scene, control.sequence)
ctx.translate(control.point.x, control.point.y)
ctx.rotate(scene.rotationRad)
ctx.fillText(String(control.sequence), 0, scoreOffsetY)
ctx.fillText(this.getControlLabelText(scene, control.sequence), 0, scoreOffsetY)
ctx.restore()
}
} else {
@@ -388,6 +388,16 @@ export class CourseLabelRenderer {
: rgbaToCss(resolvedStyle.color, 0.98)
}
getControlLabelText(scene: MapScene, sequence: number): string {
if (scene.gameMode === 'score-o') {
const score = scene.controlScoresBySequence[sequence]
if (typeof score === 'number' && Number.isFinite(score)) {
return String(score)
}
}
return String(sequence)
}
clearCanvas(ctx: any): void {
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)

View File

@@ -13,6 +13,47 @@ export interface ResolvedLegStyle {
color: RgbaColor
}
function resolveCompletedBoundaryEntry(scene: MapScene, baseEntry: ControlPointStyleEntry): ControlPointStyleEntry {
const completedPalette = scene.gameMode === 'score-o'
? scene.courseStyleConfig.scoreO.controls.collected
: scene.courseStyleConfig.sequential.controls.completed
return {
...baseEntry,
colorHex: completedPalette.colorHex,
labelColorHex: completedPalette.labelColorHex || baseEntry.labelColorHex,
glowStrength: 0,
}
}
function mergeControlStyleEntries(
baseEntry: ControlPointStyleEntry,
overrideEntry?: ControlPointStyleEntry | null,
): ControlPointStyleEntry {
if (!overrideEntry) {
return baseEntry
}
return {
...baseEntry,
...overrideEntry,
}
}
function mergeLegStyleEntries(
baseEntry: CourseLegStyleEntry,
overrideEntry?: CourseLegStyleEntry | null,
): CourseLegStyleEntry {
if (!overrideEntry) {
return baseEntry
}
return {
...baseEntry,
...overrideEntry,
}
}
export function hexToRgbaColor(hex: string, alphaOverride?: number): RgbaColor {
const fallback: RgbaColor = [1, 1, 1, alphaOverride !== undefined ? alphaOverride : 1]
if (typeof hex !== 'string' || !hex || hex.charAt(0) !== '#') {
@@ -59,24 +100,26 @@ function resolveScoreBandStyle(scene: MapScene, sequence: number): ScoreBandStyl
export function resolveControlStyle(scene: MapScene, kind: 'start' | 'control' | 'finish', sequence: number | null, index?: number): ResolvedControlStyle {
if (kind === 'start') {
if (index !== undefined && scene.startStyleOverrides[index]) {
const entry = scene.startStyleOverrides[index]
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
const entry = scene.gameMode === 'score-o'
const baseEntry = index !== undefined && scene.startStyleOverrides[index]
? scene.startStyleOverrides[index]
: scene.gameMode === 'score-o'
? scene.courseStyleConfig.scoreO.controls.start
: scene.courseStyleConfig.sequential.controls.start
const entry = scene.completedStart
? resolveCompletedBoundaryEntry(scene, baseEntry)
: baseEntry
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (kind === 'finish') {
if (index !== undefined && scene.finishStyleOverrides[index]) {
const entry = scene.finishStyleOverrides[index]
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
const entry = scene.gameMode === 'score-o'
const baseEntry = index !== undefined && scene.finishStyleOverrides[index]
? scene.finishStyleOverrides[index]
: scene.gameMode === 'score-o'
? scene.courseStyleConfig.scoreO.controls.finish
: scene.courseStyleConfig.sequential.controls.finish
const entry = scene.completedFinish
? resolveCompletedBoundaryEntry(scene, baseEntry)
: baseEntry
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
@@ -84,59 +127,81 @@ export function resolveControlStyle(scene: MapScene, kind: 'start' | 'control' |
const entry = scene.courseStyleConfig.sequential.controls.default
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.controlStyleOverridesBySequence[sequence]) {
const entry = scene.controlStyleOverridesBySequence[sequence]
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
const sequenceOverride = scene.controlStyleOverridesBySequence[sequence]
const defaultOverride = scene.defaultControlStyleOverride
if (scene.gameMode === 'score-o') {
if (scene.completedControlSequences.includes(sequence)) {
const entry = scene.courseStyleConfig.scoreO.controls.collected
const entry = mergeControlStyleEntries(
scene.courseStyleConfig.scoreO.controls.collected,
sequenceOverride || defaultOverride,
)
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.focusedControlSequences.includes(sequence)) {
const entry = scene.courseStyleConfig.scoreO.controls.focused
const bandEntry = resolveScoreBandStyle(scene, sequence)
const baseEntry = bandEntry || scene.courseStyleConfig.scoreO.controls.default
const focusedEntry = scene.courseStyleConfig.scoreO.controls.focused
const focusedMergedEntry: ControlPointStyleEntry = {
...baseEntry,
...focusedEntry,
colorHex: baseEntry.colorHex,
}
const entry = mergeControlStyleEntries(focusedMergedEntry, sequenceOverride || defaultOverride)
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
const bandEntry = resolveScoreBandStyle(scene, sequence)
const entry = bandEntry || scene.courseStyleConfig.scoreO.controls.default
const entry = mergeControlStyleEntries(
bandEntry || scene.courseStyleConfig.scoreO.controls.default,
sequenceOverride || defaultOverride,
)
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.readyControlSequences.includes(sequence) || scene.activeControlSequences.includes(sequence)) {
const entry = scene.courseStyleConfig.sequential.controls.current
const entry = mergeControlStyleEntries(
scene.courseStyleConfig.sequential.controls.current,
sequenceOverride || defaultOverride,
)
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.completedControlSequences.includes(sequence)) {
const entry = scene.courseStyleConfig.sequential.controls.completed
const entry = mergeControlStyleEntries(
scene.courseStyleConfig.sequential.controls.completed,
sequenceOverride || defaultOverride,
)
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.skippedControlSequences.includes(sequence)) {
const entry = scene.courseStyleConfig.sequential.controls.skipped
const entry = mergeControlStyleEntries(
scene.courseStyleConfig.sequential.controls.skipped,
sequenceOverride || defaultOverride,
)
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
const entry = scene.courseStyleConfig.sequential.controls.default
const entry = mergeControlStyleEntries(
scene.courseStyleConfig.sequential.controls.default,
sequenceOverride || defaultOverride,
)
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
export function resolveLegStyle(scene: MapScene, index: number): ResolvedLegStyle {
if (scene.legStyleOverridesByIndex[index]) {
const entry = scene.legStyleOverridesByIndex[index]
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.gameMode === 'score-o') {
const entry = scene.courseStyleConfig.sequential.legs.default
const entry = mergeLegStyleEntries(
scene.courseStyleConfig.sequential.legs.default,
scene.legStyleOverridesByIndex[index] || scene.defaultLegStyleOverride,
)
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
const completed = scene.completedLegIndices.includes(index)
const entry = completed ? scene.courseStyleConfig.sequential.legs.completed : scene.courseStyleConfig.sequential.legs.default
const baseEntry = completed ? scene.courseStyleConfig.sequential.legs.completed : scene.courseStyleConfig.sequential.legs.default
const entry = mergeLegStyleEntries(baseEntry, scene.legStyleOverridesByIndex[index] || scene.defaultLegStyleOverride)
return { entry, color: hexToRgbaColor(entry.colorHex) }
}

View File

@@ -42,9 +42,11 @@ export interface MapScene {
gameMode: 'classic-sequential' | 'score-o'
courseStyleConfig: CourseStyleConfig
controlScoresBySequence: Record<number, number>
defaultControlStyleOverride: ControlPointStyleEntry | null
controlStyleOverridesBySequence: Record<number, ControlPointStyleEntry>
startStyleOverrides: ControlPointStyleEntry[]
finishStyleOverrides: ControlPointStyleEntry[]
defaultLegStyleOverride: CourseLegStyleEntry | null
legStyleOverridesByIndex: Record<number, CourseLegStyleEntry>
controlVisualMode: 'single-target' | 'multi-target'
showCourseLegs: boolean

View File

@@ -18,6 +18,7 @@ export interface HeartRateInputControllerDebugState {
mockBridgeConnected: boolean
mockBridgeStatusText: string
mockBridgeUrlText: string
mockChannelIdText: string
mockHeartRateText: string
}
@@ -55,6 +56,7 @@ export class HeartRateInputController {
sourceMode: HeartRateSourceMode
mockBridgeStatusText: string
mockBridgeUrl: string
mockChannelId: string
mockBpm: number | null
constructor(callbacks: HeartRateInputControllerCallbacks) {
@@ -62,6 +64,7 @@ export class HeartRateInputController {
this.sourceMode = 'real'
this.mockBridgeUrl = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
this.mockChannelId = 'default'
this.mockBpm = null
const realCallbacks: HeartRateControllerCallbacks = {
@@ -194,6 +197,7 @@ export class HeartRateInputController {
mockBridgeConnected: this.mockBridge.connected,
mockBridgeStatusText: this.mockBridgeStatusText,
mockBridgeUrlText: this.mockBridgeUrl,
mockChannelIdText: this.mockChannelId,
mockHeartRateText: formatMockHeartRateText(this.mockBpm),
}
}
@@ -269,6 +273,16 @@ export class HeartRateInputController {
this.emitDebugState()
}
setMockChannelId(channelId: string): void {
const normalized = String(channelId || '').trim() || 'default'
this.mockChannelId = normalized
this.mockBridge.setChannelId(normalized)
if (this.sourceMode === 'mock') {
this.callbacks.onStatus(`模拟心率通道已切换到 ${normalized}`)
}
this.emitDebugState()
}
connectMockBridge(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
if (this.mockBridge.connected || this.mockBridge.connecting) {
if (this.sourceMode === 'mock') {

View File

@@ -12,6 +12,7 @@ export interface LocationControllerDebugState {
mockBridgeConnected: boolean
mockBridgeStatusText: string
mockBridgeUrlText: string
mockChannelIdText: string
mockCoordText: string
mockSpeedText: string
}
@@ -70,12 +71,14 @@ export class LocationController {
sourceMode: LocationSourceMode
mockBridgeStatusText: string
mockBridgeUrl: string
mockChannelId: string
constructor(callbacks: LocationControllerCallbacks) {
this.callbacks = callbacks
this.sourceMode = 'real'
this.mockBridgeUrl = DEFAULT_MOCK_LOCATION_BRIDGE_URL
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
this.mockChannelId = 'default'
const sourceCallbacks: LocationSourceCallbacks = {
onLocation: (sample) => {
@@ -129,6 +132,7 @@ export class LocationController {
mockBridgeConnected: this.mockBridge.connected,
mockBridgeStatusText: this.mockBridgeStatusText,
mockBridgeUrlText: this.mockBridgeUrl,
mockChannelIdText: this.mockChannelId,
mockCoordText: formatMockCoordText(this.mockSource.lastSample),
mockSpeedText: formatMockSpeedText(this.mockSource.lastSample),
}
@@ -187,6 +191,14 @@ export class LocationController {
this.emitDebugState()
}
setMockChannelId(channelId: string): void {
const normalized = String(channelId || '').trim() || 'default'
this.mockChannelId = normalized
this.mockBridge.setChannelId(normalized)
this.callbacks.onStatus(`模拟定位通道已切换到 ${normalized}`)
this.emitDebugState()
}
connectMockBridge(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
if (this.mockBridge.connected || this.mockBridge.connecting) {
this.callbacks.onStatus('模拟定位源已连接')

View File

@@ -11,6 +11,12 @@ type RawMockHeartRateMessage = {
type?: string
timestamp?: number
bpm?: number
channelId?: string
}
function normalizeMockChannelId(rawChannelId: string | null | undefined): string {
const trimmed = String(rawChannelId || '').trim()
return trimmed || 'default'
}
function safeParseMessage(data: string): RawMockHeartRateMessage | null {
@@ -21,11 +27,15 @@ function safeParseMessage(data: string): RawMockHeartRateMessage | null {
}
}
function toHeartRateValue(message: RawMockHeartRateMessage): number | null {
function toHeartRateValue(message: RawMockHeartRateMessage, expectedChannelId: string): number | null {
if (message.type !== 'mock_heart_rate' || !Number.isFinite(message.bpm)) {
return null
}
if (normalizeMockChannelId(message.channelId) !== expectedChannelId) {
return null
}
const bpm = Math.round(Number(message.bpm))
if (bpm <= 0) {
return null
@@ -40,6 +50,7 @@ export class MockHeartRateBridge {
connected: boolean
connecting: boolean
url: string
channelId: string
constructor(callbacks: MockHeartRateBridgeCallbacks) {
this.callbacks = callbacks
@@ -47,6 +58,11 @@ export class MockHeartRateBridge {
this.connected = false
this.connecting = false
this.url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
this.channelId = 'default'
}
setChannelId(channelId: string): void {
this.channelId = normalizeMockChannelId(channelId)
}
connect(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
@@ -96,7 +112,7 @@ export class MockHeartRateBridge {
return
}
const bpm = toHeartRateValue(parsed)
const bpm = toHeartRateValue(parsed, this.channelId)
if (bpm === null) {
return
}

View File

@@ -17,6 +17,12 @@ type RawMockGpsMessage = {
accuracyMeters?: number
speedMps?: number
headingDeg?: number
channelId?: string
}
function normalizeMockChannelId(rawChannelId: string | null | undefined): string {
const trimmed = String(rawChannelId || '').trim()
return trimmed || 'default'
}
function safeParseMessage(data: string): RawMockGpsMessage | null {
@@ -27,7 +33,7 @@ function safeParseMessage(data: string): RawMockGpsMessage | null {
}
}
function toLocationSample(message: RawMockGpsMessage): LocationSample | null {
function toLocationSample(message: RawMockGpsMessage, expectedChannelId: string): LocationSample | null {
if (message.type !== 'mock_gps') {
return null
}
@@ -36,6 +42,10 @@ function toLocationSample(message: RawMockGpsMessage): LocationSample | null {
return null
}
if (normalizeMockChannelId(message.channelId) !== expectedChannelId) {
return null
}
return {
latitude: Number(message.lat),
longitude: Number(message.lon),
@@ -53,6 +63,7 @@ export class MockLocationBridge {
connected: boolean
connecting: boolean
url: string
channelId: string
constructor(callbacks: MockLocationBridgeCallbacks) {
this.callbacks = callbacks
@@ -60,6 +71,11 @@ export class MockLocationBridge {
this.connected = false
this.connecting = false
this.url = DEFAULT_MOCK_LOCATION_BRIDGE_URL
this.channelId = 'default'
}
setChannelId(channelId: string): void {
this.channelId = normalizeMockChannelId(channelId)
}
connect(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
@@ -109,7 +125,7 @@ export class MockLocationBridge {
return
}
const sample = toLocationSample(parsed)
const sample = toLocationSample(parsed, this.channelId)
if (!sample) {
return
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -73,25 +73,26 @@
</view>
</view>
<view
class="game-content-card game-content-card--{{contentCardTemplate}} {{contentCardFxClass}}"
wx:if="{{contentCardVisible}}"
catchtap="handleContentCardTap"
>
<view class="game-content-card__title">{{contentCardTitle}}</view>
<view class="game-content-card__body">{{contentCardBody}}</view>
<view class="game-content-card__action-row {{contentCardActions.length ? 'game-content-card__action-row--split' : ''}}">
<view class="game-content-card__cta-group" wx:if="{{contentCardActions.length}}">
<view
wx:for="{{contentCardActions}}"
wx:key="key"
class="game-content-card__action"
data-type="{{item.type}}"
data-key="{{item.key}}"
catchtap="handleOpenContentCardAction"
>{{item.label}}</view>
<view class="game-content-card-layer" wx:if="{{contentCardVisible}}" bindtap="handleDismissTransientContentCard">
<view
class="game-content-card game-content-card--{{contentCardTemplate}} {{contentCardFxClass}}"
catchtap="handleContentCardTap"
>
<view class="game-content-card__title">{{contentCardTitle}}</view>
<view class="game-content-card__body">{{contentCardBody}}</view>
<view wx:if="{{contentCardActions.length}}" class="game-content-card__action-row game-content-card__action-row--split">
<view class="game-content-card__cta-group" wx:if="{{contentCardActions.length}}">
<view
wx:for="{{contentCardActions}}"
wx:key="key"
class="game-content-card__action"
data-type="{{item.type}}"
data-key="{{item.key}}"
catchtap="handleOpenContentCardAction"
>{{item.label}}</view>
</view>
<view class="game-content-card__close" catchtap="handleCloseContentCard">关闭</view>
</view>
<view class="game-content-card__close" catchtap="handleCloseContentCard">关闭</view>
</view>
</view>
@@ -118,7 +119,7 @@
</view>
</view>
<view class="game-punch-hint" wx:if="{{!showResultScene && showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap">
<view class="game-punch-hint {{punchHintFxClass}}" wx:if="{{!showResultScene && !contentCardVisible && !contentQuizVisible && showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap">
<view class="game-punch-hint__text">{{punchHintText}}</view>
<view class="game-punch-hint__close" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap" catchtap="handleClosePunchHint">×</view>
</view>
@@ -187,12 +188,15 @@
<view class="race-panel__grid">
<view class="race-panel__cell race-panel__cell--action">
<view class="race-panel__action-button"><!-- status only -->
<view class="race-panel__action-button-text">{{punchButtonText}}</view>
<view class="race-panel__action-stack">
<view class="race-panel__action-button {{punchButtonEnabled ? 'race-panel__action-button--active' : ''}}"><!-- status only -->
<view class="race-panel__action-button-text">{{punchButtonText}}</view>
</view>
<text class="race-panel__action-summary">{{panelTargetSummaryText}}</text>
</view>
</view>
<view class="race-panel__cell race-panel__cell--timer">
<text class="race-panel__timer {{panelTimerFxClass}}">{{panelTimerText}}</text>
<text class="race-panel__timer {{panelTimerFxClass}} {{panelTimerMode === 'countdown' ? 'race-panel__timer--countdown' : ''}}">{{panelTimerText}}</text>
</view>
<view class="race-panel__cell race-panel__cell--mileage">
<view class="race-panel__mileage-wrap {{panelMileageFxClass}}">
@@ -244,7 +248,7 @@
</view>
</view>
<view class="race-panel__cell race-panel__cell--timer">
<text class="race-panel__timer {{panelTimerFxClass}}">{{panelTimerText}}</text>
<text class="race-panel__timer {{panelTimerFxClass}} {{panelTimerMode === 'countdown' ? 'race-panel__timer--countdown' : ''}}">{{panelTimerText}}</text>
</view>
<view class="race-panel__cell race-panel__cell--mileage">
<view class="race-panel__metric-group race-panel__metric-group--right race-panel__metric-group--panel">
@@ -364,8 +368,8 @@
<view class="debug-section__title">01. 动画性能</view>
<view class="debug-section__desc">根据设备性能切换动画强度,低端机建议精简</view>
</view>
<view class="debug-section__lock {{lockAnimationLevel ? 'debug-section__lock--active' : ''}}" data-key="lockAnimationLevel" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockAnimationLevel ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockAnimationLevel ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockAnimationLevel ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -386,8 +390,8 @@
<view class="debug-section__title">02. 轨迹选项</view>
<view class="debug-section__desc">控制不显示、彗尾拖尾、全轨迹三种显示方式</view>
</view>
<view class="debug-section__lock {{lockTrackMode ? 'debug-section__lock--active' : ''}}" data-key="lockTrackMode" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockTrackMode ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockTrackMode ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockTrackMode ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -411,8 +415,8 @@
<view class="debug-section__title">03. 轨迹尾巴</view>
<view class="debug-section__desc">拖尾模式下控制尾巴长短,跑得越快会在此基础上再拉长</view>
</view>
<view class="debug-section__lock {{lockTrackTailLength ? 'debug-section__lock--active' : ''}}" data-key="lockTrackTailLength" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockTrackTailLength ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockTrackTailLength ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockTrackTailLength ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -436,8 +440,8 @@
<view class="debug-section__title">04. 轨迹颜色</view>
<view class="debug-section__desc">亮色轨迹调色盘,运行中会按速度和心率张力自动提亮</view>
</view>
<view class="debug-section__lock {{lockTrackColor ? 'debug-section__lock--active' : ''}}" data-key="lockTrackColor" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockTrackColor ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockTrackColor ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockTrackColor ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -468,8 +472,8 @@
<view class="debug-section__title">05. 轨迹风格</view>
<view class="debug-section__desc">切换经典线条和流光轨迹风格,默认推荐流光</view>
</view>
<view class="debug-section__lock {{lockTrackStyle ? 'debug-section__lock--active' : ''}}" data-key="lockTrackStyle" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockTrackStyle ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockTrackStyle ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockTrackStyle ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -490,8 +494,8 @@
<view class="debug-section__title">06. GPS点显示</view>
<view class="debug-section__desc">控制地图上的 GPS 定位点显示与隐藏</view>
</view>
<view class="debug-section__lock {{lockGpsMarkerVisible ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerVisible" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockGpsMarkerVisible ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockGpsMarkerVisible ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockGpsMarkerVisible ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -512,8 +516,8 @@
<view class="debug-section__title">07. GPS点大小</view>
<view class="debug-section__desc">控制定位点本体和朝向小三角的整体尺寸</view>
</view>
<view class="debug-section__lock {{lockGpsMarkerSize ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerSize" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockGpsMarkerSize ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockGpsMarkerSize ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockGpsMarkerSize ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -535,8 +539,8 @@
<view class="debug-section__title">08. GPS点颜色</view>
<view class="debug-section__desc">切换定位点主色,默认使用青绿高亮色</view>
</view>
<view class="debug-section__lock {{lockGpsMarkerColor ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerColor" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockGpsMarkerColor ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockGpsMarkerColor ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockGpsMarkerColor ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -567,8 +571,8 @@
<view class="debug-section__title">09. GPS点风格</view>
<view class="debug-section__desc">切换定位点底座风格,影响本体与外圈表现</view>
</view>
<view class="debug-section__lock {{lockGpsMarkerStyle ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerStyle" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockGpsMarkerStyle ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockGpsMarkerStyle ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockGpsMarkerStyle ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -591,8 +595,8 @@
<view class="debug-section__title">10. 按钮习惯</view>
<view class="debug-section__desc">切换功能按钮显示在左侧还是右侧,适配左手/右手操作习惯</view>
</view>
<view class="debug-section__lock {{lockSideButtonPlacement ? 'debug-section__lock--active' : ''}}" data-key="lockSideButtonPlacement" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockSideButtonPlacement ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockSideButtonPlacement ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockSideButtonPlacement ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -613,8 +617,8 @@
<view class="debug-section__title">11. 自动转图</view>
<view class="debug-section__desc">控制地图是否跟随朝向自动旋转,外部按钮与这里保持同步</view>
</view>
<view class="debug-section__lock {{lockAutoRotate ? 'debug-section__lock--active' : ''}}" data-key="lockAutoRotate" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockAutoRotate ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockAutoRotate ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockAutoRotate ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -635,8 +639,8 @@
<view class="debug-section__title">12. 指北针响应</view>
<view class="debug-section__desc">切换指针的平滑与跟手程度,影响指北针响应手感</view>
</view>
<view class="debug-section__lock {{lockCompassTuning ? 'debug-section__lock--active' : ''}}" data-key="lockCompassTuning" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockCompassTuning ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockCompassTuning ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockCompassTuning ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -658,8 +662,8 @@
<view class="debug-section__title">13. 比例尺显示</view>
<view class="debug-section__desc">控制比例尺显示与否,默认沿用你的本地偏好</view>
</view>
<view class="debug-section__lock {{lockScaleRulerVisible ? 'debug-section__lock--active' : ''}}" data-key="lockScaleRulerVisible" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockScaleRulerVisible ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockScaleRulerVisible ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockScaleRulerVisible ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -680,8 +684,8 @@
<view class="debug-section__title">14. 比例尺基准点</view>
<view class="debug-section__desc">设置比例尺零点锚定位置,可跟随屏幕中心或指北针圆心</view>
</view>
<view class="debug-section__lock {{lockScaleRulerAnchor ? 'debug-section__lock--active' : ''}}" data-key="lockScaleRulerAnchor" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockScaleRulerAnchor ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockScaleRulerAnchor ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockScaleRulerAnchor ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -702,8 +706,8 @@
<view class="debug-section__title">15. 北参考</view>
<view class="debug-section__desc">切换磁北/真北作为地图与指北针参考</view>
</view>
<view class="debug-section__lock {{lockNorthReference ? 'debug-section__lock--active' : ''}}" data-key="lockNorthReference" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockNorthReference ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockNorthReference ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockNorthReference ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -724,8 +728,8 @@
<view class="debug-section__title">16. 心率设备</view>
<view class="debug-section__desc">清除已记住的首选心率带设备,下次重新选择</view>
</view>
<view class="debug-section__lock {{lockHeartRateDevice ? 'debug-section__lock--active' : ''}}" data-key="lockHeartRateDevice" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockHeartRateDevice ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockHeartRateDevice ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockHeartRateDevice ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
@@ -801,6 +805,21 @@
<view class="control-chip control-chip--primary" bindtap="handleConnectAllMockSources">一键连接开发调试源</view>
<view class="control-chip control-chip--secondary" bindtap="handleOpenWebViewTest">测试 H5</view>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">模拟通道号</text>
<view class="debug-inline-stack">
<input
class="debug-input"
value="{{mockChannelIdDraft}}"
placeholder="default / runner-a"
bindinput="handleMockChannelIdInput"
/>
<view class="control-row control-row--compact">
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockChannelId">保存通道号</view>
</view>
</view>
<text class="info-panel__hint">当前通道:{{mockChannelIdText}}</text>
</view>
<view class="debug-group-title">定位模拟</view>
<view class="info-panel__row">
<text class="info-panel__label">GPS</text>
@@ -907,7 +926,7 @@
<input
class="debug-input"
value="{{mockHeartRateBridgeUrlDraft}}"
placeholder="ws://192.168.x.x:17865/mock-gps"
placeholder="ws://192.168.x.x:17865/mock-hr"
bindinput="handleMockHeartRateBridgeUrlInput"
/>
<view class="control-row control-row--compact">
@@ -932,7 +951,7 @@
<input
class="debug-input"
value="{{mockDebugLogBridgeUrlDraft}}"
placeholder="ws://192.168.x.x:17865/mock-gps"
placeholder="ws://192.168.x.x:17865/debug-log"
bindinput="handleMockDebugLogBridgeUrlInput"
/>
<view class="control-row control-row--compact">
@@ -1025,6 +1044,19 @@
<text class="info-panel__label">Accuracy</text>
<text class="info-panel__value">{{panelAccuracyValueText}} {{panelAccuracyUnitText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Timer</text>
<text class="info-panel__value">{{panelTimerText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Timer Mode</text>
<text class="info-panel__value">{{panelTimerMode === 'countdown' ? '倒计时' : '正计时'}}</text>
</view>
<view class="control-row control-row--triple">
<view class="control-chip control-chip--secondary" bindtap="handleDebugSetSessionRemainingWarning">剩10分钟</view>
<view class="control-chip control-chip--secondary" bindtap="handleDebugSetSessionRemainingOneMinute">剩1分钟</view>
<view class="control-chip control-chip--secondary" bindtap="handleDebugTimeoutSession">立即超时</view>
</view>
<view class="control-row control-row--triple">
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateBlue">蓝</view>
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRatePurple">紫</view>

View File

@@ -849,6 +849,12 @@
text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.2);
}
.race-panel__timer--countdown {
color: #ffe082;
text-shadow: 0 0 14rpx rgba(255, 176, 32, 0.42);
animation: race-panel-timer-countdown 1.15s ease-in-out infinite;
}
.race-panel__timer--fx-tick {
animation: race-panel-timer-tick 0.32s cubic-bezier(0.24, 0.86, 0.3, 1) 1;
}
@@ -973,6 +979,12 @@
100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes race-panel-timer-countdown {
0% { opacity: 0.88; transform: scale(1); }
50% { opacity: 1; transform: scale(1.03); }
100% { opacity: 0.88; transform: scale(1); }
}
@keyframes race-panel-mileage-update {
0% { transform: translateX(-16rpx) scale(1); opacity: 0.94; }
40% { transform: translateX(-16rpx) scale(1.05); opacity: 1; }
@@ -1612,6 +1624,7 @@
border-radius: 999rpx;
background: rgba(233, 242, 228, 0.92);
box-shadow: inset 0 0 0 1rpx rgba(22, 48, 32, 0.08);
pointer-events: none;
}
.debug-section__lock--active {
@@ -1942,6 +1955,10 @@
pointer-events: auto;
}
.game-punch-hint--fx-enter {
animation: game-punch-hint-enter 0.42s cubic-bezier(0.22, 0.88, 0.28, 1) 1;
}
.game-punch-hint__text {
flex: 1;
min-width: 0;
@@ -1961,6 +1978,26 @@
background: rgba(255, 255, 255, 0.08);
}
@keyframes game-punch-hint-enter {
0% {
opacity: 0;
transform: translateX(-50%) translateY(-16rpx) scale(0.96);
box-shadow: 0 0 0 0 rgba(120, 255, 210, 0);
}
58% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1.02);
box-shadow: 0 0 0 12rpx rgba(120, 255, 210, 0.12);
}
100% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
box-shadow: 0 0 0 0 rgba(120, 255, 210, 0);
}
}
.game-punch-feedback {
position: absolute;
left: 50%;
@@ -2002,6 +2039,13 @@
animation: feedback-toast-warning 0.56s ease-out;
}
.game-content-card-layer {
position: absolute;
inset: 0;
z-index: 34;
pointer-events: auto;
}
.game-content-card {
position: absolute;
left: 50%;
@@ -2014,7 +2058,7 @@
background: rgba(248, 251, 244, 0.96);
box-shadow: 0 18rpx 48rpx rgba(22, 48, 32, 0.18);
box-sizing: border-box;
z-index: 33;
z-index: 35;
pointer-events: auto;
}
@@ -2233,6 +2277,24 @@
color: rgba(236, 241, 246, 0.86);
}
.race-panel__action-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10rpx;
max-width: 100%;
}
.race-panel__action-summary {
max-width: 220rpx;
font-size: 18rpx;
line-height: 1.2;
color: rgba(233, 241, 248, 0.68);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.race-panel__action-button--active .race-panel__action-button-text {
color: #775000;
}

View File

@@ -0,0 +1,209 @@
export type DemoGamePreset = 'classic' | 'score-o'
export type BusinessLaunchSource = 'demo' | 'competition' | 'direct-event' | 'custom'
export interface GameConfigLaunchRequest {
configUrl: string
configLabel: string
configChecksumSha256?: string | null
releaseId?: string | null
routeCode?: string | null
}
export interface BusinessLaunchContext {
source: BusinessLaunchSource
competitionId?: string | null
eventId?: string | null
launchRequestId?: string | null
participantId?: string | null
sessionId?: string | null
sessionToken?: string | null
sessionTokenExpiresAt?: string | null
realtimeEndpoint?: string | null
realtimeToken?: string | null
}
export interface GameLaunchEnvelope {
config: GameConfigLaunchRequest
business: BusinessLaunchContext | null
}
export interface MapPageLaunchOptions {
launchId?: string
preset?: string
configUrl?: string
configLabel?: string
configChecksumSha256?: string
releaseId?: string
routeCode?: string
launchSource?: string
competitionId?: string
eventId?: string
launchRequestId?: string
participantId?: string
sessionId?: string
sessionToken?: string
sessionTokenExpiresAt?: string
realtimeEndpoint?: string
realtimeToken?: string
}
type PendingGameLaunchStore = Record<string, GameLaunchEnvelope>
const PENDING_GAME_LAUNCH_STORAGE_KEY = 'cmr.pendingGameLaunch.v1'
const CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json'
const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json'
function normalizeOptionalString(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const normalized = decodeURIComponent(value).trim()
return normalized ? normalized : null
}
function resolveDemoPreset(value: string | null): DemoGamePreset {
return value === 'score-o' ? 'score-o' : 'classic'
}
function resolveBusinessLaunchSource(value: string | null): BusinessLaunchSource {
if (value === 'competition' || value === 'direct-event' || value === 'custom') {
return value
}
return 'demo'
}
function buildDemoConfig(preset: DemoGamePreset): GameConfigLaunchRequest {
if (preset === 'score-o') {
return {
configUrl: SCORE_O_REMOTE_GAME_CONFIG_URL,
configLabel: '积分赛配置',
}
}
return {
configUrl: CLASSIC_REMOTE_GAME_CONFIG_URL,
configLabel: '顺序赛配置',
}
}
function hasBusinessFields(context: Omit<BusinessLaunchContext, 'source'>): boolean {
return Object.values(context).some((value) => typeof value === 'string' && value.length > 0)
}
function buildBusinessLaunchContext(options?: MapPageLaunchOptions | null): BusinessLaunchContext | null {
if (!options) {
return null
}
const context = {
competitionId: normalizeOptionalString(options.competitionId),
eventId: normalizeOptionalString(options.eventId),
launchRequestId: normalizeOptionalString(options.launchRequestId),
participantId: normalizeOptionalString(options.participantId),
sessionId: normalizeOptionalString(options.sessionId),
sessionToken: normalizeOptionalString(options.sessionToken),
sessionTokenExpiresAt: normalizeOptionalString(options.sessionTokenExpiresAt),
realtimeEndpoint: normalizeOptionalString(options.realtimeEndpoint),
realtimeToken: normalizeOptionalString(options.realtimeToken),
}
const launchSource = normalizeOptionalString(options.launchSource)
if (!hasBusinessFields(context) && launchSource === null) {
return null
}
return {
source: resolveBusinessLaunchSource(launchSource),
...context,
}
}
function loadPendingGameLaunchStore(): PendingGameLaunchStore {
try {
const stored = wx.getStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY)
if (!stored || typeof stored !== 'object') {
return {}
}
return stored as PendingGameLaunchStore
} catch {
return {}
}
}
function savePendingGameLaunchStore(store: PendingGameLaunchStore): void {
try {
wx.setStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY, store)
} catch {}
}
export function getDemoGameLaunchEnvelope(preset: DemoGamePreset = 'classic'): GameLaunchEnvelope {
return {
config: buildDemoConfig(preset),
business: {
source: 'demo',
},
}
}
export function stashPendingGameLaunchEnvelope(envelope: GameLaunchEnvelope): string {
const launchId = `launch_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
const store = loadPendingGameLaunchStore()
store[launchId] = envelope
savePendingGameLaunchStore(store)
return launchId
}
export function consumePendingGameLaunchEnvelope(launchId: string): GameLaunchEnvelope | null {
const normalizedLaunchId = normalizeOptionalString(launchId)
if (!normalizedLaunchId) {
return null
}
const store = loadPendingGameLaunchStore()
const envelope = store[normalizedLaunchId] || null
if (!envelope) {
return null
}
delete store[normalizedLaunchId]
savePendingGameLaunchStore(store)
return envelope
}
export function buildMapPageUrlWithLaunchId(launchId: string): string {
return `/pages/map/map?launchId=${encodeURIComponent(launchId)}`
}
export function prepareMapPageUrlForLaunch(envelope: GameLaunchEnvelope): string {
return buildMapPageUrlWithLaunchId(stashPendingGameLaunchEnvelope(envelope))
}
export function resolveGameLaunchEnvelope(options?: MapPageLaunchOptions | null): GameLaunchEnvelope {
const launchId = normalizeOptionalString(options ? options.launchId : undefined)
if (launchId) {
const pendingEnvelope = consumePendingGameLaunchEnvelope(launchId)
if (pendingEnvelope) {
return pendingEnvelope
}
}
const configUrl = normalizeOptionalString(options ? options.configUrl : undefined)
if (configUrl) {
return {
config: {
configUrl,
configLabel: normalizeOptionalString(options ? options.configLabel : undefined) || '线上配置',
configChecksumSha256: normalizeOptionalString(options ? options.configChecksumSha256 : undefined),
releaseId: normalizeOptionalString(options ? options.releaseId : undefined),
routeCode: normalizeOptionalString(options ? options.routeCode : undefined),
},
business: buildBusinessLaunchContext(options),
}
}
const preset = resolveDemoPreset(normalizeOptionalString(options ? options.preset : undefined))
return getDemoGameLaunchEnvelope(preset)
}

View File

@@ -45,6 +45,16 @@ import {
type GpsMarkerStyleConfig,
type GpsMarkerStyleId,
} from '../game/presentation/gpsMarkerStyleConfig'
import {
getDefaultSkipRadiusMeters,
getGameModeDefaults,
resolveDefaultControlScore,
} from '../game/core/gameModeDefaults'
import {
type SystemSettingsConfig,
type SettingLockKey,
type StoredUserSettings,
} from '../game/core/systemSettingsState'
export interface TileZoomBounds {
minX: number
@@ -79,6 +89,9 @@ export interface RemoteMapConfig {
courseStatusText: string
cpRadiusMeters: number
gameMode: 'classic-sequential' | 'score-o'
sessionCloseAfterMs: number
sessionCloseWarningMs: number
minCompletedControlsBeforeFinish: number
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
requiresFocusSelection: boolean
@@ -88,7 +101,10 @@ export interface RemoteMapConfig {
autoFinishOnLastControl: boolean
controlScoreOverrides: Record<string, number>
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
defaultControlContentOverride: GameControlDisplayContentOverride | null
defaultControlPointStyleOverride: ControlPointStyleEntry | null
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
defaultLegStyleOverride: CourseLegStyleEntry | null
legStyleOverrides: Record<number, CourseLegStyleEntry>
defaultControlScore: number | null
courseStyleConfig: CourseStyleConfig
@@ -98,6 +114,7 @@ export interface RemoteMapConfig {
audioConfig: GameAudioConfig
hapticsConfig: GameHapticsConfig
uiEffectsConfig: GameUiEffectsConfig
systemSettingsConfig: SystemSettingsConfig
}
interface ParsedGameConfig {
@@ -111,6 +128,9 @@ interface ParsedGameConfig {
cpRadiusMeters: number
defaultZoom: number | null
gameMode: 'classic-sequential' | 'score-o'
sessionCloseAfterMs: number
sessionCloseWarningMs: number
minCompletedControlsBeforeFinish: number
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
requiresFocusSelection: boolean
@@ -120,7 +140,10 @@ interface ParsedGameConfig {
autoFinishOnLastControl: boolean
controlScoreOverrides: Record<string, number>
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
defaultControlContentOverride: GameControlDisplayContentOverride | null
defaultControlPointStyleOverride: ControlPointStyleEntry | null
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
defaultLegStyleOverride: CourseLegStyleEntry | null
legStyleOverrides: Record<number, CourseLegStyleEntry>
defaultControlScore: number | null
courseStyleConfig: CourseStyleConfig
@@ -130,6 +153,7 @@ interface ParsedGameConfig {
audioConfig: GameAudioConfig
hapticsConfig: GameHapticsConfig
uiEffectsConfig: GameUiEffectsConfig
systemSettingsConfig: SystemSettingsConfig
declinationDeg: number
}
@@ -279,6 +303,150 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
return rawValue === 'enter' ? 'enter' : 'enter-confirm'
}
function parseSettingLockKey(rawValue: string): SettingLockKey | null {
const normalized = rawValue.trim().toLowerCase()
const table: Record<string, SettingLockKey> = {
animationlevel: 'lockAnimationLevel',
trackdisplaymode: 'lockTrackMode',
trackmode: 'lockTrackMode',
tracktaillength: 'lockTrackTailLength',
trackcolorpreset: 'lockTrackColor',
trackcolor: 'lockTrackColor',
trackstyleprofile: 'lockTrackStyle',
trackstyle: 'lockTrackStyle',
gpsmarkervisible: 'lockGpsMarkerVisible',
gpsmarkerstyle: 'lockGpsMarkerStyle',
gpsmarkersize: 'lockGpsMarkerSize',
gpsmarkercolorpreset: 'lockGpsMarkerColor',
gpsmarkercolor: 'lockGpsMarkerColor',
sidebuttonplacement: 'lockSideButtonPlacement',
autorotateenabled: 'lockAutoRotate',
autorotate: 'lockAutoRotate',
compasstuningprofile: 'lockCompassTuning',
compasstuning: 'lockCompassTuning',
showcenterscaleruler: 'lockScaleRulerVisible',
centerscaleruleranchormode: 'lockScaleRulerAnchor',
centerruleranchor: 'lockScaleRulerAnchor',
northreferencemode: 'lockNorthReference',
northreference: 'lockNorthReference',
heartratedevice: 'lockHeartRateDevice',
}
return table[normalized] || null
}
function assignParsedSettingValue(
target: Partial<StoredUserSettings>,
key: string,
rawValue: unknown,
): void {
const normalized = key.trim().toLowerCase()
if (normalized === 'animationlevel') {
if (rawValue === 'standard' || rawValue === 'lite') {
target.animationLevel = rawValue
}
return
}
if (normalized === 'trackdisplaymode' || normalized === 'trackmode') {
const parsed = parseTrackDisplayMode(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.mode)
target.trackDisplayMode = parsed
return
}
if (normalized === 'tracktaillength') {
if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') {
target.trackTailLength = rawValue
}
return
}
if (normalized === 'trackcolorpreset' || normalized === 'trackcolor') {
const parsed = parseTrackColorPreset(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.colorPreset)
target.trackColorPreset = parsed
return
}
if (normalized === 'trackstyleprofile' || normalized === 'trackstyle') {
const parsed = parseTrackStyleProfile(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.style)
target.trackStyleProfile = parsed
return
}
if (normalized === 'gpsmarkervisible') {
target.gpsMarkerVisible = parseBoolean(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.visible)
return
}
if (normalized === 'gpsmarkerstyle') {
target.gpsMarkerStyle = parseGpsMarkerStyleId(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.style)
return
}
if (normalized === 'gpsmarkersize') {
target.gpsMarkerSize = parseGpsMarkerSizePreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.size)
return
}
if (normalized === 'gpsmarkercolorpreset' || normalized === 'gpsmarkercolor') {
target.gpsMarkerColorPreset = parseGpsMarkerColorPreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.colorPreset)
return
}
if (normalized === 'sidebuttonplacement') {
if (rawValue === 'left' || rawValue === 'right') {
target.sideButtonPlacement = rawValue
}
return
}
if (normalized === 'autorotateenabled' || normalized === 'autorotate') {
target.autoRotateEnabled = parseBoolean(rawValue, true)
return
}
if (normalized === 'compasstuningprofile' || normalized === 'compasstuning') {
if (rawValue === 'smooth' || rawValue === 'balanced' || rawValue === 'responsive') {
target.compassTuningProfile = rawValue
}
return
}
if (normalized === 'northreferencemode' || normalized === 'northreference') {
if (rawValue === 'magnetic' || rawValue === 'true') {
target.northReferenceMode = rawValue
}
return
}
if (normalized === 'showcenterscaleruler') {
target.showCenterScaleRuler = parseBoolean(rawValue, false)
return
}
if (normalized === 'centerscaleruleranchormode' || normalized === 'centerruleranchor') {
if (rawValue === 'screen-center' || rawValue === 'compass-center') {
target.centerScaleRulerAnchorMode = rawValue
}
}
}
function parseSystemSettingsConfig(rawValue: unknown): SystemSettingsConfig {
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
return { values: {}, locks: {} }
}
const values: Partial<StoredUserSettings> = {}
const locks: Partial<Record<SettingLockKey, boolean>> = {}
for (const [key, entry] of Object.entries(normalized)) {
const normalizedEntry = normalizeObjectRecord(entry)
if (Object.keys(normalizedEntry).length) {
const hasValue = Object.prototype.hasOwnProperty.call(normalizedEntry, 'value')
const hasLocked = Object.prototype.hasOwnProperty.call(normalizedEntry, 'islocked')
if (hasValue) {
assignParsedSettingValue(values, key, normalizedEntry.value)
}
if (hasLocked) {
const lockKey = parseSettingLockKey(key)
if (lockKey) {
locks[lockKey] = parseBoolean(normalizedEntry.islocked, false)
}
}
continue
}
assignParsedSettingValue(values, key, entry)
}
return { values, locks }
}
function parseContentExperienceOverride(
rawValue: unknown,
baseUrl: string,
@@ -325,6 +493,152 @@ function parseContentExperienceOverride(
}
}
function parseControlDisplayContentOverride(
rawValue: unknown,
baseUrl: string,
): GameControlDisplayContentOverride | null {
const item = normalizeObjectRecord(rawValue)
if (!Object.keys(item).length) {
return null
}
const titleValue = typeof item.title === 'string' ? item.title.trim() : ''
const templateRaw = typeof item.template === 'string' ? item.template.trim().toLowerCase() : ''
const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus'
? templateRaw
: ''
const bodyValue = typeof item.body === 'string' ? item.body.trim() : ''
const clickTitleValue = typeof item.clickTitle === 'string' ? item.clickTitle.trim() : ''
const clickBodyValue = typeof item.clickBody === 'string' ? item.clickBody.trim() : ''
const autoPopupValue = item.autoPopup
const onceValue = item.once
const priorityNumeric = Number(item.priority)
const ctasValue = parseContentCardCtas(item.ctas)
const contentExperienceValue = parseContentExperienceOverride(item.contentExperience, baseUrl)
const clickExperienceValue = parseContentExperienceOverride(item.clickExperience, baseUrl)
const hasAutoPopup = typeof autoPopupValue === 'boolean'
const hasOnce = typeof onceValue === 'boolean'
const hasPriority = Number.isFinite(priorityNumeric)
if (
!templateValue
&& !titleValue
&& !bodyValue
&& !clickTitleValue
&& !clickBodyValue
&& !hasAutoPopup
&& !hasOnce
&& !hasPriority
&& !ctasValue
&& !contentExperienceValue
&& !clickExperienceValue
) {
return null
}
const parsed: GameControlDisplayContentOverride = {}
if (templateValue) {
parsed.template = templateValue
}
if (titleValue) {
parsed.title = titleValue
}
if (bodyValue) {
parsed.body = bodyValue
}
if (clickTitleValue) {
parsed.clickTitle = clickTitleValue
}
if (clickBodyValue) {
parsed.clickBody = clickBodyValue
}
if (hasAutoPopup) {
parsed.autoPopup = !!autoPopupValue
}
if (hasOnce) {
parsed.once = !!onceValue
}
if (hasPriority) {
parsed.priority = Math.max(0, Math.round(priorityNumeric))
}
if (ctasValue) {
parsed.ctas = ctasValue
}
if (contentExperienceValue) {
parsed.contentExperience = contentExperienceValue
}
if (clickExperienceValue) {
parsed.clickExperience = clickExperienceValue
}
return parsed
}
function parseControlPointStyleOverride(
rawValue: unknown,
fallbackPointStyle: ControlPointStyleEntry,
): ControlPointStyleEntry | null {
const item = normalizeObjectRecord(rawValue)
if (!Object.keys(item).length) {
return null
}
const rawPointStyle = getFirstDefined(item, ['pointstyle', 'style'])
const rawPointColor = getFirstDefined(item, ['pointcolorhex', 'pointcolor', 'color', 'colorhex'])
const rawPointSizeScale = getFirstDefined(item, ['pointsizescale', 'sizescale'])
const rawPointAccentRingScale = getFirstDefined(item, ['pointaccentringscale', 'accentringscale'])
const rawPointGlowStrength = getFirstDefined(item, ['pointglowstrength', 'glowstrength'])
const rawPointLabelScale = getFirstDefined(item, ['pointlabelscale', 'labelscale'])
const rawPointLabelColor = getFirstDefined(item, ['pointlabelcolorhex', 'pointlabelcolor', 'labelcolor', 'labelcolorhex'])
if (
rawPointStyle === undefined
&& rawPointColor === undefined
&& rawPointSizeScale === undefined
&& rawPointAccentRingScale === undefined
&& rawPointGlowStrength === undefined
&& rawPointLabelScale === undefined
&& rawPointLabelColor === undefined
) {
return null
}
return {
style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style),
colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex),
sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1),
accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0),
glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2),
labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1),
labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''),
}
}
function parseLegStyleOverride(
rawValue: unknown,
fallbackLegStyle: CourseLegStyleEntry,
): CourseLegStyleEntry | null {
const normalized = normalizeObjectRecord(rawValue)
const rawStyle = getFirstDefined(normalized, ['style'])
const rawColor = getFirstDefined(normalized, ['color', 'colorhex'])
const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
if (
rawStyle === undefined
&& rawColor === undefined
&& rawWidthScale === undefined
&& rawGlowStrength === undefined
) {
return null
}
return {
style: parseCourseLegStyleId(rawStyle, fallbackLegStyle.style),
colorHex: normalizeHexColor(rawColor, fallbackLegStyle.colorHex),
widthScale: parsePositiveNumber(rawWidthScale, fallbackLegStyle.widthScale || 1),
glowStrength: clamp(parseNumber(rawGlowStrength, fallbackLegStyle.glowStrength || 0), 0, 1.2),
}
}
function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
if (typeof rawValue !== 'string') {
return 'classic-sequential'
@@ -684,6 +998,7 @@ function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'controlcompleted:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'punchfeedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
{ key: 'guidance:searching', aliases: ['guidance:searching', 'guidance_searching', 'searching', 'search', 'normal_search'] },
{ key: 'guidance:distant', aliases: ['guidance:distant', 'guidance_distant', 'distant', 'far', 'far_distance'] },
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] },
{ key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] },
]
@@ -705,11 +1020,29 @@ function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
? parsePositiveNumber(normalized.volume, 1)
: undefined,
obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined,
distantDistanceMeters: normalized.distantdistancemeters !== undefined
? parsePositiveNumber(normalized.distantdistancemeters, 80)
: normalized.distantdistance !== undefined
? parsePositiveNumber(normalized.distantdistance, 80)
: normalized.fardistancemeters !== undefined
? parsePositiveNumber(normalized.fardistancemeters, 80)
: normalized.fardistance !== undefined
? parsePositiveNumber(normalized.fardistance, 80)
: undefined,
approachDistanceMeters: normalized.approachdistancemeters !== undefined
? parsePositiveNumber(normalized.approachdistancemeters, 20)
: normalized.approachdistance !== undefined
? parsePositiveNumber(normalized.approachdistance, 20)
: undefined,
readyDistanceMeters: normalized.readydistancemeters !== undefined
? parsePositiveNumber(normalized.readydistancemeters, 5)
: normalized.readydistance !== undefined
? parsePositiveNumber(normalized.readydistance, 5)
: normalized.punchreadydistancemeters !== undefined
? parsePositiveNumber(normalized.punchreadydistancemeters, 5)
: normalized.punchreadydistance !== undefined
? parsePositiveNumber(normalized.punchreadydistance, 5)
: undefined,
cues,
})
}
@@ -1120,6 +1453,7 @@ function parseHapticsConfig(rawValue: unknown): GameHapticsConfig {
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
{ key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
{ key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] },
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
{ key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
]
@@ -1153,6 +1487,7 @@ function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig {
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
{ key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
{ key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] },
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
{ key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
]
@@ -1194,6 +1529,9 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map)
? parsed.map as Record<string, unknown>
: null
const rawSettings = parsed.settings && typeof parsed.settings === 'object' && !Array.isArray(parsed.settings)
? parsed.settings as Record<string, unknown>
: null
const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield)
? parsed.playfield as Record<string, unknown>
: null
@@ -1241,6 +1579,9 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring)
? rawGame.scoring as Record<string, unknown>
: null
const rawGameSettings = rawGame && rawGame.settings && typeof rawGame.settings === 'object' && !Array.isArray(rawGame.settings)
? rawGame.settings as Record<string, unknown>
: null
const mapRoot = rawMap && typeof rawMap.tiles === 'string'
? rawMap.tiles
@@ -1258,9 +1599,22 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
const gameMode = parseGameMode(modeValue)
const modeDefaults = getGameModeDefaults(gameMode)
const fallbackPointStyle = gameMode === 'score-o'
? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default
: DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default
const fallbackLegStyle = DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default
const rawControlDefaults = rawPlayfield && rawPlayfield.controlDefaults && typeof rawPlayfield.controlDefaults === 'object' && !Array.isArray(rawPlayfield.controlDefaults)
? rawPlayfield.controlDefaults as Record<string, unknown>
: null
const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides)
? rawPlayfield.controlOverrides as Record<string, unknown>
: null
const defaultControlScoreFromPlayfield = rawControlDefaults
? Number(getFirstDefined(normalizeObjectRecord(rawControlDefaults), ['score']))
: Number.NaN
const defaultControlContentOverride = parseControlDisplayContentOverride(rawControlDefaults, gameConfigUrl)
const defaultControlPointStyleOverride = parseControlPointStyleOverride(rawControlDefaults, fallbackPointStyle)
const controlScoreOverrides: Record<string, number> = {}
const controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {}
const controlPointStyleOverrides: Record<string, ControlPointStyleEntry> = {}
@@ -1275,92 +1629,21 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
if (Number.isFinite(scoreValue)) {
controlScoreOverrides[key] = scoreValue
}
const rawPointStyle = getFirstDefined(item as Record<string, unknown>, ['pointStyle'])
const rawPointColor = getFirstDefined(item as Record<string, unknown>, ['pointColorHex'])
const rawPointSizeScale = getFirstDefined(item as Record<string, unknown>, ['pointSizeScale'])
const rawPointAccentRingScale = getFirstDefined(item as Record<string, unknown>, ['pointAccentRingScale'])
const rawPointGlowStrength = getFirstDefined(item as Record<string, unknown>, ['pointGlowStrength'])
const rawPointLabelScale = getFirstDefined(item as Record<string, unknown>, ['pointLabelScale'])
const rawPointLabelColor = getFirstDefined(item as Record<string, unknown>, ['pointLabelColorHex'])
if (
rawPointStyle !== undefined
|| rawPointColor !== undefined
|| rawPointSizeScale !== undefined
|| rawPointAccentRingScale !== undefined
|| rawPointGlowStrength !== undefined
|| rawPointLabelScale !== undefined
|| rawPointLabelColor !== undefined
) {
const fallbackPointStyle = gameMode === 'score-o'
? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default
: DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default
controlPointStyleOverrides[key] = {
style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style),
colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex),
sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1),
accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0),
glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2),
labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1),
labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''),
}
}
const titleValue = typeof (item as Record<string, unknown>).title === 'string'
? ((item as Record<string, unknown>).title as string).trim()
: ''
const templateRaw = typeof (item as Record<string, unknown>).template === 'string'
? ((item as Record<string, unknown>).template as string).trim().toLowerCase()
: ''
const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus'
? templateRaw
: ''
const bodyValue = typeof (item as Record<string, unknown>).body === 'string'
? ((item as Record<string, unknown>).body as string).trim()
: ''
const clickTitleValue = typeof (item as Record<string, unknown>).clickTitle === 'string'
? ((item as Record<string, unknown>).clickTitle as string).trim()
: ''
const clickBodyValue = typeof (item as Record<string, unknown>).clickBody === 'string'
? ((item as Record<string, unknown>).clickBody as string).trim()
: ''
const autoPopupValue = (item as Record<string, unknown>).autoPopup
const onceValue = (item as Record<string, unknown>).once
const priorityNumeric = Number((item as Record<string, unknown>).priority)
const ctasValue = parseContentCardCtas((item as Record<string, unknown>).ctas)
const contentExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).contentExperience, gameConfigUrl)
const clickExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).clickExperience, gameConfigUrl)
const hasAutoPopup = typeof autoPopupValue === 'boolean'
const hasOnce = typeof onceValue === 'boolean'
const hasPriority = Number.isFinite(priorityNumeric)
if (
templateValue
|| titleValue
|| bodyValue
|| clickTitleValue
|| clickBodyValue
|| hasAutoPopup
|| hasOnce
|| hasPriority
|| ctasValue
|| contentExperienceValue
|| clickExperienceValue
) {
controlContentOverrides[key] = {
...(templateValue ? { template: templateValue } : {}),
...(titleValue ? { title: titleValue } : {}),
...(bodyValue ? { body: bodyValue } : {}),
...(clickTitleValue ? { clickTitle: clickTitleValue } : {}),
...(clickBodyValue ? { clickBody: clickBodyValue } : {}),
...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
...(hasOnce ? { once: !!onceValue } : {}),
...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
...(ctasValue ? { ctas: ctasValue } : {}),
...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}),
...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}),
}
}
const styleOverride = parseControlPointStyleOverride(item, fallbackPointStyle)
if (styleOverride) {
controlPointStyleOverrides[key] = styleOverride
}
const contentOverride = parseControlDisplayContentOverride(item, gameConfigUrl)
if (contentOverride) {
controlContentOverrides[key] = contentOverride
}
}
}
const rawLegDefaults = rawPlayfield && rawPlayfield.legDefaults && typeof rawPlayfield.legDefaults === 'object' && !Array.isArray(rawPlayfield.legDefaults)
? rawPlayfield.legDefaults as Record<string, unknown>
: null
const defaultLegStyleOverride = parseLegStyleOverride(rawLegDefaults, fallbackLegStyle)
const rawLegOverrides = rawPlayfield && rawPlayfield.legOverrides && typeof rawPlayfield.legOverrides === 'object' && !Array.isArray(rawPlayfield.legOverrides)
? rawPlayfield.legOverrides as Record<string, unknown>
: null
@@ -1373,23 +1656,99 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
if (index === null || !item || typeof item !== 'object' || Array.isArray(item)) {
continue
}
const normalized = normalizeObjectRecord(item)
const rawStyle = getFirstDefined(normalized, ['style'])
const rawColor = getFirstDefined(normalized, ['color', 'colorhex'])
const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
if (rawStyle === undefined && rawColor === undefined && rawWidthScale === undefined && rawGlowStrength === undefined) {
continue
}
legStyleOverrides[index] = {
style: parseCourseLegStyleId(rawStyle, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.style),
colorHex: normalizeHexColor(rawColor, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.colorHex),
widthScale: parsePositiveNumber(rawWidthScale, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.widthScale || 1),
glowStrength: clamp(parseNumber(rawGlowStrength, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.glowStrength || 0), 0, 1.2),
}
const legOverride = parseLegStyleOverride(item, fallbackLegStyle)
if (!legOverride) {
continue
}
legStyleOverrides[index] = legOverride
}
}
const punchRadiusMeters = parsePositiveNumber(
rawPunch && rawPunch.radiusMeters !== undefined
? rawPunch.radiusMeters
: normalizedGame.punchradiusmeters !== undefined
? normalizedGame.punchradiusmeters
: normalizedGame.punchradius !== undefined
? normalizedGame.punchradius
: normalized.punchradiusmeters !== undefined
? normalized.punchradiusmeters
: normalized.punchradius,
5,
)
const sessionCloseAfterMs = parsePositiveNumber(
rawSession && rawSession.closeAfterMs !== undefined
? rawSession.closeAfterMs
: rawSession && rawSession.sessionCloseAfterMs !== undefined
? rawSession.sessionCloseAfterMs
: normalizedGame.sessioncloseafterms !== undefined
? normalizedGame.sessioncloseafterms
: normalized.sessioncloseafterms,
modeDefaults.sessionCloseAfterMs,
)
const sessionCloseWarningMs = parsePositiveNumber(
rawSession && rawSession.closeWarningMs !== undefined
? rawSession.closeWarningMs
: rawSession && rawSession.sessionCloseWarningMs !== undefined
? rawSession.sessionCloseWarningMs
: normalizedGame.sessionclosewarningms !== undefined
? normalizedGame.sessionclosewarningms
: normalized.sessionclosewarningms,
modeDefaults.sessionCloseWarningMs,
)
const minCompletedControlsBeforeFinish = Math.max(0, Math.floor(parseNumber(
rawSession && rawSession.minCompletedControlsBeforeFinish !== undefined
? rawSession.minCompletedControlsBeforeFinish
: rawSession && rawSession.minControlsBeforeFinish !== undefined
? rawSession.minControlsBeforeFinish
: normalizedGame.mincompletedcontrolsbeforefinish !== undefined
? normalizedGame.mincompletedcontrolsbeforefinish
: normalizedGame.mincontrolsbeforefinish !== undefined
? normalizedGame.mincontrolsbeforefinish
: normalized.mincompletedcontrolsbeforefinish !== undefined
? normalized.mincompletedcontrolsbeforefinish
: normalized.mincontrolsbeforefinish,
modeDefaults.minCompletedControlsBeforeFinish,
)))
const requiresFocusSelection = parseBoolean(
rawPunch && rawPunch.requiresFocusSelection !== undefined
? rawPunch.requiresFocusSelection
: normalizedGame.requiresfocusselection !== undefined
? normalizedGame.requiresfocusselection
: rawPunch && (rawPunch as Record<string, unknown>).requiresfocusselection !== undefined
? (rawPunch as Record<string, unknown>).requiresfocusselection
: normalized.requiresfocusselection,
modeDefaults.requiresFocusSelection,
)
const skipEnabled = parseBoolean(
rawSkip && rawSkip.enabled !== undefined
? rawSkip.enabled
: normalizedGame.skipenabled !== undefined
? normalizedGame.skipenabled
: normalized.skipenabled,
modeDefaults.skipEnabled,
)
const skipRadiusMeters = parsePositiveNumber(
rawSkip && rawSkip.radiusMeters !== undefined
? rawSkip.radiusMeters
: normalizedGame.skipradiusmeters !== undefined
? normalizedGame.skipradiusmeters
: normalizedGame.skipradius !== undefined
? normalizedGame.skipradius
: normalized.skipradiusmeters !== undefined
? normalized.skipradiusmeters
: normalized.skipradius,
getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters),
)
const autoFinishOnLastControl = parseBoolean(
rawSession && rawSession.autoFinishOnLastControl !== undefined
? rawSession.autoFinishOnLastControl
: normalizedGame.autofinishonlastcontrol !== undefined
? normalizedGame.autofinishonlastcontrol
: normalized.autofinishonlastcontrol,
modeDefaults.autoFinishOnLastControl,
)
return {
title: rawApp && typeof rawApp.title === 'string' ? rawApp.title : '',
appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '',
@@ -1410,6 +1769,9 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
? parsePositiveNumber((rawMap.initialView as Record<string, unknown>).zoom, 17)
: null,
gameMode,
sessionCloseAfterMs,
sessionCloseWarningMs,
minCompletedControlsBeforeFinish,
punchPolicy: parsePunchPolicy(
rawPunch && rawPunch.policy !== undefined
? rawPunch.policy
@@ -1417,71 +1779,34 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
? normalizedGame.punchpolicy
: normalized.punchpolicy,
),
punchRadiusMeters: parsePositiveNumber(
rawPunch && rawPunch.radiusMeters !== undefined
? rawPunch.radiusMeters
: normalizedGame.punchradiusmeters !== undefined
? normalizedGame.punchradiusmeters
: normalizedGame.punchradius !== undefined
? normalizedGame.punchradius
: normalized.punchradiusmeters !== undefined
? normalized.punchradiusmeters
: normalized.punchradius,
5,
),
requiresFocusSelection: parseBoolean(
rawPunch && rawPunch.requiresFocusSelection !== undefined
? rawPunch.requiresFocusSelection
: normalizedGame.requiresfocusselection !== undefined
? normalizedGame.requiresfocusselection
: rawPunch && (rawPunch as Record<string, unknown>).requiresfocusselection !== undefined
? (rawPunch as Record<string, unknown>).requiresfocusselection
: normalized.requiresfocusselection,
false,
),
skipEnabled: parseBoolean(
rawSkip && rawSkip.enabled !== undefined
? rawSkip.enabled
: normalizedGame.skipenabled !== undefined
? normalizedGame.skipenabled
: normalized.skipenabled,
false,
),
skipRadiusMeters: parsePositiveNumber(
rawSkip && rawSkip.radiusMeters !== undefined
? rawSkip.radiusMeters
: normalizedGame.skipradiusmeters !== undefined
? normalizedGame.skipradiusmeters
: normalizedGame.skipradius !== undefined
? normalizedGame.skipradius
: normalized.skipradiusmeters !== undefined
? normalized.skipradiusmeters
: normalized.skipradius,
30,
),
punchRadiusMeters,
requiresFocusSelection,
skipEnabled,
skipRadiusMeters,
skipRequiresConfirm: parseBoolean(
rawSkip && rawSkip.requiresConfirm !== undefined
? rawSkip.requiresConfirm
: normalizedGame.skiprequiresconfirm !== undefined
? normalizedGame.skiprequiresconfirm
: normalized.skiprequiresconfirm,
true,
),
autoFinishOnLastControl: parseBoolean(
rawSession && rawSession.autoFinishOnLastControl !== undefined
? rawSession.autoFinishOnLastControl
: normalizedGame.autofinishonlastcontrol !== undefined
? normalizedGame.autofinishonlastcontrol
: normalized.autofinishonlastcontrol,
true,
modeDefaults.skipRequiresConfirm,
),
autoFinishOnLastControl,
controlScoreOverrides,
controlContentOverrides,
defaultControlContentOverride,
defaultControlPointStyleOverride,
controlPointStyleOverrides,
defaultLegStyleOverride,
legStyleOverrides,
defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined
? parsePositiveNumber(rawScoring.defaultControlScore, 10)
: null,
defaultControlScore: resolveDefaultControlScore(
gameMode,
Number.isFinite(defaultControlScoreFromPlayfield)
? defaultControlScoreFromPlayfield
: rawScoring && rawScoring.defaultControlScore !== undefined
? parsePositiveNumber(rawScoring.defaultControlScore, modeDefaults.defaultControlScore)
: null,
),
courseStyleConfig: parseCourseStyleConfig(rawGamePresentation),
trackStyleConfig: parseTrackVisualizationConfig(getFirstDefined(normalizedGamePresentation, ['track'])),
gpsMarkerStyleConfig: parseGpsMarkerStyleConfig(getFirstDefined(normalizedGamePresentation, ['gpsmarker', 'gps'])),
@@ -1489,6 +1814,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
hapticsConfig: parseHapticsConfig(rawHaptics),
uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
systemSettingsConfig: parseSystemSettingsConfig(rawGameSettings || rawSettings),
declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination),
}
}
@@ -1518,6 +1844,11 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
}
const gameMode = parseGameMode(config.gamemode)
const modeDefaults = getGameModeDefaults(gameMode)
const punchRadiusMeters = parsePositiveNumber(
config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
5,
)
return {
title: '',
@@ -1530,24 +1861,27 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
defaultZoom: null,
gameMode,
sessionCloseAfterMs: modeDefaults.sessionCloseAfterMs,
sessionCloseWarningMs: modeDefaults.sessionCloseWarningMs,
minCompletedControlsBeforeFinish: modeDefaults.minCompletedControlsBeforeFinish,
punchPolicy: parsePunchPolicy(config.punchpolicy),
punchRadiusMeters: parsePositiveNumber(
config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
5,
),
requiresFocusSelection: parseBoolean(config.requiresfocusselection, false),
skipEnabled: parseBoolean(config.skipenabled, false),
punchRadiusMeters,
requiresFocusSelection: parseBoolean(config.requiresfocusselection, modeDefaults.requiresFocusSelection),
skipEnabled: parseBoolean(config.skipenabled, modeDefaults.skipEnabled),
skipRadiusMeters: parsePositiveNumber(
config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius,
30,
getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters),
),
skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, true),
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, modeDefaults.skipRequiresConfirm),
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, modeDefaults.autoFinishOnLastControl),
controlScoreOverrides: {},
controlContentOverrides: {},
defaultControlContentOverride: null,
defaultControlPointStyleOverride: null,
controlPointStyleOverrides: {},
defaultLegStyleOverride: null,
legStyleOverrides: {},
defaultControlScore: null,
defaultControlScore: modeDefaults.defaultControlScore,
courseStyleConfig: DEFAULT_COURSE_STYLE_CONFIG,
trackStyleConfig: DEFAULT_TRACK_VISUALIZATION_CONFIG,
gpsMarkerStyleConfig: DEFAULT_GPS_MARKER_STYLE_CONFIG,
@@ -1567,7 +1901,21 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
enabled: config.audioenabled,
masterVolume: config.audiomastervolume,
obeyMuteSwitch: config.audioobeymuteswitch,
distantDistanceMeters: config.audiodistantdistancemeters !== undefined
? config.audiodistantdistancemeters
: config.audiodistantdistance !== undefined
? config.audiodistantdistance
: config.audiofardistancemeters !== undefined
? config.audiofardistancemeters
: config.audiofardistance,
approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance,
readyDistanceMeters: config.audioreadydistancemeters !== undefined
? config.audioreadydistancemeters
: config.audioreadydistance !== undefined
? config.audioreadydistance
: config.audiopunchreadydistancemeters !== undefined
? config.audiopunchreadydistancemeters
: config.audiopunchreadydistance,
cues: {
session_started: config.audiosessionstarted,
'control_completed:start': config.audiostartcomplete,
@@ -1575,6 +1923,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
'control_completed:finish': config.audiofinishcomplete,
'punch_feedback:warning': config.audiowarning,
'guidance:searching': config.audiosearching,
'guidance:distant': config.audiodistant,
'guidance:approaching': config.audioapproaching,
'guidance:ready': config.audioready,
},
@@ -1589,6 +1938,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
'control_completed:finish': config.hapticsfinishcomplete,
'punch_feedback:warning': config.hapticswarning,
'guidance:searching': config.hapticssearching,
'guidance:distant': config.hapticsdistant,
'guidance:approaching': config.hapticsapproaching,
'guidance:ready': config.hapticsready,
},
@@ -1605,6 +1955,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms },
},
}),
systemSettingsConfig: { values: {}, locks: {} },
declinationDeg: parseDeclinationValue(config.declination),
}
}
@@ -1827,6 +2178,9 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
courseStatusText,
cpRadiusMeters: gameConfig.cpRadiusMeters,
gameMode: gameConfig.gameMode,
sessionCloseAfterMs: gameConfig.sessionCloseAfterMs,
sessionCloseWarningMs: gameConfig.sessionCloseWarningMs,
minCompletedControlsBeforeFinish: gameConfig.minCompletedControlsBeforeFinish,
punchPolicy: gameConfig.punchPolicy,
punchRadiusMeters: gameConfig.punchRadiusMeters,
requiresFocusSelection: gameConfig.requiresFocusSelection,
@@ -1836,7 +2190,10 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
controlScoreOverrides: gameConfig.controlScoreOverrides,
controlContentOverrides: gameConfig.controlContentOverrides,
defaultControlContentOverride: gameConfig.defaultControlContentOverride,
defaultControlPointStyleOverride: gameConfig.defaultControlPointStyleOverride,
controlPointStyleOverrides: gameConfig.controlPointStyleOverrides,
defaultLegStyleOverride: gameConfig.defaultLegStyleOverride,
legStyleOverrides: gameConfig.legStyleOverrides,
defaultControlScore: gameConfig.defaultControlScore,
courseStyleConfig: gameConfig.courseStyleConfig,
@@ -1846,6 +2203,7 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
audioConfig: gameConfig.audioConfig,
hapticsConfig: gameConfig.hapticsConfig,
uiEffectsConfig: gameConfig.uiEffectsConfig,
systemSettingsConfig: gameConfig.systemSettingsConfig,
}
}