feat: 收敛玩法运行时配置并加入故障恢复
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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('模拟定位源已连接')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AudioCueKey =
|
||||
| 'control_completed:finish'
|
||||
| 'punch_feedback:warning'
|
||||
| 'guidance:searching'
|
||||
| 'guidance:distant'
|
||||
| 'guidance:approaching'
|
||||
| 'guidance:ready'
|
||||
|
||||
@@ -21,7 +22,9 @@ export interface GameAudioConfig {
|
||||
masterVolume: number
|
||||
obeyMuteSwitch: boolean
|
||||
backgroundAudioEnabled: boolean
|
||||
distantDistanceMeters: number
|
||||
approachDistanceMeters: number
|
||||
readyDistanceMeters: number
|
||||
cues: Record<AudioCueKey, AudioCueConfig>
|
||||
}
|
||||
|
||||
@@ -38,7 +41,9 @@ export interface GameAudioConfigOverrides {
|
||||
masterVolume?: number
|
||||
obeyMuteSwitch?: boolean
|
||||
backgroundAudioEnabled?: boolean
|
||||
distantDistanceMeters?: number
|
||||
approachDistanceMeters?: number
|
||||
readyDistanceMeters?: number
|
||||
cues?: Partial<Record<AudioCueKey, PartialAudioCueConfig>>
|
||||
}
|
||||
|
||||
@@ -47,7 +52,9 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
|
||||
masterVolume: 1,
|
||||
obeyMuteSwitch: true,
|
||||
backgroundAudioEnabled: true,
|
||||
distantDistanceMeters: 80,
|
||||
approachDistanceMeters: 20,
|
||||
readyDistanceMeters: 5,
|
||||
cues: {
|
||||
session_started: {
|
||||
src: '/assets/sounds/session-start.wav',
|
||||
@@ -91,6 +98,13 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
|
||||
loopGapMs: 1800,
|
||||
backgroundMode: 'guidance',
|
||||
},
|
||||
'guidance:distant': {
|
||||
src: '/assets/sounds/guidance-searching.wav',
|
||||
volume: 0.34,
|
||||
loop: true,
|
||||
loopGapMs: 4800,
|
||||
backgroundMode: 'guidance',
|
||||
},
|
||||
'guidance:approaching': {
|
||||
src: '/assets/sounds/guidance-approaching.wav',
|
||||
volume: 0.58,
|
||||
@@ -129,6 +143,7 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null
|
||||
'control_completed:finish': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:finish'] },
|
||||
'punch_feedback:warning': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['punch_feedback:warning'] },
|
||||
'guidance:searching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:searching'] },
|
||||
'guidance:distant': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:distant'] },
|
||||
'guidance:approaching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:approaching'] },
|
||||
'guidance:ready': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:ready'] },
|
||||
}
|
||||
@@ -170,7 +185,15 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null
|
||||
backgroundAudioEnabled: overrides && overrides.backgroundAudioEnabled !== undefined
|
||||
? !!overrides.backgroundAudioEnabled
|
||||
: true,
|
||||
distantDistanceMeters: clampDistance(
|
||||
Number(overrides && overrides.distantDistanceMeters),
|
||||
DEFAULT_GAME_AUDIO_CONFIG.distantDistanceMeters,
|
||||
),
|
||||
approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters),
|
||||
readyDistanceMeters: clampDistance(
|
||||
Number(overrides && overrides.readyDistanceMeters),
|
||||
DEFAULT_GAME_AUDIO_CONFIG.readyDistanceMeters,
|
||||
),
|
||||
cues,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@ export class SoundDirector {
|
||||
}
|
||||
|
||||
const hasFinishCompletion = effects.some((effect) => effect.type === 'control_completed' && effect.controlKind === 'finish')
|
||||
if (hasFinishCompletion) {
|
||||
this.stopGuidanceLoop()
|
||||
this.play('control_completed:finish')
|
||||
return
|
||||
}
|
||||
|
||||
for (const effect of effects) {
|
||||
if (effect.type === 'session_started') {
|
||||
@@ -85,15 +90,19 @@ export class SoundDirector {
|
||||
}
|
||||
|
||||
if (effect.type === 'guidance_state_changed') {
|
||||
if (effect.guidanceState === 'searching') {
|
||||
this.startGuidanceLoop('guidance:searching')
|
||||
if (effect.guidanceState === 'distant') {
|
||||
this.startGuidanceLoop('guidance:distant')
|
||||
continue
|
||||
}
|
||||
if (effect.guidanceState === 'approaching') {
|
||||
this.startGuidanceLoop('guidance:approaching')
|
||||
continue
|
||||
}
|
||||
this.startGuidanceLoop('guidance:ready')
|
||||
if (effect.guidanceState === 'ready') {
|
||||
this.startGuidanceLoop('guidance:ready')
|
||||
continue
|
||||
}
|
||||
this.stopGuidanceLoop()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -273,6 +282,7 @@ export class SoundDirector {
|
||||
|
||||
isGuidanceCue(key: AudioCueKey): boolean {
|
||||
return key === 'guidance:searching'
|
||||
|| key === 'guidance:distant'
|
||||
|| key === 'guidance:approaching'
|
||||
|| key === 'guidance:ready'
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
resolveContentCardCtaConfig,
|
||||
} from '../experience/contentCard'
|
||||
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
||||
import {
|
||||
getDefaultSkipRadiusMeters,
|
||||
getGameModeDefaults,
|
||||
resolveDefaultControlScore,
|
||||
} from '../core/gameModeDefaults'
|
||||
|
||||
function sortBySequence<T extends { sequence: number | null }>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
|
||||
@@ -86,18 +91,48 @@ export function buildGameDefinitionFromCourse(
|
||||
course: OrienteeringCourseData,
|
||||
controlRadiusMeters: number,
|
||||
mode: GameDefinition['mode'] = 'classic-sequential',
|
||||
autoFinishOnLastControl = true,
|
||||
sessionCloseAfterMs?: number,
|
||||
sessionCloseWarningMs?: number,
|
||||
minCompletedControlsBeforeFinish?: number,
|
||||
autoFinishOnLastControl?: boolean,
|
||||
punchPolicy: PunchPolicyType = 'enter-confirm',
|
||||
punchRadiusMeters = 5,
|
||||
requiresFocusSelection = false,
|
||||
skipEnabled = false,
|
||||
skipRadiusMeters = 30,
|
||||
skipRequiresConfirm = true,
|
||||
requiresFocusSelection?: boolean,
|
||||
skipEnabled?: boolean,
|
||||
skipRadiusMeters?: number,
|
||||
skipRequiresConfirm?: boolean,
|
||||
controlScoreOverrides: Record<string, number> = {},
|
||||
defaultControlContentOverride: GameControlDisplayContentOverride | null = null,
|
||||
controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {},
|
||||
defaultControlScore: number | null = null,
|
||||
): GameDefinition {
|
||||
const controls: GameControl[] = []
|
||||
const modeDefaults = getGameModeDefaults(mode)
|
||||
const resolvedSessionCloseAfterMs = sessionCloseAfterMs !== undefined
|
||||
? sessionCloseAfterMs
|
||||
: modeDefaults.sessionCloseAfterMs
|
||||
const resolvedSessionCloseWarningMs = sessionCloseWarningMs !== undefined
|
||||
? sessionCloseWarningMs
|
||||
: modeDefaults.sessionCloseWarningMs
|
||||
const resolvedMinCompletedControlsBeforeFinish = minCompletedControlsBeforeFinish !== undefined
|
||||
? minCompletedControlsBeforeFinish
|
||||
: modeDefaults.minCompletedControlsBeforeFinish
|
||||
const resolvedRequiresFocusSelection = requiresFocusSelection !== undefined
|
||||
? requiresFocusSelection
|
||||
: modeDefaults.requiresFocusSelection
|
||||
const resolvedSkipEnabled = skipEnabled !== undefined
|
||||
? skipEnabled
|
||||
: modeDefaults.skipEnabled
|
||||
const resolvedSkipRadiusMeters = skipRadiusMeters !== undefined
|
||||
? skipRadiusMeters
|
||||
: getDefaultSkipRadiusMeters(mode, punchRadiusMeters)
|
||||
const resolvedSkipRequiresConfirm = skipRequiresConfirm !== undefined
|
||||
? skipRequiresConfirm
|
||||
: modeDefaults.skipRequiresConfirm
|
||||
const resolvedAutoFinishOnLastControl = autoFinishOnLastControl !== undefined
|
||||
? autoFinishOnLastControl
|
||||
: modeDefaults.autoFinishOnLastControl
|
||||
const resolvedDefaultControlScore = resolveDefaultControlScore(mode, defaultControlScore)
|
||||
|
||||
for (let startIndex = 0; startIndex < course.layers.starts.length; startIndex += 1) {
|
||||
const start = course.layers.starts[startIndex]
|
||||
@@ -114,11 +149,11 @@ export function buildGameDefinitionFromCourse(
|
||||
template: 'focus',
|
||||
title: '比赛开始',
|
||||
body: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
|
||||
autoPopup: true,
|
||||
autoPopup: false,
|
||||
once: false,
|
||||
priority: 1,
|
||||
clickTitle: '比赛开始',
|
||||
clickBody: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
|
||||
clickTitle: null,
|
||||
clickBody: null,
|
||||
ctas: [],
|
||||
contentExperience: null,
|
||||
clickExperience: null,
|
||||
@@ -131,7 +166,7 @@ export function buildGameDefinitionFromCourse(
|
||||
const controlId = `control-${control.sequence}`
|
||||
const score = controlId in controlScoreOverrides
|
||||
? controlScoreOverrides[controlId]
|
||||
: defaultControlScore
|
||||
: resolvedDefaultControlScore
|
||||
controls.push({
|
||||
id: controlId,
|
||||
code: label,
|
||||
@@ -140,19 +175,22 @@ export function buildGameDefinitionFromCourse(
|
||||
point: control.point,
|
||||
sequence: control.sequence,
|
||||
score,
|
||||
displayContent: applyDisplayContentOverride({
|
||||
template: 'story',
|
||||
title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
|
||||
body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence),
|
||||
autoPopup: true,
|
||||
once: false,
|
||||
priority: 1,
|
||||
clickTitle: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
|
||||
clickBody: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence),
|
||||
ctas: [],
|
||||
contentExperience: null,
|
||||
clickExperience: null,
|
||||
}, controlContentOverrides[controlId]),
|
||||
displayContent: applyDisplayContentOverride(
|
||||
applyDisplayContentOverride({
|
||||
template: 'story',
|
||||
title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
|
||||
body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence),
|
||||
autoPopup: false,
|
||||
once: false,
|
||||
priority: 1,
|
||||
clickTitle: null,
|
||||
clickBody: null,
|
||||
ctas: [],
|
||||
contentExperience: null,
|
||||
clickExperience: null,
|
||||
}, defaultControlContentOverride || undefined),
|
||||
controlContentOverrides[controlId],
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -172,11 +210,11 @@ export function buildGameDefinitionFromCourse(
|
||||
template: 'focus',
|
||||
title: '完成路线',
|
||||
body: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
|
||||
autoPopup: true,
|
||||
autoPopup: false,
|
||||
once: false,
|
||||
priority: 2,
|
||||
clickTitle: '完成路线',
|
||||
clickBody: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
|
||||
clickTitle: null,
|
||||
clickBody: null,
|
||||
ctas: [],
|
||||
contentExperience: null,
|
||||
clickExperience: null,
|
||||
@@ -189,13 +227,16 @@ export function buildGameDefinitionFromCourse(
|
||||
mode,
|
||||
title: course.title || (mode === 'score-o' ? 'Score-O' : 'Classic Sequential'),
|
||||
controlRadiusMeters,
|
||||
sessionCloseAfterMs: resolvedSessionCloseAfterMs,
|
||||
sessionCloseWarningMs: resolvedSessionCloseWarningMs,
|
||||
minCompletedControlsBeforeFinish: resolvedMinCompletedControlsBeforeFinish,
|
||||
punchRadiusMeters,
|
||||
punchPolicy,
|
||||
requiresFocusSelection,
|
||||
skipEnabled,
|
||||
skipRadiusMeters,
|
||||
skipRequiresConfirm,
|
||||
requiresFocusSelection: resolvedRequiresFocusSelection,
|
||||
skipEnabled: resolvedSkipEnabled,
|
||||
skipRadiusMeters: resolvedSkipRadiusMeters,
|
||||
skipRequiresConfirm: resolvedSkipRequiresConfirm,
|
||||
controls,
|
||||
autoFinishOnLastControl,
|
||||
autoFinishOnLastControl: resolvedAutoFinishOnLastControl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ export interface GameDefinition {
|
||||
mode: GameMode
|
||||
title: string
|
||||
controlRadiusMeters: number
|
||||
sessionCloseAfterMs: number
|
||||
sessionCloseWarningMs: number
|
||||
minCompletedControlsBeforeFinish: number
|
||||
punchRadiusMeters: number
|
||||
punchPolicy: PunchPolicyType
|
||||
requiresFocusSelection: boolean
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export type GameEvent =
|
||||
| { type: 'session_started'; at: number }
|
||||
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
|
||||
| { type: 'punch_requested'; at: number }
|
||||
| { type: 'punch_requested'; at: number; lon: number | null; lat: number | null }
|
||||
| { type: 'skip_requested'; at: number; lon: number | null; lat: number | null }
|
||||
| { type: 'control_focused'; at: number; controlId: string | null }
|
||||
| { type: 'session_ended'; at: number }
|
||||
| { type: 'session_timed_out'; at: number }
|
||||
|
||||
55
miniprogram/game/core/gameModeDefaults.ts
Normal file
55
miniprogram/game/core/gameModeDefaults.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { type GameMode } from './gameDefinition'
|
||||
|
||||
export interface GameModeDefaults {
|
||||
sessionCloseAfterMs: number
|
||||
sessionCloseWarningMs: number
|
||||
minCompletedControlsBeforeFinish: number
|
||||
requiresFocusSelection: boolean
|
||||
skipEnabled: boolean
|
||||
skipRequiresConfirm: boolean
|
||||
autoFinishOnLastControl: boolean
|
||||
defaultControlScore: number
|
||||
}
|
||||
|
||||
const GAME_MODE_DEFAULTS: Record<GameMode, GameModeDefaults> = {
|
||||
'classic-sequential': {
|
||||
sessionCloseAfterMs: 2 * 60 * 60 * 1000,
|
||||
sessionCloseWarningMs: 10 * 60 * 1000,
|
||||
minCompletedControlsBeforeFinish: 0,
|
||||
requiresFocusSelection: false,
|
||||
skipEnabled: true,
|
||||
skipRequiresConfirm: true,
|
||||
autoFinishOnLastControl: false,
|
||||
defaultControlScore: 1,
|
||||
},
|
||||
'score-o': {
|
||||
sessionCloseAfterMs: 2 * 60 * 60 * 1000,
|
||||
sessionCloseWarningMs: 10 * 60 * 1000,
|
||||
minCompletedControlsBeforeFinish: 1,
|
||||
requiresFocusSelection: false,
|
||||
skipEnabled: false,
|
||||
skipRequiresConfirm: true,
|
||||
autoFinishOnLastControl: false,
|
||||
defaultControlScore: 10,
|
||||
},
|
||||
}
|
||||
|
||||
export function getGameModeDefaults(mode: GameMode): GameModeDefaults {
|
||||
return GAME_MODE_DEFAULTS[mode]
|
||||
}
|
||||
|
||||
export function getDefaultSkipRadiusMeters(mode: GameMode, punchRadiusMeters: number): number {
|
||||
if (mode === 'classic-sequential') {
|
||||
return punchRadiusMeters * 2
|
||||
}
|
||||
|
||||
return 30
|
||||
}
|
||||
|
||||
export function resolveDefaultControlScore(mode: GameMode, configuredDefaultScore: number | null): number {
|
||||
if (typeof configuredDefaultScore === 'number') {
|
||||
return configuredDefaultScore
|
||||
}
|
||||
|
||||
return getGameModeDefaults(mode).defaultControlScore
|
||||
}
|
||||
@@ -5,9 +5,10 @@ export type GameEffect =
|
||||
| { type: 'session_started' }
|
||||
| { type: 'session_cancelled' }
|
||||
| { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' }
|
||||
| { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string; displayAutoPopup: boolean; displayOnce: boolean; displayPriority: number }
|
||||
| { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string; displayAutoPopup: boolean; displayOnce: boolean; displayPriority: number; autoOpenQuiz: boolean }
|
||||
| { type: 'guidance_state_changed'; guidanceState: GuidanceState; controlId: string | null }
|
||||
| { type: 'session_finished' }
|
||||
| { type: 'session_timed_out' }
|
||||
|
||||
export interface GameResult {
|
||||
nextState: GameSessionState
|
||||
|
||||
@@ -54,6 +54,36 @@ export class GameRuntime {
|
||||
return result
|
||||
}
|
||||
|
||||
restoreDefinition(definition: GameDefinition, state: GameSessionState): GameResult {
|
||||
this.definition = definition
|
||||
this.plugin = this.resolvePlugin(definition)
|
||||
this.state = {
|
||||
status: state.status,
|
||||
endReason: state.endReason,
|
||||
startedAt: state.startedAt,
|
||||
endedAt: state.endedAt,
|
||||
completedControlIds: state.completedControlIds.slice(),
|
||||
skippedControlIds: state.skippedControlIds.slice(),
|
||||
currentTargetControlId: state.currentTargetControlId,
|
||||
inRangeControlId: state.inRangeControlId,
|
||||
score: state.score,
|
||||
guidanceState: state.guidanceState,
|
||||
modeState: state.modeState
|
||||
? JSON.parse(JSON.stringify(state.modeState)) as Record<string, unknown>
|
||||
: null,
|
||||
}
|
||||
const result: GameResult = {
|
||||
nextState: this.state,
|
||||
presentation: this.plugin.buildPresentation(definition, this.state),
|
||||
effects: [],
|
||||
}
|
||||
this.presentation = result.presentation
|
||||
this.mapPresentation = result.presentation.map
|
||||
this.hudPresentation = result.presentation.hud
|
||||
this.lastResult = result
|
||||
return result
|
||||
}
|
||||
|
||||
startSession(startAt = Date.now()): GameResult {
|
||||
return this.dispatch({ type: 'session_started', at: startAt })
|
||||
}
|
||||
@@ -62,6 +92,7 @@ export class GameRuntime {
|
||||
if (!this.definition || !this.plugin || !this.state) {
|
||||
const emptyState: GameSessionState = {
|
||||
status: 'idle',
|
||||
endReason: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
completedControlIds: [],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
|
||||
export type GuidanceState = 'searching' | 'approaching' | 'ready'
|
||||
export type GameSessionEndReason = 'completed' | 'timed_out' | 'cancelled' | null
|
||||
export type GuidanceState = 'searching' | 'distant' | 'approaching' | 'ready'
|
||||
export type GameModeState = Record<string, unknown> | null
|
||||
|
||||
export interface GameSessionState {
|
||||
status: GameSessionStatus
|
||||
endReason: GameSessionEndReason
|
||||
startedAt: number | null
|
||||
endedAt: number | null
|
||||
completedControlIds: string[]
|
||||
|
||||
150
miniprogram/game/core/runtimeProfileCompiler.ts
Normal file
150
miniprogram/game/core/runtimeProfileCompiler.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { type RemoteMapConfig } from '../../utils/remoteMapConfig'
|
||||
import { type GameAudioConfig } from '../audio/audioConfig'
|
||||
import { type GameHapticsConfig, type GameUiEffectsConfig } from '../feedback/feedbackConfig'
|
||||
import { getGameModeDefaults } from './gameModeDefaults'
|
||||
import {
|
||||
resolveSystemSettingsState,
|
||||
type ResolvedSystemSettingsState,
|
||||
} from './systemSettingsState'
|
||||
import { type CourseStyleConfig } from '../presentation/courseStyleConfig'
|
||||
import { type GpsMarkerStyleConfig } from '../presentation/gpsMarkerStyleConfig'
|
||||
import { type TrackVisualizationConfig } from '../presentation/trackStyleConfig'
|
||||
import { mergeTelemetrySources, type PlayerTelemetryProfile } from '../telemetry/playerTelemetryProfile'
|
||||
import { type TelemetryConfig } from '../telemetry/telemetryConfig'
|
||||
|
||||
export interface RuntimeMapProfile {
|
||||
title: string
|
||||
tileSource: string
|
||||
projectionModeText: string
|
||||
magneticDeclinationText: string
|
||||
cpRadiusMeters: number
|
||||
projection: string
|
||||
magneticDeclinationDeg: number
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
initialZoom: number
|
||||
initialCenterTileX: number
|
||||
initialCenterTileY: number
|
||||
tileBoundsByZoom: RemoteMapConfig['tileBoundsByZoom']
|
||||
courseStatusText: string
|
||||
}
|
||||
|
||||
export interface RuntimeGameProfile {
|
||||
mode: RemoteMapConfig['gameMode']
|
||||
sessionCloseAfterMs: number
|
||||
sessionCloseWarningMs: number
|
||||
minCompletedControlsBeforeFinish: number
|
||||
punchPolicy: RemoteMapConfig['punchPolicy']
|
||||
punchRadiusMeters: number
|
||||
requiresFocusSelection: boolean
|
||||
skipEnabled: boolean
|
||||
skipRadiusMeters: number
|
||||
skipRequiresConfirm: boolean
|
||||
autoFinishOnLastControl: boolean
|
||||
defaultControlScore: number | null
|
||||
}
|
||||
|
||||
export interface RuntimePresentationProfile {
|
||||
course: CourseStyleConfig
|
||||
track: TrackVisualizationConfig
|
||||
gpsMarker: GpsMarkerStyleConfig
|
||||
}
|
||||
|
||||
export interface RuntimeFeedbackProfile {
|
||||
audio: GameAudioConfig
|
||||
haptics: GameHapticsConfig
|
||||
uiEffects: GameUiEffectsConfig
|
||||
}
|
||||
|
||||
export interface RuntimeTelemetryProfile {
|
||||
config: TelemetryConfig
|
||||
playerProfile: PlayerTelemetryProfile | null
|
||||
}
|
||||
|
||||
export interface RuntimeSettingsProfile extends ResolvedSystemSettingsState {
|
||||
lockLifetimeActive: boolean
|
||||
}
|
||||
|
||||
export interface CompiledRuntimeProfile {
|
||||
map: RuntimeMapProfile
|
||||
game: RuntimeGameProfile
|
||||
settings: RuntimeSettingsProfile
|
||||
telemetry: RuntimeTelemetryProfile
|
||||
presentation: RuntimePresentationProfile
|
||||
feedback: RuntimeFeedbackProfile
|
||||
}
|
||||
|
||||
export interface CompileRuntimeProfileOptions {
|
||||
playerTelemetryProfile?: PlayerTelemetryProfile | null
|
||||
settingsLockLifetimeActive?: boolean
|
||||
storedSettingsKey?: string
|
||||
}
|
||||
|
||||
export function compileRuntimeProfile(
|
||||
config: RemoteMapConfig,
|
||||
options?: CompileRuntimeProfileOptions,
|
||||
): CompiledRuntimeProfile {
|
||||
const modeDefaults = getGameModeDefaults(config.gameMode)
|
||||
const lockLifetimeActive = !!(options && options.settingsLockLifetimeActive === true)
|
||||
const playerTelemetryProfile = options && options.playerTelemetryProfile
|
||||
? Object.assign({}, options.playerTelemetryProfile)
|
||||
: null
|
||||
|
||||
const settings = resolveSystemSettingsState(
|
||||
config.systemSettingsConfig,
|
||||
options && options.storedSettingsKey ? options.storedSettingsKey : undefined,
|
||||
lockLifetimeActive,
|
||||
)
|
||||
|
||||
return {
|
||||
map: {
|
||||
title: config.configTitle,
|
||||
tileSource: config.tileSource,
|
||||
projectionModeText: config.projectionModeText,
|
||||
magneticDeclinationText: config.magneticDeclinationText,
|
||||
cpRadiusMeters: config.cpRadiusMeters,
|
||||
projection: config.projection,
|
||||
magneticDeclinationDeg: config.magneticDeclinationDeg,
|
||||
minZoom: config.minZoom,
|
||||
maxZoom: config.maxZoom,
|
||||
initialZoom: config.defaultZoom,
|
||||
initialCenterTileX: config.initialCenterTileX,
|
||||
initialCenterTileY: config.initialCenterTileY,
|
||||
tileBoundsByZoom: config.tileBoundsByZoom,
|
||||
courseStatusText: config.courseStatusText,
|
||||
},
|
||||
game: {
|
||||
mode: config.gameMode,
|
||||
sessionCloseAfterMs: config.sessionCloseAfterMs || modeDefaults.sessionCloseAfterMs,
|
||||
sessionCloseWarningMs: config.sessionCloseWarningMs || modeDefaults.sessionCloseWarningMs,
|
||||
minCompletedControlsBeforeFinish: config.minCompletedControlsBeforeFinish,
|
||||
punchPolicy: config.punchPolicy,
|
||||
punchRadiusMeters: config.punchRadiusMeters,
|
||||
requiresFocusSelection: config.requiresFocusSelection,
|
||||
skipEnabled: config.skipEnabled,
|
||||
skipRadiusMeters: config.skipRadiusMeters,
|
||||
skipRequiresConfirm: config.skipRequiresConfirm,
|
||||
autoFinishOnLastControl: config.autoFinishOnLastControl,
|
||||
defaultControlScore: config.defaultControlScore,
|
||||
},
|
||||
settings: {
|
||||
values: settings.values,
|
||||
locks: settings.locks,
|
||||
lockLifetimeActive,
|
||||
},
|
||||
telemetry: {
|
||||
config: mergeTelemetrySources(config.telemetryConfig, playerTelemetryProfile),
|
||||
playerProfile: playerTelemetryProfile,
|
||||
},
|
||||
presentation: {
|
||||
course: config.courseStyleConfig,
|
||||
track: config.trackStyleConfig,
|
||||
gpsMarker: config.gpsMarkerStyleConfig,
|
||||
},
|
||||
feedback: {
|
||||
audio: config.audioConfig,
|
||||
haptics: config.hapticsConfig,
|
||||
uiEffects: config.uiEffectsConfig,
|
||||
},
|
||||
}
|
||||
}
|
||||
146
miniprogram/game/core/sessionRecovery.ts
Normal file
146
miniprogram/game/core/sessionRecovery.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { type LonLatPoint } from '../../utils/projection'
|
||||
import { type GameLaunchEnvelope } from '../../utils/gameLaunch'
|
||||
import { type GameSessionState } from './gameSessionState'
|
||||
|
||||
export interface RecoveryTelemetrySnapshot {
|
||||
distanceMeters: number
|
||||
currentSpeedKmh: number | null
|
||||
averageSpeedKmh: number | null
|
||||
heartRateBpm: number | null
|
||||
caloriesKcal: number | null
|
||||
lastGpsPoint: LonLatPoint | null
|
||||
lastGpsAt: number | null
|
||||
lastGpsAccuracyMeters: number | null
|
||||
}
|
||||
|
||||
export interface RecoveryViewportSnapshot {
|
||||
zoom: number
|
||||
centerTileX: number
|
||||
centerTileY: number
|
||||
rotationDeg: number
|
||||
gpsLockEnabled: boolean
|
||||
hasGpsCenteredOnce: boolean
|
||||
}
|
||||
|
||||
export interface RecoveryRuntimeSnapshot {
|
||||
gameState: GameSessionState
|
||||
telemetry: RecoveryTelemetrySnapshot
|
||||
viewport: RecoveryViewportSnapshot
|
||||
currentGpsPoint: LonLatPoint | null
|
||||
currentGpsAccuracyMeters: number | null
|
||||
currentGpsInsideMap: boolean
|
||||
bonusScore: number
|
||||
quizCorrectCount: number
|
||||
quizWrongCount: number
|
||||
quizTimeoutCount: number
|
||||
}
|
||||
|
||||
export interface SessionRecoverySnapshot {
|
||||
schemaVersion: 1
|
||||
savedAt: number
|
||||
launchEnvelope: GameLaunchEnvelope
|
||||
configAppId: string
|
||||
configVersion: string
|
||||
runtime: RecoveryRuntimeSnapshot
|
||||
}
|
||||
|
||||
const SESSION_RECOVERY_STORAGE_KEY = 'cmr.sessionRecovery.v1'
|
||||
|
||||
function cloneLonLatPoint(point: LonLatPoint | null): LonLatPoint | null {
|
||||
if (!point) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
lon: point.lon,
|
||||
lat: point.lat,
|
||||
}
|
||||
}
|
||||
|
||||
function cloneGameSessionState(state: GameSessionState): GameSessionState {
|
||||
return {
|
||||
status: state.status,
|
||||
endReason: state.endReason,
|
||||
startedAt: state.startedAt,
|
||||
endedAt: state.endedAt,
|
||||
completedControlIds: state.completedControlIds.slice(),
|
||||
skippedControlIds: state.skippedControlIds.slice(),
|
||||
currentTargetControlId: state.currentTargetControlId,
|
||||
inRangeControlId: state.inRangeControlId,
|
||||
score: state.score,
|
||||
guidanceState: state.guidanceState,
|
||||
modeState: state.modeState
|
||||
? JSON.parse(JSON.stringify(state.modeState)) as Record<string, unknown>
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneSessionRecoverySnapshot(snapshot: SessionRecoverySnapshot): SessionRecoverySnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
savedAt: snapshot.savedAt,
|
||||
launchEnvelope: JSON.parse(JSON.stringify(snapshot.launchEnvelope)) as GameLaunchEnvelope,
|
||||
configAppId: snapshot.configAppId,
|
||||
configVersion: snapshot.configVersion,
|
||||
runtime: {
|
||||
gameState: cloneGameSessionState(snapshot.runtime.gameState),
|
||||
telemetry: {
|
||||
distanceMeters: snapshot.runtime.telemetry.distanceMeters,
|
||||
currentSpeedKmh: snapshot.runtime.telemetry.currentSpeedKmh,
|
||||
averageSpeedKmh: snapshot.runtime.telemetry.averageSpeedKmh,
|
||||
heartRateBpm: snapshot.runtime.telemetry.heartRateBpm,
|
||||
caloriesKcal: snapshot.runtime.telemetry.caloriesKcal,
|
||||
lastGpsPoint: cloneLonLatPoint(snapshot.runtime.telemetry.lastGpsPoint),
|
||||
lastGpsAt: snapshot.runtime.telemetry.lastGpsAt,
|
||||
lastGpsAccuracyMeters: snapshot.runtime.telemetry.lastGpsAccuracyMeters,
|
||||
},
|
||||
viewport: {
|
||||
zoom: snapshot.runtime.viewport.zoom,
|
||||
centerTileX: snapshot.runtime.viewport.centerTileX,
|
||||
centerTileY: snapshot.runtime.viewport.centerTileY,
|
||||
rotationDeg: snapshot.runtime.viewport.rotationDeg,
|
||||
gpsLockEnabled: snapshot.runtime.viewport.gpsLockEnabled,
|
||||
hasGpsCenteredOnce: snapshot.runtime.viewport.hasGpsCenteredOnce,
|
||||
},
|
||||
currentGpsPoint: cloneLonLatPoint(snapshot.runtime.currentGpsPoint),
|
||||
currentGpsAccuracyMeters: snapshot.runtime.currentGpsAccuracyMeters,
|
||||
currentGpsInsideMap: snapshot.runtime.currentGpsInsideMap,
|
||||
bonusScore: snapshot.runtime.bonusScore,
|
||||
quizCorrectCount: snapshot.runtime.quizCorrectCount,
|
||||
quizWrongCount: snapshot.runtime.quizWrongCount,
|
||||
quizTimeoutCount: snapshot.runtime.quizTimeoutCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSessionRecoverySnapshot(raw: unknown): SessionRecoverySnapshot | null {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = raw as SessionRecoverySnapshot
|
||||
if (candidate.schemaVersion !== 1 || !candidate.runtime || !candidate.runtime.gameState) {
|
||||
return null
|
||||
}
|
||||
|
||||
return cloneSessionRecoverySnapshot(candidate)
|
||||
}
|
||||
|
||||
export function loadSessionRecoverySnapshot(): SessionRecoverySnapshot | null {
|
||||
try {
|
||||
return normalizeSessionRecoverySnapshot(wx.getStorageSync(SESSION_RECOVERY_STORAGE_KEY))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSessionRecoverySnapshot(snapshot: SessionRecoverySnapshot): void {
|
||||
try {
|
||||
wx.setStorageSync(SESSION_RECOVERY_STORAGE_KEY, cloneSessionRecoverySnapshot(snapshot))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function clearSessionRecoverySnapshot(): void {
|
||||
try {
|
||||
wx.removeStorageSync(SESSION_RECOVERY_STORAGE_KEY)
|
||||
} catch {}
|
||||
}
|
||||
292
miniprogram/game/core/systemSettingsState.ts
Normal file
292
miniprogram/game/core/systemSettingsState.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { type AnimationLevel } from '../../utils/animationLevel'
|
||||
import { type TrackColorPreset, type TrackDisplayMode, type TrackStyleProfile, type TrackTailLengthPreset } from '../presentation/trackStyleConfig'
|
||||
import { type GpsMarkerColorPreset, type GpsMarkerSizePreset, type GpsMarkerStyleId } from '../presentation/gpsMarkerStyleConfig'
|
||||
|
||||
export type SideButtonPlacement = 'left' | 'right'
|
||||
export type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center'
|
||||
export type UserNorthReferenceMode = 'magnetic' | 'true'
|
||||
export type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive'
|
||||
|
||||
export type SettingLockKey =
|
||||
| 'lockAnimationLevel'
|
||||
| 'lockTrackMode'
|
||||
| 'lockTrackTailLength'
|
||||
| 'lockTrackColor'
|
||||
| 'lockTrackStyle'
|
||||
| 'lockGpsMarkerVisible'
|
||||
| 'lockGpsMarkerStyle'
|
||||
| 'lockGpsMarkerSize'
|
||||
| 'lockGpsMarkerColor'
|
||||
| 'lockSideButtonPlacement'
|
||||
| 'lockAutoRotate'
|
||||
| 'lockCompassTuning'
|
||||
| 'lockScaleRulerVisible'
|
||||
| 'lockScaleRulerAnchor'
|
||||
| 'lockNorthReference'
|
||||
| 'lockHeartRateDevice'
|
||||
|
||||
export type StoredUserSettings = {
|
||||
animationLevel?: AnimationLevel
|
||||
trackDisplayMode?: TrackDisplayMode
|
||||
trackTailLength?: TrackTailLengthPreset
|
||||
trackColorPreset?: TrackColorPreset
|
||||
trackStyleProfile?: TrackStyleProfile
|
||||
gpsMarkerVisible?: boolean
|
||||
gpsMarkerStyle?: GpsMarkerStyleId
|
||||
gpsMarkerSize?: GpsMarkerSizePreset
|
||||
gpsMarkerColorPreset?: GpsMarkerColorPreset
|
||||
autoRotateEnabled?: boolean
|
||||
compassTuningProfile?: CompassTuningProfile
|
||||
northReferenceMode?: UserNorthReferenceMode
|
||||
sideButtonPlacement?: SideButtonPlacement
|
||||
showCenterScaleRuler?: boolean
|
||||
centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode
|
||||
}
|
||||
|
||||
export interface SystemSettingsConfig {
|
||||
values: Partial<StoredUserSettings>
|
||||
locks: Partial<Record<SettingLockKey, boolean>>
|
||||
}
|
||||
|
||||
export type ResolvedSystemSettingsState = {
|
||||
values: Required<StoredUserSettings>
|
||||
locks: Record<SettingLockKey, boolean>
|
||||
}
|
||||
|
||||
export const USER_SETTINGS_STORAGE_KEY = 'cmr_user_settings_v1'
|
||||
|
||||
export const DEFAULT_STORED_USER_SETTINGS: Required<StoredUserSettings> = {
|
||||
animationLevel: 'standard',
|
||||
trackDisplayMode: 'full',
|
||||
trackTailLength: 'medium',
|
||||
trackColorPreset: 'mint',
|
||||
trackStyleProfile: 'neon',
|
||||
gpsMarkerVisible: true,
|
||||
gpsMarkerStyle: 'beacon',
|
||||
gpsMarkerSize: 'medium',
|
||||
gpsMarkerColorPreset: 'cyan',
|
||||
autoRotateEnabled: true,
|
||||
compassTuningProfile: 'balanced',
|
||||
northReferenceMode: 'magnetic',
|
||||
sideButtonPlacement: 'left',
|
||||
showCenterScaleRuler: false,
|
||||
centerScaleRulerAnchorMode: 'screen-center',
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTING_LOCKS: Record<SettingLockKey, boolean> = {
|
||||
lockAnimationLevel: false,
|
||||
lockTrackMode: false,
|
||||
lockTrackTailLength: false,
|
||||
lockTrackColor: false,
|
||||
lockTrackStyle: false,
|
||||
lockGpsMarkerVisible: false,
|
||||
lockGpsMarkerStyle: false,
|
||||
lockGpsMarkerSize: false,
|
||||
lockGpsMarkerColor: false,
|
||||
lockSideButtonPlacement: false,
|
||||
lockAutoRotate: false,
|
||||
lockCompassTuning: false,
|
||||
lockScaleRulerVisible: false,
|
||||
lockScaleRulerAnchor: false,
|
||||
lockNorthReference: false,
|
||||
lockHeartRateDevice: false,
|
||||
}
|
||||
|
||||
export const SETTING_LOCK_VALUE_MAP: Record<SettingLockKey, keyof StoredUserSettings | null> = {
|
||||
lockAnimationLevel: 'animationLevel',
|
||||
lockTrackMode: 'trackDisplayMode',
|
||||
lockTrackTailLength: 'trackTailLength',
|
||||
lockTrackColor: 'trackColorPreset',
|
||||
lockTrackStyle: 'trackStyleProfile',
|
||||
lockGpsMarkerVisible: 'gpsMarkerVisible',
|
||||
lockGpsMarkerStyle: 'gpsMarkerStyle',
|
||||
lockGpsMarkerSize: 'gpsMarkerSize',
|
||||
lockGpsMarkerColor: 'gpsMarkerColorPreset',
|
||||
lockSideButtonPlacement: 'sideButtonPlacement',
|
||||
lockAutoRotate: 'autoRotateEnabled',
|
||||
lockCompassTuning: 'compassTuningProfile',
|
||||
lockScaleRulerVisible: 'showCenterScaleRuler',
|
||||
lockScaleRulerAnchor: 'centerScaleRulerAnchorMode',
|
||||
lockNorthReference: 'northReferenceMode',
|
||||
lockHeartRateDevice: null,
|
||||
}
|
||||
|
||||
function normalizeStoredUserSettings(raw: unknown): StoredUserSettings {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const normalized = raw as Record<string, unknown>
|
||||
const settings: StoredUserSettings = {}
|
||||
if (normalized.animationLevel === 'standard' || normalized.animationLevel === 'lite') {
|
||||
settings.animationLevel = normalized.animationLevel
|
||||
}
|
||||
if (normalized.trackDisplayMode === 'none' || normalized.trackDisplayMode === 'full' || normalized.trackDisplayMode === 'tail') {
|
||||
settings.trackDisplayMode = normalized.trackDisplayMode
|
||||
}
|
||||
if (normalized.trackTailLength === 'short' || normalized.trackTailLength === 'medium' || normalized.trackTailLength === 'long') {
|
||||
settings.trackTailLength = normalized.trackTailLength
|
||||
}
|
||||
if (normalized.trackStyleProfile === 'classic' || normalized.trackStyleProfile === 'neon') {
|
||||
settings.trackStyleProfile = normalized.trackStyleProfile
|
||||
}
|
||||
if (typeof normalized.gpsMarkerVisible === 'boolean') {
|
||||
settings.gpsMarkerVisible = normalized.gpsMarkerVisible
|
||||
}
|
||||
if (
|
||||
normalized.gpsMarkerStyle === 'dot'
|
||||
|| normalized.gpsMarkerStyle === 'beacon'
|
||||
|| normalized.gpsMarkerStyle === 'disc'
|
||||
|| normalized.gpsMarkerStyle === 'badge'
|
||||
) {
|
||||
settings.gpsMarkerStyle = normalized.gpsMarkerStyle
|
||||
}
|
||||
if (normalized.gpsMarkerSize === 'small' || normalized.gpsMarkerSize === 'medium' || normalized.gpsMarkerSize === 'large') {
|
||||
settings.gpsMarkerSize = normalized.gpsMarkerSize
|
||||
}
|
||||
if (
|
||||
normalized.gpsMarkerColorPreset === 'mint'
|
||||
|| normalized.gpsMarkerColorPreset === 'cyan'
|
||||
|| normalized.gpsMarkerColorPreset === 'sky'
|
||||
|| normalized.gpsMarkerColorPreset === 'blue'
|
||||
|| normalized.gpsMarkerColorPreset === 'violet'
|
||||
|| normalized.gpsMarkerColorPreset === 'pink'
|
||||
|| normalized.gpsMarkerColorPreset === 'orange'
|
||||
|| normalized.gpsMarkerColorPreset === 'yellow'
|
||||
) {
|
||||
settings.gpsMarkerColorPreset = normalized.gpsMarkerColorPreset
|
||||
}
|
||||
if (
|
||||
normalized.trackColorPreset === 'mint'
|
||||
|| normalized.trackColorPreset === 'cyan'
|
||||
|| normalized.trackColorPreset === 'sky'
|
||||
|| normalized.trackColorPreset === 'blue'
|
||||
|| normalized.trackColorPreset === 'violet'
|
||||
|| normalized.trackColorPreset === 'pink'
|
||||
|| normalized.trackColorPreset === 'orange'
|
||||
|| normalized.trackColorPreset === 'yellow'
|
||||
) {
|
||||
settings.trackColorPreset = normalized.trackColorPreset
|
||||
}
|
||||
if (normalized.northReferenceMode === 'magnetic' || normalized.northReferenceMode === 'true') {
|
||||
settings.northReferenceMode = normalized.northReferenceMode
|
||||
}
|
||||
if (typeof normalized.autoRotateEnabled === 'boolean') {
|
||||
settings.autoRotateEnabled = normalized.autoRotateEnabled
|
||||
}
|
||||
if (normalized.compassTuningProfile === 'smooth' || normalized.compassTuningProfile === 'balanced' || normalized.compassTuningProfile === 'responsive') {
|
||||
settings.compassTuningProfile = normalized.compassTuningProfile
|
||||
}
|
||||
if (normalized.sideButtonPlacement === 'left' || normalized.sideButtonPlacement === 'right') {
|
||||
settings.sideButtonPlacement = normalized.sideButtonPlacement
|
||||
}
|
||||
if (typeof normalized.showCenterScaleRuler === 'boolean') {
|
||||
settings.showCenterScaleRuler = normalized.showCenterScaleRuler
|
||||
}
|
||||
if (normalized.centerScaleRulerAnchorMode === 'screen-center' || normalized.centerScaleRulerAnchorMode === 'compass-center') {
|
||||
settings.centerScaleRulerAnchorMode = normalized.centerScaleRulerAnchorMode
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
export function loadStoredUserSettings(storageKey = USER_SETTINGS_STORAGE_KEY): StoredUserSettings {
|
||||
try {
|
||||
return normalizeStoredUserSettings(wx.getStorageSync(storageKey))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function persistStoredUserSettings(
|
||||
settings: StoredUserSettings,
|
||||
storageKey = USER_SETTINGS_STORAGE_KEY,
|
||||
): void {
|
||||
try {
|
||||
wx.setStorageSync(storageKey, settings)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function mergeStoredUserSettings(
|
||||
current: StoredUserSettings,
|
||||
patch: Partial<StoredUserSettings>,
|
||||
): StoredUserSettings {
|
||||
return {
|
||||
...current,
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInitialSystemSettingsState(
|
||||
stored: StoredUserSettings,
|
||||
config?: Partial<SystemSettingsConfig>,
|
||||
): ResolvedSystemSettingsState {
|
||||
const values = {
|
||||
...DEFAULT_STORED_USER_SETTINGS,
|
||||
...(config && config.values ? config.values : {}),
|
||||
}
|
||||
const locks = {
|
||||
...DEFAULT_SETTING_LOCKS,
|
||||
...(config && config.locks ? config.locks : {}),
|
||||
}
|
||||
|
||||
const resolvedValues: Required<StoredUserSettings> = {
|
||||
...values,
|
||||
}
|
||||
|
||||
for (const [lockKey, isLocked] of Object.entries(locks) as Array<[SettingLockKey, boolean]>) {
|
||||
const valueKey = SETTING_LOCK_VALUE_MAP[lockKey]
|
||||
if (!valueKey) {
|
||||
continue
|
||||
}
|
||||
if (!isLocked && stored[valueKey] !== undefined) {
|
||||
;(resolvedValues as Record<string, unknown>)[valueKey] = stored[valueKey]
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(stored) as Array<[keyof StoredUserSettings, StoredUserSettings[keyof StoredUserSettings]]>) {
|
||||
const matchingLockKey = (Object.keys(SETTING_LOCK_VALUE_MAP) as SettingLockKey[])
|
||||
.find((lockKey) => SETTING_LOCK_VALUE_MAP[lockKey] === key)
|
||||
if (matchingLockKey && locks[matchingLockKey]) {
|
||||
continue
|
||||
}
|
||||
if (value !== undefined) {
|
||||
;(resolvedValues as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
values: resolvedValues,
|
||||
locks,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRuntimeSettingLocks(
|
||||
locks: Partial<Record<SettingLockKey, boolean>> | undefined,
|
||||
runtimeActive: boolean,
|
||||
): Partial<Record<SettingLockKey, boolean>> {
|
||||
const sourceLocks = locks || {}
|
||||
if (runtimeActive) {
|
||||
return { ...sourceLocks }
|
||||
}
|
||||
|
||||
const unlocked: Partial<Record<SettingLockKey, boolean>> = {}
|
||||
for (const key of Object.keys(sourceLocks) as SettingLockKey[]) {
|
||||
unlocked[key] = false
|
||||
}
|
||||
return unlocked
|
||||
}
|
||||
|
||||
export function resolveSystemSettingsState(
|
||||
config?: Partial<SystemSettingsConfig>,
|
||||
storageKey = USER_SETTINGS_STORAGE_KEY,
|
||||
runtimeActive = false,
|
||||
): ResolvedSystemSettingsState {
|
||||
return buildInitialSystemSettingsState(
|
||||
loadStoredUserSettings(storageKey),
|
||||
{
|
||||
values: config && config.values ? config.values : {},
|
||||
locks: buildRuntimeSettingLocks(config && config.locks ? config.locks : {}, runtimeActive),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export interface ContentCardActionViewModel {
|
||||
|
||||
export const DEFAULT_CONTENT_CARD_QUIZ_CONFIG: ContentCardQuizConfig = {
|
||||
bonusScore: 1,
|
||||
countdownSeconds: 12,
|
||||
countdownSeconds: 10,
|
||||
minValue: 10,
|
||||
maxValue: 999,
|
||||
allowSubtraction: true,
|
||||
|
||||
@@ -3,11 +3,13 @@ import { type AnimationLevel } from '../../utils/animationLevel'
|
||||
export type FeedbackCueKey =
|
||||
| 'session_started'
|
||||
| 'session_finished'
|
||||
| 'hint:changed'
|
||||
| 'control_completed:start'
|
||||
| 'control_completed:control'
|
||||
| 'control_completed:finish'
|
||||
| 'punch_feedback:warning'
|
||||
| 'guidance:searching'
|
||||
| 'guidance:distant'
|
||||
| 'guidance:approaching'
|
||||
| 'guidance:ready'
|
||||
|
||||
@@ -83,12 +85,14 @@ export const DEFAULT_GAME_HAPTICS_CONFIG: GameHapticsConfig = {
|
||||
cues: {
|
||||
session_started: { enabled: false, pattern: 'short' },
|
||||
session_finished: { enabled: true, pattern: 'long' },
|
||||
'hint:changed': { enabled: true, pattern: 'short' },
|
||||
'control_completed:start': { enabled: true, pattern: 'short' },
|
||||
'control_completed:control': { enabled: true, pattern: 'short' },
|
||||
'control_completed:finish': { enabled: true, pattern: 'long' },
|
||||
'punch_feedback:warning': { enabled: true, pattern: 'short' },
|
||||
'guidance:searching': { enabled: false, pattern: 'short' },
|
||||
'guidance:approaching': { enabled: false, pattern: 'short' },
|
||||
'guidance:distant': { enabled: true, pattern: 'short' },
|
||||
'guidance:approaching': { enabled: true, pattern: 'short' },
|
||||
'guidance:ready': { enabled: true, pattern: 'short' },
|
||||
},
|
||||
}
|
||||
@@ -98,11 +102,13 @@ export const DEFAULT_GAME_UI_EFFECTS_CONFIG: GameUiEffectsConfig = {
|
||||
cues: {
|
||||
session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
||||
session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
||||
'hint:changed': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
||||
'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 },
|
||||
'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 },
|
||||
'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', hudProgressMotion: 'finish', hudDistanceMotion: 'success', durationMs: 680 },
|
||||
'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 560 },
|
||||
'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
||||
'guidance:distant': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
||||
'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
||||
'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 900 },
|
||||
},
|
||||
@@ -137,11 +143,13 @@ export function mergeGameHapticsConfig(overrides?: GameHapticsConfigOverrides |
|
||||
const cues: GameHapticsConfig['cues'] = {
|
||||
session_started: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
|
||||
session_finished: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
|
||||
'hint:changed': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['hint:changed'], overrides && overrides.cues ? overrides.cues['hint:changed'] : undefined),
|
||||
'control_completed:start': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
|
||||
'control_completed:control': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
|
||||
'control_completed:finish': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
|
||||
'punch_feedback:warning': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
|
||||
'guidance:searching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
|
||||
'guidance:distant': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:distant'], overrides && overrides.cues ? overrides.cues['guidance:distant'] : undefined),
|
||||
'guidance:approaching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
|
||||
'guidance:ready': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
|
||||
}
|
||||
@@ -156,11 +164,13 @@ export function mergeGameUiEffectsConfig(overrides?: GameUiEffectsConfigOverride
|
||||
const cues: GameUiEffectsConfig['cues'] = {
|
||||
session_started: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
|
||||
session_finished: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
|
||||
'hint:changed': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['hint:changed'], overrides && overrides.cues ? overrides.cues['hint:changed'] : undefined),
|
||||
'control_completed:start': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
|
||||
'control_completed:control': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
|
||||
'control_completed:finish': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
|
||||
'punch_feedback:warning': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
|
||||
'guidance:searching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
|
||||
'guidance:distant': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:distant'], overrides && overrides.cues ? overrides.cues['guidance:distant'] : undefined),
|
||||
'guidance:approaching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
|
||||
'guidance:ready': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DEFAULT_GAME_AUDIO_CONFIG, type GameAudioConfig } from '../audio/audioConfig'
|
||||
import { DEFAULT_GAME_AUDIO_CONFIG, type AudioCueKey, type GameAudioConfig } from '../audio/audioConfig'
|
||||
import { SoundDirector } from '../audio/soundDirector'
|
||||
import { type GameEffect } from '../core/gameResult'
|
||||
import { type AnimationLevel } from '../../utils/animationLevel'
|
||||
import {
|
||||
DEFAULT_GAME_HAPTICS_CONFIG,
|
||||
DEFAULT_GAME_UI_EFFECTS_CONFIG,
|
||||
type FeedbackCueKey,
|
||||
type GameHapticsConfig,
|
||||
type GameUiEffectsConfig,
|
||||
} from './feedbackConfig'
|
||||
@@ -61,12 +62,20 @@ export class FeedbackDirector {
|
||||
this.soundDirector.setAppAudioMode(mode)
|
||||
}
|
||||
|
||||
playAudioCue(key: AudioCueKey): void {
|
||||
this.soundDirector.play(key)
|
||||
}
|
||||
|
||||
playHapticCue(key: FeedbackCueKey): void {
|
||||
this.hapticsDirector.trigger(key)
|
||||
}
|
||||
|
||||
handleEffects(effects: GameEffect[]): void {
|
||||
this.soundDirector.handleEffects(effects)
|
||||
this.hapticsDirector.handleEffects(effects)
|
||||
this.uiEffectDirector.handleEffects(effects)
|
||||
|
||||
if (effects.some((effect) => effect.type === 'session_finished')) {
|
||||
if (effects.some((effect) => effect.type === 'session_finished' || effect.type === 'session_timed_out')) {
|
||||
this.host.stopLocationTracking()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ export class HapticsDirector {
|
||||
this.trigger('guidance:searching')
|
||||
continue
|
||||
}
|
||||
if (effect.guidanceState === 'distant') {
|
||||
this.trigger('guidance:distant')
|
||||
continue
|
||||
}
|
||||
if (effect.guidanceState === 'approaching') {
|
||||
this.trigger('guidance:approaching')
|
||||
continue
|
||||
|
||||
@@ -258,17 +258,19 @@ export class UiEffectDirector {
|
||||
'success',
|
||||
cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '',
|
||||
)
|
||||
this.host.showContentCard(
|
||||
effect.displayTitle,
|
||||
effect.displayBody,
|
||||
cue ? this.getContentCardMotionClass(cue.contentCardMotion) : '',
|
||||
{
|
||||
contentKey: effect.controlId,
|
||||
autoPopup: effect.displayAutoPopup,
|
||||
once: effect.displayOnce,
|
||||
priority: effect.displayPriority,
|
||||
},
|
||||
)
|
||||
if (effect.controlKind !== 'finish' && effect.displayAutoPopup) {
|
||||
this.host.showContentCard(
|
||||
effect.displayTitle,
|
||||
effect.displayBody,
|
||||
cue ? this.getContentCardMotionClass(cue.contentCardMotion) : '',
|
||||
{
|
||||
contentKey: effect.controlId,
|
||||
autoPopup: effect.displayAutoPopup,
|
||||
once: effect.displayOnce,
|
||||
priority: effect.displayPriority,
|
||||
},
|
||||
)
|
||||
}
|
||||
if (cue && cue.mapPulseMotion !== 'none') {
|
||||
this.host.showMapPulse(effect.controlId, this.getMapPulseMotionClass(cue.mapPulseMotion))
|
||||
}
|
||||
|
||||
@@ -72,15 +72,15 @@ export const DEFAULT_COURSE_STYLE_CONFIG: CourseStyleConfig = {
|
||||
},
|
||||
scoreO: {
|
||||
controls: {
|
||||
default: { style: 'badge', colorHex: '#cc006b', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02 },
|
||||
focused: { style: 'pulse-core', colorHex: '#fff0fa', sizeScale: 1.12, accentRingScale: 1.36, glowStrength: 1, labelScale: 1.12, labelColorHex: '#fffafc' },
|
||||
collected: { style: 'solid-dot', colorHex: '#d6dae0', sizeScale: 0.82, labelScale: 0.92 },
|
||||
default: { style: 'badge', colorHex: '#cc006b', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02, labelColorHex: '#ffffff' },
|
||||
focused: { style: 'badge', colorHex: '#cc006b', sizeScale: 1.1, accentRingScale: 1.34, glowStrength: 0.92, labelScale: 1.08, labelColorHex: '#ffffff' },
|
||||
collected: { style: 'badge', colorHex: '#9aa3ad', sizeScale: 0.86, accentRingScale: 1.08, labelScale: 0.94, labelColorHex: '#ffffff' },
|
||||
start: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.02, accentRingScale: 1.24, labelScale: 1.02 },
|
||||
finish: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.06, accentRingScale: 1.28, glowStrength: 0.26, labelScale: 1.04, labelColorHex: '#fff4de' },
|
||||
scoreBands: [
|
||||
{ min: 0, max: 19, style: 'badge', colorHex: '#56ccf2', sizeScale: 0.88, accentRingScale: 1.06, labelScale: 0.94 },
|
||||
{ min: 20, max: 49, style: 'badge', colorHex: '#f2c94c', sizeScale: 1.02, accentRingScale: 1.18, labelScale: 1.02 },
|
||||
{ min: 50, max: 999999, style: 'badge', colorHex: '#eb5757', sizeScale: 1.14, accentRingScale: 1.32, glowStrength: 0.72, labelScale: 1.1 },
|
||||
{ min: 0, max: 19, style: 'badge', colorHex: '#56ccf2', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02, labelColorHex: '#ffffff' },
|
||||
{ min: 20, max: 49, style: 'badge', colorHex: '#f2c94c', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02, labelColorHex: '#ffffff' },
|
||||
{ min: 50, max: 999999, style: 'badge', colorHex: '#eb5757', sizeScale: 0.96, accentRingScale: 1.1, glowStrength: 0.72, labelScale: 1.02, labelColorHex: '#ffffff' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface HudPresentationState {
|
||||
actionTagText: string
|
||||
distanceTagText: string
|
||||
targetSummaryText: string
|
||||
hudTargetControlId: string | null
|
||||
progressText: string
|
||||
punchableControlId: string | null
|
||||
@@ -12,6 +13,7 @@ export interface HudPresentationState {
|
||||
export const EMPTY_HUD_PRESENTATION_STATE: HudPresentationState = {
|
||||
actionTagText: '目标',
|
||||
distanceTagText: '点距',
|
||||
targetSummaryText: '等待选择目标',
|
||||
hudTargetControlId: null,
|
||||
progressText: '0/0',
|
||||
punchableControlId: null,
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from './hudPresentationState'
|
||||
import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from './mapPresentationState'
|
||||
|
||||
export interface GameTargetingPresentationState {
|
||||
punchableControlId: string | null
|
||||
guidanceControlId: string | null
|
||||
hudControlId: string | null
|
||||
highlightedControlId: string | null
|
||||
}
|
||||
|
||||
export interface GamePresentationState {
|
||||
map: MapPresentationState
|
||||
hud: HudPresentationState
|
||||
targeting: GameTargetingPresentationState
|
||||
}
|
||||
|
||||
export const EMPTY_GAME_PRESENTATION_STATE: GamePresentationState = {
|
||||
map: EMPTY_MAP_PRESENTATION_STATE,
|
||||
hud: EMPTY_HUD_PRESENTATION_STATE,
|
||||
targeting: {
|
||||
punchableControlId: null,
|
||||
guidanceControlId: null,
|
||||
hudControlId: null,
|
||||
highlightedControlId: null,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,15 @@ export interface ResultSummarySnapshot {
|
||||
rows: ResultSummaryRow[]
|
||||
}
|
||||
|
||||
export interface ResultSummaryMetrics {
|
||||
totalScore?: number
|
||||
baseScore?: number
|
||||
bonusScore?: number
|
||||
quizCorrectCount?: number
|
||||
quizWrongCount?: number
|
||||
quizTimeoutCount?: number
|
||||
}
|
||||
|
||||
function resolveTitle(definition: GameDefinition | null, mapTitle: string): string {
|
||||
if (mapTitle) {
|
||||
return mapTitle
|
||||
@@ -25,11 +34,19 @@ function resolveTitle(definition: GameDefinition | null, mapTitle: string): stri
|
||||
return '本局结果'
|
||||
}
|
||||
|
||||
function buildHeroValue(definition: GameDefinition | null, sessionState: GameSessionState, telemetryPresentation: TelemetryPresentation): string {
|
||||
function buildHeroValue(
|
||||
definition: GameDefinition | null,
|
||||
sessionState: GameSessionState,
|
||||
telemetryPresentation: TelemetryPresentation,
|
||||
metrics?: ResultSummaryMetrics,
|
||||
): string {
|
||||
const totalScore = metrics && typeof metrics.totalScore === 'number'
|
||||
? metrics.totalScore
|
||||
: sessionState.score
|
||||
if (definition && definition.mode === 'score-o') {
|
||||
return `${sessionState.score}`
|
||||
return `${totalScore}`
|
||||
}
|
||||
return telemetryPresentation.timerText
|
||||
return telemetryPresentation.elapsedTimerText
|
||||
}
|
||||
|
||||
function buildHeroLabel(definition: GameDefinition | null): string {
|
||||
@@ -40,6 +57,9 @@ function buildSubtitle(sessionState: GameSessionState): string {
|
||||
if (sessionState.status === 'finished') {
|
||||
return '本局已完成'
|
||||
}
|
||||
if (sessionState.endReason === 'timed_out') {
|
||||
return '本局超时结束'
|
||||
}
|
||||
if (sessionState.status === 'failed') {
|
||||
return '本局已结束'
|
||||
}
|
||||
@@ -51,9 +71,11 @@ export function buildResultSummarySnapshot(
|
||||
sessionState: GameSessionState | null,
|
||||
telemetryPresentation: TelemetryPresentation,
|
||||
mapTitle: string,
|
||||
metrics?: ResultSummaryMetrics,
|
||||
): ResultSummarySnapshot {
|
||||
const resolvedSessionState: GameSessionState = sessionState || {
|
||||
status: 'idle',
|
||||
endReason: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
completedControlIds: [],
|
||||
@@ -71,21 +93,45 @@ export function buildResultSummarySnapshot(
|
||||
const averageHeartRateText = telemetryPresentation.heartRateValueText !== '--'
|
||||
? `${telemetryPresentation.heartRateValueText} ${telemetryPresentation.heartRateUnitText || 'bpm'}`
|
||||
: '--'
|
||||
const totalScore = metrics && typeof metrics.totalScore === 'number' ? metrics.totalScore : resolvedSessionState.score
|
||||
const baseScore = metrics && typeof metrics.baseScore === 'number' ? metrics.baseScore : resolvedSessionState.score
|
||||
const bonusScore = metrics && typeof metrics.bonusScore === 'number' ? metrics.bonusScore : 0
|
||||
const quizCorrectCount = metrics && typeof metrics.quizCorrectCount === 'number' ? metrics.quizCorrectCount : 0
|
||||
const quizWrongCount = metrics && typeof metrics.quizWrongCount === 'number' ? metrics.quizWrongCount : 0
|
||||
const quizTimeoutCount = metrics && typeof metrics.quizTimeoutCount === 'number' ? metrics.quizTimeoutCount : 0
|
||||
const includeQuizRows = bonusScore > 0 || quizCorrectCount > 0 || quizWrongCount > 0 || quizTimeoutCount > 0
|
||||
const rows: ResultSummaryRow[] = [
|
||||
{
|
||||
label: '状态',
|
||||
value: resolvedSessionState.endReason === 'timed_out'
|
||||
? '超时结束'
|
||||
: resolvedSessionState.status === 'finished'
|
||||
? '完成'
|
||||
: (resolvedSessionState.status === 'failed' ? '结束' : '进行中'),
|
||||
},
|
||||
{ label: '完成点数', value: totalControlCount > 0 ? `${resolvedSessionState.completedControlIds.length}/${totalControlCount}` : `${resolvedSessionState.completedControlIds.length}` },
|
||||
{ label: '跳过点数', value: `${skippedCount}` },
|
||||
{ label: '总分', value: `${totalScore}` },
|
||||
]
|
||||
|
||||
if (includeQuizRows) {
|
||||
rows.push({ label: '基础积分', value: `${baseScore}` })
|
||||
rows.push({ label: '答题奖励积分', value: `${bonusScore}` })
|
||||
rows.push({ label: '答题正确数', value: `${quizCorrectCount}` })
|
||||
rows.push({ label: '答题错误数', value: `${quizWrongCount}` })
|
||||
rows.push({ label: '答题超时数', value: `${quizTimeoutCount}` })
|
||||
}
|
||||
|
||||
rows.push({ label: '累计里程', value: telemetryPresentation.mileageText })
|
||||
rows.push({ label: '平均速度', value: `${telemetryPresentation.averageSpeedValueText}${telemetryPresentation.averageSpeedUnitText}` })
|
||||
rows.push({ label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` })
|
||||
rows.push({ label: '平均心率', value: averageHeartRateText })
|
||||
|
||||
return {
|
||||
title: resolveTitle(definition, mapTitle),
|
||||
subtitle: buildSubtitle(resolvedSessionState),
|
||||
heroLabel: buildHeroLabel(definition),
|
||||
heroValue: buildHeroValue(definition, resolvedSessionState, telemetryPresentation),
|
||||
rows: [
|
||||
{ label: '状态', value: resolvedSessionState.status === 'finished' ? '完成' : (resolvedSessionState.status === 'failed' ? '结束' : '进行中') },
|
||||
{ label: '完成点数', value: totalControlCount > 0 ? `${resolvedSessionState.completedControlIds.length}/${totalControlCount}` : `${resolvedSessionState.completedControlIds.length}` },
|
||||
{ label: '跳过点数', value: `${skippedCount}` },
|
||||
{ label: '累计里程', value: telemetryPresentation.mileageText },
|
||||
{ label: '平均速度', value: `${telemetryPresentation.averageSpeedValueText}${telemetryPresentation.averageSpeedUnitText}` },
|
||||
{ label: '当前得分', value: `${resolvedSessionState.score}` },
|
||||
{ label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` },
|
||||
{ label: '平均心率', value: averageHeartRateText },
|
||||
],
|
||||
heroValue: buildHeroValue(definition, resolvedSessionState, telemetryPresentation, metrics),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,15 +79,23 @@ function getTargetText(control: GameControl): string {
|
||||
}
|
||||
|
||||
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
|
||||
if (distanceMeters <= definition.punchRadiusMeters) {
|
||||
const audioConfig = definition.audioConfig || DEFAULT_GAME_AUDIO_CONFIG
|
||||
const readyDistanceMeters = Math.max(definition.punchRadiusMeters, audioConfig.readyDistanceMeters)
|
||||
const approachDistanceMeters = Math.max(readyDistanceMeters, audioConfig.approachDistanceMeters)
|
||||
const distantDistanceMeters = Math.max(approachDistanceMeters, audioConfig.distantDistanceMeters)
|
||||
|
||||
if (distanceMeters <= readyDistanceMeters) {
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
|
||||
if (distanceMeters <= approachDistanceMeters) {
|
||||
return 'approaching'
|
||||
}
|
||||
|
||||
if (distanceMeters <= distantDistanceMeters) {
|
||||
return 'distant'
|
||||
}
|
||||
|
||||
return 'searching'
|
||||
}
|
||||
|
||||
@@ -129,6 +137,29 @@ function buildPunchHintText(definition: GameDefinition, state: GameSessionState,
|
||||
: `${targetText}内,可点击打点`
|
||||
}
|
||||
|
||||
function buildTargetSummaryText(state: GameSessionState, currentTarget: GameControl | null): string {
|
||||
if (state.status === 'finished') {
|
||||
return '本局已完成'
|
||||
}
|
||||
|
||||
if (!currentTarget) {
|
||||
return '等待路线初始化'
|
||||
}
|
||||
|
||||
if (currentTarget.kind === 'start') {
|
||||
return `${currentTarget.label} / 先打开始点`
|
||||
}
|
||||
|
||||
if (currentTarget.kind === 'finish') {
|
||||
return `${currentTarget.label} / 前往终点`
|
||||
}
|
||||
|
||||
const sequenceText = typeof currentTarget.sequence === 'number'
|
||||
? `第 ${currentTarget.sequence} 点`
|
||||
: '当前目标点'
|
||||
return `${sequenceText} / ${currentTarget.label}`
|
||||
}
|
||||
|
||||
function buildSkipFeedbackText(currentTarget: GameControl): string {
|
||||
if (currentTarget.kind === 'start') {
|
||||
return '开始点不可跳过'
|
||||
@@ -193,6 +224,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
||||
const hudPresentation: HudPresentationState = {
|
||||
actionTagText: '目标',
|
||||
distanceTagText: '点距',
|
||||
targetSummaryText: buildTargetSummaryText(state, currentTarget),
|
||||
hudTargetControlId: currentTarget ? currentTarget.id : null,
|
||||
progressText: '0/0',
|
||||
punchButtonText,
|
||||
@@ -200,6 +232,12 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
||||
punchButtonEnabled,
|
||||
punchHintText: buildPunchHintText(definition, state, currentTarget),
|
||||
}
|
||||
const targetingPresentation = {
|
||||
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
|
||||
guidanceControlId: currentTarget ? currentTarget.id : null,
|
||||
hudControlId: currentTarget ? currentTarget.id : null,
|
||||
highlightedControlId: running && currentTarget ? currentTarget.id : null,
|
||||
}
|
||||
|
||||
if (!scoringControls.length) {
|
||||
return {
|
||||
@@ -223,6 +261,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
||||
skippedControlSequences: [],
|
||||
},
|
||||
hud: hudPresentation,
|
||||
targeting: targetingPresentation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +294,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
||||
...hudPresentation,
|
||||
progressText: `${completedControls.length}/${scoringControls.length}`,
|
||||
},
|
||||
targeting: targetingPresentation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +323,9 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
||||
const allowAutoPopup = punchPolicy === 'enter'
|
||||
? false
|
||||
: (control.displayContent ? control.displayContent.autoPopup : true)
|
||||
const autoOpenQuiz = control.kind === 'control'
|
||||
&& !!control.displayContent
|
||||
&& control.displayContent.ctas.some((item) => item.type === 'quiz')
|
||||
if (control.kind === 'start') {
|
||||
return {
|
||||
type: 'control_completed',
|
||||
@@ -295,6 +338,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
||||
displayAutoPopup: allowAutoPopup,
|
||||
displayOnce: control.displayContent ? control.displayContent.once : false,
|
||||
displayPriority: control.displayContent ? control.displayContent.priority : 1,
|
||||
autoOpenQuiz: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +354,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
||||
displayAutoPopup: allowAutoPopup,
|
||||
displayOnce: control.displayContent ? control.displayContent.once : false,
|
||||
displayPriority: control.displayContent ? control.displayContent.priority : 2,
|
||||
autoOpenQuiz: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +373,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
||||
displayAutoPopup: allowAutoPopup,
|
||||
displayOnce: control.displayContent ? control.displayContent.once : false,
|
||||
displayPriority: control.displayContent ? control.displayContent.priority : 1,
|
||||
autoOpenQuiz,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +386,7 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
|
||||
const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl)
|
||||
const nextState: GameSessionState = {
|
||||
...state,
|
||||
endReason: finished ? 'completed' : state.endReason,
|
||||
startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt,
|
||||
completedControlIds,
|
||||
skippedControlIds: currentTarget.id === state.currentTargetControlId
|
||||
@@ -410,6 +457,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
||||
initialize(definition: GameDefinition): GameSessionState {
|
||||
return {
|
||||
status: 'idle',
|
||||
endReason: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
completedControlIds: [],
|
||||
@@ -434,6 +482,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
||||
const nextState: GameSessionState = {
|
||||
...state,
|
||||
status: 'running',
|
||||
endReason: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
inRangeControlId: null,
|
||||
@@ -454,6 +503,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
||||
const nextState: GameSessionState = {
|
||||
...state,
|
||||
status: 'finished',
|
||||
endReason: 'completed',
|
||||
endedAt: event.at,
|
||||
guidanceState: 'searching',
|
||||
modeState: {
|
||||
@@ -468,6 +518,25 @@ export class ClassicSequentialRule implements RulePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === 'session_timed_out') {
|
||||
const nextState: GameSessionState = {
|
||||
...state,
|
||||
status: 'failed',
|
||||
endReason: 'timed_out',
|
||||
endedAt: event.at,
|
||||
guidanceState: 'searching',
|
||||
modeState: {
|
||||
mode: 'classic-sequential',
|
||||
phase: 'done',
|
||||
},
|
||||
}
|
||||
return {
|
||||
nextState,
|
||||
presentation: buildPresentation(definition, nextState),
|
||||
effects: [{ type: 'session_timed_out' }],
|
||||
}
|
||||
}
|
||||
|
||||
if (state.status !== 'running' || !state.currentTargetControlId) {
|
||||
return {
|
||||
nextState: state,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { type RulePlugin } from './rulePlugin'
|
||||
type ScoreOModeState = {
|
||||
phase: 'start' | 'controls' | 'finish' | 'done'
|
||||
focusedControlId: string | null
|
||||
guidanceControlId: string | null
|
||||
}
|
||||
|
||||
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
|
||||
@@ -46,6 +47,7 @@ function getModeState(state: GameSessionState): ScoreOModeState {
|
||||
return {
|
||||
phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start',
|
||||
focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null,
|
||||
guidanceControlId: rawModeState && typeof rawModeState.guidanceControlId === 'string' ? rawModeState.guidanceControlId : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,12 +58,27 @@ function withModeState(state: GameSessionState, modeState: ScoreOModeState): Gam
|
||||
}
|
||||
}
|
||||
|
||||
function hasCompletedEnoughControlsForFinish(definition: GameDefinition, state: GameSessionState): boolean {
|
||||
const completedScoreControls = getScoreControls(definition)
|
||||
.filter((control) => state.completedControlIds.includes(control.id))
|
||||
.length
|
||||
return completedScoreControls >= definition.minCompletedControlsBeforeFinish
|
||||
}
|
||||
|
||||
function canFocusFinish(definition: GameDefinition, state: GameSessionState): boolean {
|
||||
const startControl = getStartControl(definition)
|
||||
const finishControl = getFinishControl(definition)
|
||||
const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
|
||||
const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
|
||||
return completedStart && !completedFinish
|
||||
return completedStart && !completedFinish && hasCompletedEnoughControlsForFinish(definition, state)
|
||||
}
|
||||
|
||||
function isFinishPunchAvailable(
|
||||
definition: GameDefinition,
|
||||
state: GameSessionState,
|
||||
_modeState: ScoreOModeState,
|
||||
): boolean {
|
||||
return canFocusFinish(definition, state)
|
||||
}
|
||||
|
||||
function getNearestRemainingControl(
|
||||
@@ -91,6 +108,38 @@ function getNearestRemainingControl(
|
||||
return nearestControl
|
||||
}
|
||||
|
||||
function getNearestGuidanceTarget(
|
||||
definition: GameDefinition,
|
||||
state: GameSessionState,
|
||||
modeState: ScoreOModeState,
|
||||
referencePoint: LonLatPoint,
|
||||
): GameControl | null {
|
||||
const candidates = getRemainingScoreControls(definition, state).slice()
|
||||
if (isFinishPunchAvailable(definition, state, modeState)) {
|
||||
const finishControl = getFinishControl(definition)
|
||||
if (finishControl && !state.completedControlIds.includes(finishControl.id)) {
|
||||
candidates.push(finishControl)
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidates.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
let nearestControl = candidates[0]
|
||||
let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point)
|
||||
for (let index = 1; index < candidates.length; index += 1) {
|
||||
const control = candidates[index]
|
||||
const distance = getApproxDistanceMeters(referencePoint, control.point)
|
||||
if (distance < nearestDistance) {
|
||||
nearestControl = control
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
return nearestControl
|
||||
}
|
||||
|
||||
function getFocusedTarget(
|
||||
definition: GameDefinition,
|
||||
state: GameSessionState,
|
||||
@@ -118,6 +167,7 @@ function getFocusedTarget(
|
||||
|
||||
function resolveInteractiveTarget(
|
||||
definition: GameDefinition,
|
||||
state: GameSessionState,
|
||||
modeState: ScoreOModeState,
|
||||
primaryTarget: GameControl | null,
|
||||
focusedTarget: GameControl | null,
|
||||
@@ -126,11 +176,23 @@ function resolveInteractiveTarget(
|
||||
return primaryTarget
|
||||
}
|
||||
|
||||
if (modeState.phase === 'finish') {
|
||||
return primaryTarget
|
||||
}
|
||||
|
||||
if (definition.requiresFocusSelection) {
|
||||
return focusedTarget
|
||||
}
|
||||
|
||||
return focusedTarget || primaryTarget
|
||||
if (focusedTarget) {
|
||||
return focusedTarget
|
||||
}
|
||||
|
||||
if (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState)) {
|
||||
return getFinishControl(definition)
|
||||
}
|
||||
|
||||
return primaryTarget
|
||||
}
|
||||
|
||||
function getNearestInRangeControl(
|
||||
@@ -157,15 +219,23 @@ function getNearestInRangeControl(
|
||||
}
|
||||
|
||||
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
|
||||
if (distanceMeters <= definition.punchRadiusMeters) {
|
||||
const audioConfig = definition.audioConfig || DEFAULT_GAME_AUDIO_CONFIG
|
||||
const readyDistanceMeters = Math.max(definition.punchRadiusMeters, audioConfig.readyDistanceMeters)
|
||||
const approachDistanceMeters = Math.max(readyDistanceMeters, audioConfig.approachDistanceMeters)
|
||||
const distantDistanceMeters = Math.max(approachDistanceMeters, audioConfig.distantDistanceMeters)
|
||||
|
||||
if (distanceMeters <= readyDistanceMeters) {
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
|
||||
if (distanceMeters <= approachDistanceMeters) {
|
||||
return 'approaching'
|
||||
}
|
||||
|
||||
if (distanceMeters <= distantDistanceMeters) {
|
||||
return 'distant'
|
||||
}
|
||||
|
||||
return 'searching'
|
||||
}
|
||||
|
||||
@@ -210,13 +280,11 @@ function buildPunchHintText(
|
||||
|
||||
const modeState = getModeState(state)
|
||||
if (modeState.phase === 'controls' || modeState.phase === 'finish') {
|
||||
if (definition.requiresFocusSelection && !focusedTarget) {
|
||||
return modeState.phase === 'finish'
|
||||
? '点击地图选中终点后结束比赛'
|
||||
: '点击地图选中一个目标点'
|
||||
if (modeState.phase === 'controls' && definition.requiresFocusSelection && !focusedTarget) {
|
||||
return '点击地图选中一个目标点'
|
||||
}
|
||||
|
||||
const displayTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
|
||||
const displayTarget = resolveInteractiveTarget(definition, state, modeState, primaryTarget, focusedTarget)
|
||||
const targetLabel = getDisplayTargetLabel(displayTarget)
|
||||
if (displayTarget && state.inRangeControlId === displayTarget.id) {
|
||||
return definition.punchPolicy === 'enter'
|
||||
@@ -241,10 +309,55 @@ function buildPunchHintText(
|
||||
: `进入${targetLabel}后点击打点`
|
||||
}
|
||||
|
||||
function buildTargetSummaryText(
|
||||
definition: GameDefinition,
|
||||
state: GameSessionState,
|
||||
primaryTarget: GameControl | null,
|
||||
focusedTarget: GameControl | null,
|
||||
): string {
|
||||
if (state.status === 'idle') {
|
||||
return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点'
|
||||
}
|
||||
|
||||
if (state.status === 'finished') {
|
||||
return '本局已完成'
|
||||
}
|
||||
|
||||
const modeState = getModeState(state)
|
||||
if (modeState.phase === 'start') {
|
||||
return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点'
|
||||
}
|
||||
|
||||
if (modeState.phase === 'finish') {
|
||||
return primaryTarget ? `${primaryTarget.label} / 可随时结束` : '可前往终点结束'
|
||||
}
|
||||
|
||||
if (focusedTarget && focusedTarget.kind === 'control') {
|
||||
return `${focusedTarget.label} / ${getControlScore(focusedTarget)} 分目标`
|
||||
}
|
||||
|
||||
if (focusedTarget && focusedTarget.kind === 'finish') {
|
||||
return `${focusedTarget.label} / 结束比赛`
|
||||
}
|
||||
|
||||
if (definition.requiresFocusSelection) {
|
||||
return '请选择目标点'
|
||||
}
|
||||
|
||||
if (primaryTarget && primaryTarget.kind === 'control') {
|
||||
return `${primaryTarget.label} / ${getControlScore(primaryTarget)} 分目标`
|
||||
}
|
||||
|
||||
return primaryTarget ? primaryTarget.label : '自由打点'
|
||||
}
|
||||
|
||||
function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect {
|
||||
const allowAutoPopup = punchPolicy === 'enter'
|
||||
? false
|
||||
: (control.displayContent ? control.displayContent.autoPopup : true)
|
||||
const autoOpenQuiz = control.kind === 'control'
|
||||
&& !!control.displayContent
|
||||
&& control.displayContent.ctas.some((item) => item.type === 'quiz')
|
||||
if (control.kind === 'start') {
|
||||
return {
|
||||
type: 'control_completed',
|
||||
@@ -257,6 +370,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
||||
displayAutoPopup: allowAutoPopup,
|
||||
displayOnce: control.displayContent ? control.displayContent.once : false,
|
||||
displayPriority: control.displayContent ? control.displayContent.priority : 1,
|
||||
autoOpenQuiz: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +386,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
||||
displayAutoPopup: allowAutoPopup,
|
||||
displayOnce: control.displayContent ? control.displayContent.once : false,
|
||||
displayPriority: control.displayContent ? control.displayContent.priority : 2,
|
||||
autoOpenQuiz: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,9 +402,50 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
||||
displayAutoPopup: allowAutoPopup,
|
||||
displayOnce: control.displayContent ? control.displayContent.once : false,
|
||||
displayPriority: control.displayContent ? control.displayContent.priority : 1,
|
||||
autoOpenQuiz,
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePunchableControl(
|
||||
definition: GameDefinition,
|
||||
state: GameSessionState,
|
||||
modeState: ScoreOModeState,
|
||||
focusedTarget: GameControl | null,
|
||||
): GameControl | null {
|
||||
if (!state.inRangeControlId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const inRangeControl = definition.controls.find((control) => control.id === state.inRangeControlId) || null
|
||||
if (!inRangeControl) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (modeState.phase === 'start') {
|
||||
return inRangeControl.kind === 'start' ? inRangeControl : null
|
||||
}
|
||||
|
||||
if (modeState.phase === 'finish') {
|
||||
return inRangeControl.kind === 'finish' ? inRangeControl : null
|
||||
}
|
||||
|
||||
if (modeState.phase === 'controls') {
|
||||
if (inRangeControl.kind === 'finish' && isFinishPunchAvailable(definition, state, modeState)) {
|
||||
return inRangeControl
|
||||
}
|
||||
|
||||
if (definition.requiresFocusSelection) {
|
||||
return focusedTarget && inRangeControl.id === focusedTarget.id ? inRangeControl : null
|
||||
}
|
||||
|
||||
if (inRangeControl.kind === 'control') {
|
||||
return inRangeControl
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
|
||||
const modeState = getModeState(state)
|
||||
const running = state.status === 'running'
|
||||
@@ -315,14 +471,35 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
||||
.filter((control) => typeof control.sequence === 'number')
|
||||
.map((control) => control.sequence as number)
|
||||
const revealFullCourse = completedStart
|
||||
const interactiveTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
|
||||
const punchableControl = resolvePunchableControl(definition, state, modeState, focusedTarget)
|
||||
const guidanceControl = modeState.guidanceControlId
|
||||
? definition.controls.find((control) => control.id === modeState.guidanceControlId) || null
|
||||
: null
|
||||
const punchButtonEnabled = running
|
||||
&& definition.punchPolicy === 'enter-confirm'
|
||||
&& !!interactiveTarget
|
||||
&& state.inRangeControlId === interactiveTarget.id
|
||||
&& !!punchableControl
|
||||
const hudTargetControlId = modeState.phase === 'finish'
|
||||
? (primaryTarget ? primaryTarget.id : null)
|
||||
: focusedTarget
|
||||
? focusedTarget.id
|
||||
: modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState)
|
||||
? (getFinishControl(definition) ? getFinishControl(definition)!.id : null)
|
||||
: definition.requiresFocusSelection
|
||||
? null
|
||||
: primaryTarget
|
||||
? primaryTarget.id
|
||||
: null
|
||||
const highlightedControlId = focusedTarget
|
||||
? focusedTarget.id
|
||||
: punchableControl
|
||||
? punchableControl.id
|
||||
: guidanceControl
|
||||
? guidanceControl.id
|
||||
: null
|
||||
|
||||
const showMultiTargetLabels = completedStart && modeState.phase !== 'start'
|
||||
const mapPresentation: MapPresentationState = {
|
||||
controlVisualMode: modeState.phase === 'controls' ? 'multi-target' : 'single-target',
|
||||
controlVisualMode: showMultiTargetLabels ? 'multi-target' : 'single-target',
|
||||
showCourseLegs: false,
|
||||
guidanceLegAnimationEnabled: false,
|
||||
focusableControlIds: canSelectFinish
|
||||
@@ -336,7 +513,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
||||
activeControlSequences,
|
||||
activeStart: running && modeState.phase === 'start',
|
||||
completedStart,
|
||||
activeFinish: running && modeState.phase === 'finish',
|
||||
activeFinish: running && (modeState.phase === 'finish' || (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState))),
|
||||
focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish',
|
||||
completedFinish,
|
||||
revealFullCourse,
|
||||
@@ -351,41 +528,48 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
||||
const hudPresentation: HudPresentationState = {
|
||||
actionTagText: modeState.phase === 'start'
|
||||
? '目标'
|
||||
: modeState.phase === 'finish'
|
||||
? '终点'
|
||||
: focusedTarget && focusedTarget.kind === 'finish'
|
||||
? '终点'
|
||||
: modeState.phase === 'finish'
|
||||
? '终点'
|
||||
: focusedTarget
|
||||
? '目标'
|
||||
: '自由',
|
||||
distanceTagText: modeState.phase === 'start'
|
||||
? '点距'
|
||||
: modeState.phase === 'finish'
|
||||
? '终点距'
|
||||
: focusedTarget && focusedTarget.kind === 'finish'
|
||||
? '终点距'
|
||||
: focusedTarget
|
||||
? '选中点距'
|
||||
: modeState.phase === 'finish'
|
||||
? '终点距'
|
||||
: '最近点距',
|
||||
hudTargetControlId: focusedTarget
|
||||
? focusedTarget.id
|
||||
: primaryTarget
|
||||
? primaryTarget.id
|
||||
: null,
|
||||
: '目标距',
|
||||
targetSummaryText: buildTargetSummaryText(definition, state, primaryTarget, focusedTarget),
|
||||
hudTargetControlId,
|
||||
progressText: `已收集 ${completedControls.length}/${scoreControls.length}`,
|
||||
punchableControlId: punchButtonEnabled && interactiveTarget ? interactiveTarget.id : null,
|
||||
punchableControlId: punchableControl ? punchableControl.id : null,
|
||||
punchButtonEnabled,
|
||||
punchButtonText: modeState.phase === 'start'
|
||||
? '开始打卡'
|
||||
: (punchableControl && punchableControl.kind === 'finish')
|
||||
? '结束打卡'
|
||||
: modeState.phase === 'finish'
|
||||
? '结束打卡'
|
||||
: focusedTarget && focusedTarget.kind === 'finish'
|
||||
? '结束打卡'
|
||||
: modeState.phase === 'finish'
|
||||
? '结束打卡'
|
||||
: '打点',
|
||||
: '打点',
|
||||
punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget),
|
||||
}
|
||||
|
||||
return {
|
||||
map: mapPresentation,
|
||||
hud: hudPresentation,
|
||||
targeting: {
|
||||
punchableControlId: punchableControl ? punchableControl.id : null,
|
||||
guidanceControlId: guidanceControl ? guidanceControl.id : null,
|
||||
hudControlId: hudTargetControlId,
|
||||
highlightedControlId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +586,7 @@ function applyCompletion(
|
||||
const previousModeState = getModeState(state)
|
||||
const nextStateDraft: GameSessionState = {
|
||||
...state,
|
||||
endReason: control.kind === 'finish' ? 'completed' : state.endReason,
|
||||
startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt,
|
||||
endedAt: control.kind === 'finish' ? at : state.endedAt,
|
||||
completedControlIds,
|
||||
@@ -424,15 +609,16 @@ function applyCompletion(
|
||||
phase = remainingControls.length ? 'controls' : 'finish'
|
||||
}
|
||||
|
||||
const nextModeState: ScoreOModeState = {
|
||||
phase,
|
||||
focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId,
|
||||
}
|
||||
const nextPrimaryTarget = phase === 'controls'
|
||||
? getNearestRemainingControl(definition, nextStateDraft, referencePoint)
|
||||
: phase === 'finish'
|
||||
? getFinishControl(definition)
|
||||
: null
|
||||
const nextModeState: ScoreOModeState = {
|
||||
phase,
|
||||
focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId,
|
||||
guidanceControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
|
||||
}
|
||||
const nextState = withModeState({
|
||||
...nextStateDraft,
|
||||
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
|
||||
@@ -459,6 +645,7 @@ export class ScoreORule implements RulePlugin {
|
||||
const startControl = getStartControl(definition)
|
||||
return {
|
||||
status: 'idle',
|
||||
endReason: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
completedControlIds: [],
|
||||
@@ -470,6 +657,7 @@ export class ScoreORule implements RulePlugin {
|
||||
modeState: {
|
||||
phase: 'start',
|
||||
focusedControlId: null,
|
||||
guidanceControlId: startControl ? startControl.id : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -484,6 +672,7 @@ export class ScoreORule implements RulePlugin {
|
||||
const nextState = withModeState({
|
||||
...state,
|
||||
status: 'running',
|
||||
endReason: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
currentTargetControlId: startControl ? startControl.id : null,
|
||||
@@ -492,6 +681,7 @@ export class ScoreORule implements RulePlugin {
|
||||
}, {
|
||||
phase: 'start',
|
||||
focusedControlId: null,
|
||||
guidanceControlId: startControl ? startControl.id : null,
|
||||
})
|
||||
return {
|
||||
nextState,
|
||||
@@ -504,11 +694,13 @@ export class ScoreORule implements RulePlugin {
|
||||
const nextState = withModeState({
|
||||
...state,
|
||||
status: 'finished',
|
||||
endReason: 'completed',
|
||||
endedAt: event.at,
|
||||
guidanceState: 'searching',
|
||||
}, {
|
||||
phase: 'done',
|
||||
focusedControlId: null,
|
||||
guidanceControlId: null,
|
||||
})
|
||||
return {
|
||||
nextState,
|
||||
@@ -517,6 +709,25 @@ export class ScoreORule implements RulePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === 'session_timed_out') {
|
||||
const nextState = withModeState({
|
||||
...state,
|
||||
status: 'failed',
|
||||
endReason: 'timed_out',
|
||||
endedAt: event.at,
|
||||
guidanceState: 'searching',
|
||||
}, {
|
||||
phase: 'done',
|
||||
focusedControlId: null,
|
||||
guidanceControlId: null,
|
||||
})
|
||||
return {
|
||||
nextState,
|
||||
presentation: buildPresentation(definition, nextState),
|
||||
effects: [{ type: 'session_timed_out' }],
|
||||
}
|
||||
}
|
||||
|
||||
if (state.status !== 'running') {
|
||||
return {
|
||||
nextState: state,
|
||||
@@ -533,25 +744,30 @@ export class ScoreORule implements RulePlugin {
|
||||
if (event.type === 'gps_updated') {
|
||||
const referencePoint = { lon: event.lon, lat: event.lat }
|
||||
const remainingControls = getRemainingScoreControls(definition, state)
|
||||
const focusedTarget = getFocusedTarget(definition, state, remainingControls)
|
||||
const nextStateBase = withModeState(state, modeState)
|
||||
const focusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls)
|
||||
let nextPrimaryTarget = targetControl
|
||||
let guidanceTarget = targetControl
|
||||
let punchTarget: GameControl | null = null
|
||||
|
||||
if (modeState.phase === 'controls') {
|
||||
nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint)
|
||||
guidanceTarget = focusedTarget || nextPrimaryTarget
|
||||
guidanceTarget = getNearestGuidanceTarget(definition, state, modeState, referencePoint)
|
||||
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||
punchTarget = focusedTarget
|
||||
} else if (!definition.requiresFocusSelection) {
|
||||
} else if (isFinishPunchAvailable(definition, state, modeState)) {
|
||||
const finishControl = getFinishControl(definition)
|
||||
if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||
punchTarget = finishControl
|
||||
}
|
||||
}
|
||||
if (!punchTarget && !definition.requiresFocusSelection) {
|
||||
punchTarget = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
|
||||
}
|
||||
} else if (modeState.phase === 'finish') {
|
||||
nextPrimaryTarget = getFinishControl(definition)
|
||||
guidanceTarget = focusedTarget || nextPrimaryTarget
|
||||
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||
punchTarget = focusedTarget
|
||||
} else if (!definition.requiresFocusSelection && nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||
guidanceTarget = nextPrimaryTarget
|
||||
if (nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||
punchTarget = nextPrimaryTarget
|
||||
}
|
||||
} else if (targetControl) {
|
||||
@@ -565,15 +781,19 @@ export class ScoreORule implements RulePlugin {
|
||||
? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint))
|
||||
: 'searching'
|
||||
const nextState: GameSessionState = {
|
||||
...state,
|
||||
...nextStateBase,
|
||||
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
|
||||
inRangeControlId: punchTarget ? punchTarget.id : null,
|
||||
guidanceState,
|
||||
}
|
||||
const nextStateWithMode = withModeState(nextState, {
|
||||
...modeState,
|
||||
guidanceControlId: guidanceTarget ? guidanceTarget.id : null,
|
||||
})
|
||||
const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null)
|
||||
|
||||
if (definition.punchPolicy === 'enter' && punchTarget) {
|
||||
const completionResult = applyCompletion(definition, nextState, punchTarget, event.at, referencePoint)
|
||||
const completionResult = applyCompletion(definition, nextStateWithMode, punchTarget, event.at, referencePoint)
|
||||
return {
|
||||
...completionResult,
|
||||
effects: [...guidanceEffects, ...completionResult.effects],
|
||||
@@ -581,8 +801,8 @@ export class ScoreORule implements RulePlugin {
|
||||
}
|
||||
|
||||
return {
|
||||
nextState,
|
||||
presentation: buildPresentation(definition, nextState),
|
||||
nextState: nextStateWithMode,
|
||||
presentation: buildPresentation(definition, nextStateWithMode),
|
||||
effects: guidanceEffects,
|
||||
}
|
||||
}
|
||||
@@ -612,6 +832,7 @@ export class ScoreORule implements RulePlugin {
|
||||
}, {
|
||||
...modeState,
|
||||
focusedControlId: nextFocusedControlId,
|
||||
guidanceControlId: modeState.guidanceControlId,
|
||||
})
|
||||
return {
|
||||
nextState,
|
||||
@@ -622,11 +843,19 @@ export class ScoreORule implements RulePlugin {
|
||||
|
||||
if (event.type === 'punch_requested') {
|
||||
const focusedTarget = getFocusedTarget(definition, state)
|
||||
if (definition.requiresFocusSelection && (modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
|
||||
let stateForPunch = state
|
||||
const finishControl = getFinishControl(definition)
|
||||
const finishInRange = !!(
|
||||
finishControl
|
||||
&& event.lon !== null
|
||||
&& event.lat !== null
|
||||
&& getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters
|
||||
)
|
||||
if (definition.requiresFocusSelection && modeState.phase === 'controls' && !focusedTarget && !finishInRange) {
|
||||
return {
|
||||
nextState: state,
|
||||
presentation: buildPresentation(definition, state),
|
||||
effects: [{ type: 'punch_feedback', text: modeState.phase === 'finish' ? '请先选中终点' : '请先选中目标点', tone: 'warning' }],
|
||||
effects: [{ type: 'punch_feedback', text: '请先选中目标点', tone: 'warning' }],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,13 +864,43 @@ export class ScoreORule implements RulePlugin {
|
||||
controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
|
||||
}
|
||||
|
||||
if (!controlToPunch || (definition.requiresFocusSelection && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
|
||||
if (!controlToPunch && event.lon !== null && event.lat !== null) {
|
||||
const referencePoint = { lon: event.lon, lat: event.lat }
|
||||
const nextStateBase = withModeState(state, modeState)
|
||||
stateForPunch = nextStateBase
|
||||
const remainingControls = getRemainingScoreControls(definition, state)
|
||||
const resolvedFocusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls)
|
||||
|
||||
if (resolvedFocusedTarget && getApproxDistanceMeters(resolvedFocusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||
controlToPunch = resolvedFocusedTarget
|
||||
} else if (isFinishPunchAvailable(definition, state, modeState)) {
|
||||
const finishControl = getFinishControl(definition)
|
||||
if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||
controlToPunch = finishControl
|
||||
}
|
||||
}
|
||||
|
||||
if (!controlToPunch && !definition.requiresFocusSelection && modeState.phase === 'controls') {
|
||||
controlToPunch = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
|
||||
}
|
||||
}
|
||||
|
||||
if (!controlToPunch || (definition.requiresFocusSelection && modeState.phase === 'controls' && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
|
||||
const isFinishLockedAttempt = !!(
|
||||
finishControl
|
||||
&& event.lon !== null
|
||||
&& event.lat !== null
|
||||
&& getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters
|
||||
&& !hasCompletedEnoughControlsForFinish(definition, state)
|
||||
)
|
||||
return {
|
||||
nextState: state,
|
||||
presentation: buildPresentation(definition, state),
|
||||
effects: [{
|
||||
type: 'punch_feedback',
|
||||
text: focusedTarget
|
||||
text: isFinishLockedAttempt
|
||||
? `至少完成 ${definition.minCompletedControlsBeforeFinish} 个积分点后才能结束`
|
||||
: focusedTarget
|
||||
? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围`
|
||||
: modeState.phase === 'start'
|
||||
? '未进入开始点打卡范围'
|
||||
@@ -651,7 +910,7 @@ export class ScoreORule implements RulePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
return applyCompletion(definition, state, controlToPunch, event.at, this.getReferencePoint(definition, state, controlToPunch))
|
||||
return applyCompletion(definition, stateForPunch, controlToPunch, event.at, this.getReferencePoint(definition, stateForPunch, controlToPunch))
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
36
miniprogram/game/telemetry/playerTelemetryProfile.ts
Normal file
36
miniprogram/game/telemetry/playerTelemetryProfile.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { mergeTelemetryConfig, type TelemetryConfig } from './telemetryConfig'
|
||||
|
||||
export interface PlayerTelemetryProfile {
|
||||
heartRateAge?: number
|
||||
restingHeartRateBpm?: number
|
||||
userWeightKg?: number
|
||||
source?: 'server' | 'device' | 'manual'
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
function pickTelemetryValue<T extends keyof TelemetryConfig>(
|
||||
key: T,
|
||||
activityConfig: Partial<TelemetryConfig> | null | undefined,
|
||||
playerProfile: PlayerTelemetryProfile | null | undefined,
|
||||
): TelemetryConfig[T] | undefined {
|
||||
if (playerProfile && playerProfile[key] !== undefined) {
|
||||
return playerProfile[key] as TelemetryConfig[T]
|
||||
}
|
||||
|
||||
if (activityConfig && activityConfig[key] !== undefined) {
|
||||
return activityConfig[key] as TelemetryConfig[T]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function mergeTelemetrySources(
|
||||
activityConfig?: Partial<TelemetryConfig> | null,
|
||||
playerProfile?: PlayerTelemetryProfile | null,
|
||||
): TelemetryConfig {
|
||||
return mergeTelemetryConfig({
|
||||
heartRateAge: pickTelemetryValue('heartRateAge', activityConfig, playerProfile),
|
||||
restingHeartRateBpm: pickTelemetryValue('restingHeartRateBpm', activityConfig, playerProfile),
|
||||
userWeightKg: pickTelemetryValue('userWeightKg', activityConfig, playerProfile),
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface TelemetryPresentation {
|
||||
timerText: string
|
||||
elapsedTimerText: string
|
||||
timerMode: 'elapsed' | 'countdown'
|
||||
mileageText: string
|
||||
distanceToTargetValueText: string
|
||||
distanceToTargetUnitText: string
|
||||
@@ -19,6 +21,8 @@ export interface TelemetryPresentation {
|
||||
|
||||
export const EMPTY_TELEMETRY_PRESENTATION: TelemetryPresentation = {
|
||||
timerText: '00:00:00',
|
||||
elapsedTimerText: '00:00:00',
|
||||
timerMode: 'elapsed',
|
||||
mileageText: '0m',
|
||||
distanceToTargetValueText: '--',
|
||||
distanceToTargetUnitText: '',
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
getHeartRateToneLabel,
|
||||
getHeartRateToneRangeText,
|
||||
getSpeedToneRangeText,
|
||||
mergeTelemetryConfig,
|
||||
type HeartRateTone,
|
||||
type TelemetryConfig,
|
||||
} from './telemetryConfig'
|
||||
import { mergeTelemetrySources, type PlayerTelemetryProfile } from './playerTelemetryProfile'
|
||||
import { type GameSessionState } from '../core/gameSessionState'
|
||||
import { type TelemetryEvent } from './telemetryEvent'
|
||||
import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation'
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type HeadingConfidence,
|
||||
type TelemetryState,
|
||||
} from './telemetryState'
|
||||
import { type RecoveryTelemetrySnapshot } from '../core/sessionRecovery'
|
||||
const SPEED_SMOOTHING_ALPHA = 0.35
|
||||
const DEVICE_HEADING_SMOOTHING_ALPHA = 0.28
|
||||
const ACCELEROMETER_SMOOTHING_ALPHA = 0.2
|
||||
@@ -109,6 +110,10 @@ function formatElapsedTimerText(totalMs: number): string {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatCountdownTimerText(remainingMs: number): string {
|
||||
return formatElapsedTimerText(Math.max(0, remainingMs))
|
||||
}
|
||||
|
||||
function formatDistanceText(distanceMeters: number): string {
|
||||
if (distanceMeters >= 1000) {
|
||||
return `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}km`
|
||||
@@ -419,10 +424,18 @@ function shouldTrackCalories(state: TelemetryState): boolean {
|
||||
export class TelemetryRuntime {
|
||||
state: TelemetryState
|
||||
config: TelemetryConfig
|
||||
activityConfig: TelemetryConfig
|
||||
playerProfile: PlayerTelemetryProfile | null
|
||||
sessionCloseAfterMs: number
|
||||
sessionCloseWarningMs: number
|
||||
|
||||
constructor() {
|
||||
this.state = { ...EMPTY_TELEMETRY_STATE }
|
||||
this.config = { ...DEFAULT_TELEMETRY_CONFIG }
|
||||
this.activityConfig = { ...DEFAULT_TELEMETRY_CONFIG }
|
||||
this.playerProfile = null
|
||||
this.sessionCloseAfterMs = 2 * 60 * 60 * 1000
|
||||
this.sessionCloseWarningMs = 10 * 60 * 1000
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
@@ -440,10 +453,102 @@ export class TelemetryRuntime {
|
||||
}
|
||||
|
||||
configure(config?: Partial<TelemetryConfig> | null): void {
|
||||
this.config = mergeTelemetryConfig(config)
|
||||
this.activityConfig = mergeTelemetrySources(config, null)
|
||||
this.syncEffectiveConfig()
|
||||
}
|
||||
|
||||
applyCompiledProfile(
|
||||
config: TelemetryConfig,
|
||||
playerProfile?: PlayerTelemetryProfile | null,
|
||||
): void {
|
||||
this.activityConfig = { ...config }
|
||||
this.playerProfile = playerProfile ? { ...playerProfile } : null
|
||||
this.config = { ...config }
|
||||
}
|
||||
|
||||
setPlayerProfile(profile?: PlayerTelemetryProfile | null): void {
|
||||
this.playerProfile = profile ? { ...profile } : null
|
||||
this.syncEffectiveConfig()
|
||||
}
|
||||
|
||||
clearPlayerProfile(): void {
|
||||
this.playerProfile = null
|
||||
this.syncEffectiveConfig()
|
||||
}
|
||||
|
||||
exportRecoveryState(): RecoveryTelemetrySnapshot {
|
||||
return {
|
||||
distanceMeters: this.state.distanceMeters,
|
||||
currentSpeedKmh: this.state.currentSpeedKmh,
|
||||
averageSpeedKmh: this.state.averageSpeedKmh,
|
||||
heartRateBpm: this.state.heartRateBpm,
|
||||
caloriesKcal: this.state.caloriesKcal,
|
||||
lastGpsPoint: this.state.lastGpsPoint
|
||||
? {
|
||||
lon: this.state.lastGpsPoint.lon,
|
||||
lat: this.state.lastGpsPoint.lat,
|
||||
}
|
||||
: null,
|
||||
lastGpsAt: this.state.lastGpsAt,
|
||||
lastGpsAccuracyMeters: this.state.lastGpsAccuracyMeters,
|
||||
}
|
||||
}
|
||||
|
||||
restoreRecoveryState(
|
||||
definition: GameDefinition,
|
||||
gameState: GameSessionState,
|
||||
snapshot: RecoveryTelemetrySnapshot,
|
||||
hudTargetControlId?: string | null,
|
||||
): void {
|
||||
const targetControlId = hudTargetControlId || null
|
||||
const targetControl = targetControlId
|
||||
? definition.controls.find((control) => control.id === targetControlId) || null
|
||||
: null
|
||||
|
||||
this.sessionCloseAfterMs = definition.sessionCloseAfterMs
|
||||
this.sessionCloseWarningMs = definition.sessionCloseWarningMs
|
||||
this.state = {
|
||||
...EMPTY_TELEMETRY_STATE,
|
||||
accelerometer: this.state.accelerometer,
|
||||
accelerometerUpdatedAt: this.state.accelerometerUpdatedAt,
|
||||
accelerometerSampleCount: this.state.accelerometerSampleCount,
|
||||
gyroscope: this.state.gyroscope,
|
||||
deviceMotion: this.state.deviceMotion,
|
||||
deviceHeadingDeg: this.state.deviceHeadingDeg,
|
||||
devicePose: this.state.devicePose,
|
||||
headingConfidence: this.state.headingConfidence,
|
||||
sessionStatus: gameState.status,
|
||||
sessionStartedAt: gameState.startedAt,
|
||||
sessionEndedAt: gameState.endedAt,
|
||||
elapsedMs: gameState.startedAt === null
|
||||
? 0
|
||||
: Math.max(0, ((gameState.endedAt || Date.now()) - gameState.startedAt)),
|
||||
distanceMeters: snapshot.distanceMeters,
|
||||
currentSpeedKmh: snapshot.currentSpeedKmh,
|
||||
averageSpeedKmh: snapshot.averageSpeedKmh,
|
||||
distanceToTargetMeters: targetControl && snapshot.lastGpsPoint
|
||||
? getApproxDistanceMeters(snapshot.lastGpsPoint, targetControl.point)
|
||||
: null,
|
||||
targetControlId: targetControl ? targetControl.id : null,
|
||||
targetPoint: targetControl ? targetControl.point : null,
|
||||
lastGpsPoint: snapshot.lastGpsPoint
|
||||
? {
|
||||
lon: snapshot.lastGpsPoint.lon,
|
||||
lat: snapshot.lastGpsPoint.lat,
|
||||
}
|
||||
: null,
|
||||
lastGpsAt: snapshot.lastGpsAt,
|
||||
lastGpsAccuracyMeters: snapshot.lastGpsAccuracyMeters,
|
||||
heartRateBpm: snapshot.heartRateBpm,
|
||||
caloriesKcal: snapshot.caloriesKcal,
|
||||
calorieTrackingAt: snapshot.lastGpsAt,
|
||||
}
|
||||
this.recomputeDerivedState()
|
||||
}
|
||||
|
||||
loadDefinition(_definition: GameDefinition): void {
|
||||
this.sessionCloseAfterMs = _definition.sessionCloseAfterMs
|
||||
this.sessionCloseWarningMs = _definition.sessionCloseWarningMs
|
||||
this.reset()
|
||||
}
|
||||
|
||||
@@ -632,6 +737,15 @@ export class TelemetryRuntime {
|
||||
this.syncCalorieAccumulation(now)
|
||||
this.alignCalorieTracking(now)
|
||||
this.recomputeDerivedState(now)
|
||||
const elapsedTimerText = formatElapsedTimerText(this.state.elapsedMs)
|
||||
const countdownActive = this.state.sessionStatus === 'running'
|
||||
&& this.state.sessionEndedAt === null
|
||||
&& this.state.sessionStartedAt !== null
|
||||
&& this.sessionCloseAfterMs > 0
|
||||
&& (this.sessionCloseAfterMs - this.state.elapsedMs) <= this.sessionCloseWarningMs
|
||||
const countdownRemainingMs = countdownActive
|
||||
? Math.max(0, this.sessionCloseAfterMs - this.state.elapsedMs)
|
||||
: 0
|
||||
const targetDistance = formatTargetDistance(this.state.distanceToTargetMeters)
|
||||
const hasHeartRate = hasHeartRateSignal(this.state)
|
||||
const heartRateTone = hasHeartRate
|
||||
@@ -643,7 +757,9 @@ export class TelemetryRuntime {
|
||||
|
||||
return {
|
||||
...EMPTY_TELEMETRY_PRESENTATION,
|
||||
timerText: formatElapsedTimerText(this.state.elapsedMs),
|
||||
timerText: countdownActive ? formatCountdownTimerText(countdownRemainingMs) : elapsedTimerText,
|
||||
elapsedTimerText,
|
||||
timerMode: countdownActive ? 'countdown' : 'elapsed',
|
||||
mileageText: formatDistanceText(this.state.distanceMeters),
|
||||
distanceToTargetValueText: targetDistance.valueText,
|
||||
distanceToTargetUnitText: targetDistance.unitText,
|
||||
@@ -716,4 +832,8 @@ export class TelemetryRuntime {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private syncEffectiveConfig(): void {
|
||||
this.config = mergeTelemetrySources(this.activityConfig, this.playerProfile)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
209
miniprogram/utils/gameLaunch.ts
Normal file
209
miniprogram/utils/gameLaunch.ts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user