import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection' import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse' import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig' import { mergeTelemetryConfig, type TelemetryConfig } from '../game/telemetry/telemetryConfig' import { type GameContentExperienceConfigOverride, type GameControlDisplayContentOverride, } from '../game/core/gameDefinition' import { mergeGameHapticsConfig, mergeGameUiEffectsConfig, type FeedbackCueKey, type GameHapticsConfig, type GameHapticsConfigOverrides, type GameUiEffectsConfig, type GameUiEffectsConfigOverrides, type PartialHapticCueConfig, type PartialUiCueConfig, } from '../game/feedback/feedbackConfig' import { DEFAULT_COURSE_STYLE_CONFIG, type ControlPointStyleEntry, type ControlPointStyleId, type CourseLegStyleEntry, type CourseLegStyleId, type CourseStyleConfig, type ScoreBandStyleEntry, } from '../game/presentation/courseStyleConfig' import { DEFAULT_TRACK_VISUALIZATION_CONFIG, TRACK_COLOR_PRESET_MAP, TRACK_TAIL_LENGTH_METERS, type TrackColorPreset, type TrackDisplayMode, type TrackTailLengthPreset, type TrackStyleProfile, type TrackVisualizationConfig, } from '../game/presentation/trackStyleConfig' import { DEFAULT_GPS_MARKER_STYLE_CONFIG, GPS_MARKER_COLOR_PRESET_MAP, type GpsMarkerAnimationProfile, type GpsMarkerColorPreset, type GpsMarkerSizePreset, 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 maxX: number minY: number maxY: number } export interface RemoteMapConfig { configTitle: string configAppId: string configSchemaVersion: string configVersion: string playfieldKind: string tileSource: string minZoom: number maxZoom: number defaultZoom: number initialCenterTileX: number initialCenterTileY: number projection: string projectionModeText: string magneticDeclinationDeg: number magneticDeclinationText: string tileFormat: string tileSize: number bounds: [number, number, number, number] | null tileBoundsByZoom: Record mapMetaUrl: string mapRootUrl: string courseUrl: string | null course: OrienteeringCourseData | null courseStatusText: string cpRadiusMeters: number gameMode: 'classic-sequential' | 'score-o' sessionCloseAfterMs: number sessionCloseWarningMs: number minCompletedControlsBeforeFinish: number punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number requiresFocusSelection: boolean skipEnabled: boolean skipRadiusMeters: number skipRequiresConfirm: boolean autoFinishOnLastControl: boolean controlScoreOverrides: Record controlContentOverrides: Record defaultControlContentOverride: GameControlDisplayContentOverride | null defaultControlPointStyleOverride: ControlPointStyleEntry | null controlPointStyleOverrides: Record defaultLegStyleOverride: CourseLegStyleEntry | null legStyleOverrides: Record defaultControlScore: number | null courseStyleConfig: CourseStyleConfig trackStyleConfig: TrackVisualizationConfig gpsMarkerStyleConfig: GpsMarkerStyleConfig telemetryConfig: TelemetryConfig audioConfig: GameAudioConfig hapticsConfig: GameHapticsConfig uiEffectsConfig: GameUiEffectsConfig systemSettingsConfig: SystemSettingsConfig } interface ParsedGameConfig { title: string appId: string schemaVersion: string version: string playfieldKind: string mapRoot: string mapMeta: string course: string | null cpRadiusMeters: number defaultZoom: number | null gameMode: 'classic-sequential' | 'score-o' sessionCloseAfterMs: number sessionCloseWarningMs: number minCompletedControlsBeforeFinish: number punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number requiresFocusSelection: boolean skipEnabled: boolean skipRadiusMeters: number skipRequiresConfirm: boolean autoFinishOnLastControl: boolean controlScoreOverrides: Record controlContentOverrides: Record defaultControlContentOverride: GameControlDisplayContentOverride | null defaultControlPointStyleOverride: ControlPointStyleEntry | null controlPointStyleOverrides: Record defaultLegStyleOverride: CourseLegStyleEntry | null legStyleOverrides: Record defaultControlScore: number | null courseStyleConfig: CourseStyleConfig trackStyleConfig: TrackVisualizationConfig gpsMarkerStyleConfig: GpsMarkerStyleConfig telemetryConfig: TelemetryConfig audioConfig: GameAudioConfig hapticsConfig: GameHapticsConfig uiEffectsConfig: GameUiEffectsConfig systemSettingsConfig: SystemSettingsConfig declinationDeg: number } interface ParsedMapMeta { tileSize: number minZoom: number maxZoom: number projection: string tileFormat: string tilePathTemplate: string bounds: [number, number, number, number] | null } function requestTextViaRequest(url: string): Promise { return new Promise((resolve, reject) => { wx.request({ url, method: 'GET', responseType: 'text' as any, success: (response) => { if (response.statusCode !== 200) { reject(new Error(`request失败: ${response.statusCode} ${url}`)) return } if (typeof response.data === 'string') { resolve(response.data) return } resolve(JSON.stringify(response.data)) }, fail: () => { reject(new Error(`request失败: ${url}`)) }, }) }) } function requestTextViaDownload(url: string): Promise { return new Promise((resolve, reject) => { const fileSystemManager = wx.getFileSystemManager() wx.downloadFile({ url, success: (response) => { if (response.statusCode !== 200 || !response.tempFilePath) { reject(new Error(`download失败: ${response.statusCode} ${url}`)) return } fileSystemManager.readFile({ filePath: response.tempFilePath, encoding: 'utf8', success: (readResult) => { if (typeof readResult.data === 'string') { resolve(readResult.data) return } reject(new Error(`read失败: ${url}`)) }, fail: () => { reject(new Error(`read失败: ${url}`)) }, }) }, fail: () => { reject(new Error(`download失败: ${url}`)) }, }) }) } async function requestText(url: string): Promise { try { return await requestTextViaRequest(url) } catch (requestError) { try { return await requestTextViaDownload(url) } catch (downloadError) { const requestMessage = requestError instanceof Error ? requestError.message : 'request失败' const downloadMessage = downloadError instanceof Error ? downloadError.message : 'download失败' throw new Error(`${requestMessage}; ${downloadMessage}`) } } } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)) } function resolveUrl(baseUrl: string, relativePath: string): string { if (/^https?:\/\//i.test(relativePath)) { return relativePath } const originMatch = baseUrl.match(/^(https?:\/\/[^/]+)/i) const origin = originMatch ? originMatch[1] : '' if (relativePath.startsWith('/')) { return `${origin}${relativePath}` } const baseDir = baseUrl.slice(0, baseUrl.lastIndexOf('/') + 1) const normalizedRelativePath = relativePath.replace(/^\.\//, '') return `${baseDir}${normalizedRelativePath}` } function formatDeclinationText(declinationDeg: number): string { const suffix = declinationDeg < 0 ? 'W' : 'E' return `${Math.abs(declinationDeg).toFixed(2)}° ${suffix}` } function parseDeclinationValue(rawValue: unknown): number { const numericValue = Number(rawValue) return Number.isFinite(numericValue) ? -Math.abs(numericValue) : -6.91 } function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number { const numericValue = Number(rawValue) return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue } function parseNumber(rawValue: unknown, fallbackValue: number): number { const numericValue = Number(rawValue) return Number.isFinite(numericValue) ? numericValue : fallbackValue } function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean { if (typeof rawValue === 'boolean') { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if (normalized === 'true') { return true } if (normalized === 'false') { return false } } return fallbackValue } 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 = { 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, 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 = {} const locks: Partial> = {} 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, ): GameContentExperienceConfigOverride | undefined { const normalized = normalizeObjectRecord(rawValue) if (!Object.keys(normalized).length) { return undefined } const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : '' if (typeValue === 'native') { return { type: 'native', fallback: 'native', presentation: 'sheet', } } if (typeValue !== 'h5') { return undefined } const rawUrl = typeof normalized.url === 'string' ? normalized.url.trim() : '' if (!rawUrl) { return undefined } const bridgeValue = typeof normalized.bridge === 'string' && normalized.bridge.trim() ? normalized.bridge.trim() : 'content-v1' const rawPresentation = typeof normalized.presentation === 'string' ? normalized.presentation.trim().toLowerCase() : '' const presentationValue = rawPresentation === 'dialog' || rawPresentation === 'fullscreen' ? rawPresentation : 'sheet' return { type: 'h5', url: resolveUrl(baseUrl, rawUrl), bridge: bridgeValue, fallback: 'native', presentation: presentationValue, } } 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' } const normalized = rawValue.trim().toLowerCase() if (normalized === 'classic-sequential' || normalized === 'classic' || normalized === 'sequential') { return 'classic-sequential' } if (normalized === 'score-o' || normalized === 'scoreo' || normalized === 'score') { return 'score-o' } throw new Error(`暂不支持的 game.mode: ${rawValue}`) } function parseTrackDisplayMode(rawValue: unknown, fallbackValue: TrackDisplayMode): TrackDisplayMode { if (rawValue === 'none' || rawValue === 'full' || rawValue === 'tail') { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if (normalized === 'none' || normalized === 'full' || normalized === 'tail') { return normalized } } return fallbackValue } function parseTrackStyleProfile(rawValue: unknown, fallbackValue: TrackStyleProfile): TrackStyleProfile { if (rawValue === 'classic' || rawValue === 'neon') { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if (normalized === 'classic' || normalized === 'neon') { return normalized } } return fallbackValue } function parseTrackTailLengthPreset(rawValue: unknown, fallbackValue: TrackTailLengthPreset): TrackTailLengthPreset { if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if (normalized === 'short' || normalized === 'medium' || normalized === 'long') { return normalized } } return fallbackValue } function parseTrackColorPreset(rawValue: unknown, fallbackValue: TrackColorPreset): TrackColorPreset { if ( rawValue === 'mint' || rawValue === 'cyan' || rawValue === 'sky' || rawValue === 'blue' || rawValue === 'violet' || rawValue === 'pink' || rawValue === 'orange' || rawValue === 'yellow' ) { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if ( normalized === 'mint' || normalized === 'cyan' || normalized === 'sky' || normalized === 'blue' || normalized === 'violet' || normalized === 'pink' || normalized === 'orange' || normalized === 'yellow' ) { return normalized } } return fallbackValue } function parseTrackVisualizationConfig(rawValue: unknown): TrackVisualizationConfig { const normalized = normalizeObjectRecord(rawValue) if (!Object.keys(normalized).length) { return DEFAULT_TRACK_VISUALIZATION_CONFIG } const fallback = DEFAULT_TRACK_VISUALIZATION_CONFIG const tailLength = parseTrackTailLengthPreset(getFirstDefined(normalized, ['taillength', 'tailpreset']), fallback.tailLength) const colorPreset = parseTrackColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset) const presetColors = TRACK_COLOR_PRESET_MAP[colorPreset] const rawTailMeters = getFirstDefined(normalized, ['tailmeters']) const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex']) const rawHeadColorHex = getFirstDefined(normalized, ['headcolor', 'headcolorhex']) return { mode: parseTrackDisplayMode(getFirstDefined(normalized, ['mode']), fallback.mode), style: parseTrackStyleProfile(getFirstDefined(normalized, ['style', 'profile']), fallback.style), tailLength, colorPreset, tailMeters: rawTailMeters !== undefined ? parsePositiveNumber(rawTailMeters, TRACK_TAIL_LENGTH_METERS[tailLength]) : TRACK_TAIL_LENGTH_METERS[tailLength], tailMaxSeconds: parsePositiveNumber(getFirstDefined(normalized, ['tailmaxseconds', 'maxseconds']), fallback.tailMaxSeconds), fadeOutWhenStill: parseBoolean(getFirstDefined(normalized, ['fadeoutwhenstill', 'fadewhenstill']), fallback.fadeOutWhenStill), stillSpeedKmh: parsePositiveNumber(getFirstDefined(normalized, ['stillspeedkmh', 'stillspeed']), fallback.stillSpeedKmh), fadeOutDurationMs: parsePositiveNumber(getFirstDefined(normalized, ['fadeoutdurationms', 'fadeoutms']), fallback.fadeOutDurationMs), colorHex: normalizeHexColor(rawColorHex, presetColors.colorHex), headColorHex: normalizeHexColor(rawHeadColorHex, presetColors.headColorHex), widthPx: parsePositiveNumber(getFirstDefined(normalized, ['widthpx', 'width']), fallback.widthPx), headWidthPx: parsePositiveNumber(getFirstDefined(normalized, ['headwidthpx', 'headwidth']), fallback.headWidthPx), glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallback.glowStrength), 0, 1.5), } } function parseGpsMarkerStyleId(rawValue: unknown, fallbackValue: GpsMarkerStyleId): GpsMarkerStyleId { if (rawValue === 'dot' || rawValue === 'beacon' || rawValue === 'disc' || rawValue === 'badge') { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if (normalized === 'dot' || normalized === 'beacon' || normalized === 'disc' || normalized === 'badge') { return normalized } } return fallbackValue } function parseGpsMarkerSizePreset(rawValue: unknown, fallbackValue: GpsMarkerSizePreset): GpsMarkerSizePreset { if (rawValue === 'small' || rawValue === 'medium' || rawValue === 'large') { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if (normalized === 'small' || normalized === 'medium' || normalized === 'large') { return normalized } } return fallbackValue } function parseGpsMarkerColorPreset(rawValue: unknown, fallbackValue: GpsMarkerColorPreset): GpsMarkerColorPreset { if ( rawValue === 'mint' || rawValue === 'cyan' || rawValue === 'sky' || rawValue === 'blue' || rawValue === 'violet' || rawValue === 'pink' || rawValue === 'orange' || rawValue === 'yellow' ) { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if ( normalized === 'mint' || normalized === 'cyan' || normalized === 'sky' || normalized === 'blue' || normalized === 'violet' || normalized === 'pink' || normalized === 'orange' || normalized === 'yellow' ) { return normalized } } return fallbackValue } function parseGpsMarkerAnimationProfile( rawValue: unknown, fallbackValue: GpsMarkerAnimationProfile, ): GpsMarkerAnimationProfile { if (rawValue === 'minimal' || rawValue === 'dynamic-runner' || rawValue === 'warning-reactive') { return rawValue } return fallbackValue } function parseGpsMarkerStyleConfig(rawValue: unknown): GpsMarkerStyleConfig { const normalized = normalizeObjectRecord(rawValue) if (!Object.keys(normalized).length) { return DEFAULT_GPS_MARKER_STYLE_CONFIG } const fallback = DEFAULT_GPS_MARKER_STYLE_CONFIG const colorPreset = parseGpsMarkerColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset) const presetColors = GPS_MARKER_COLOR_PRESET_MAP[colorPreset] const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex']) const rawRingColorHex = getFirstDefined(normalized, ['ringcolor', 'ringcolorhex']) const rawIndicatorColorHex = getFirstDefined(normalized, ['indicatorcolor', 'indicatorcolorhex']) return { visible: parseBoolean(getFirstDefined(normalized, ['visible', 'show']), fallback.visible), style: parseGpsMarkerStyleId(getFirstDefined(normalized, ['style', 'profile']), fallback.style), size: parseGpsMarkerSizePreset(getFirstDefined(normalized, ['size']), fallback.size), colorPreset, colorHex: typeof rawColorHex === 'string' && rawColorHex.trim() ? rawColorHex.trim() : presetColors.colorHex, ringColorHex: typeof rawRingColorHex === 'string' && rawRingColorHex.trim() ? rawRingColorHex.trim() : presetColors.ringColorHex, indicatorColorHex: typeof rawIndicatorColorHex === 'string' && rawIndicatorColorHex.trim() ? rawIndicatorColorHex.trim() : presetColors.indicatorColorHex, showHeadingIndicator: parseBoolean(getFirstDefined(normalized, ['showheadingindicator', 'showindicator']), fallback.showHeadingIndicator), animationProfile: parseGpsMarkerAnimationProfile( getFirstDefined(normalized, ['animationprofile', 'motionprofile']), fallback.animationProfile, ), motionState: fallback.motionState, motionIntensity: fallback.motionIntensity, pulseStrength: fallback.pulseStrength, headingAlpha: fallback.headingAlpha, effectScale: fallback.effectScale, wakeStrength: fallback.wakeStrength, warningGlowStrength: fallback.warningGlowStrength, indicatorScale: fallback.indicatorScale, logoScale: fallback.logoScale, logoUrl: typeof getFirstDefined(normalized, ['logourl']) === 'string' ? String(getFirstDefined(normalized, ['logourl'])).trim() : '', logoMode: 'center-badge', } } function parseTelemetryConfig(rawValue: unknown): TelemetryConfig { const normalized = normalizeObjectRecord(rawValue) if (!Object.keys(normalized).length) { return mergeTelemetryConfig() } const rawHeartRate = getFirstDefined(normalized, ['heartrate', 'heart_rate']) const normalizedHeartRate = normalizeObjectRecord(rawHeartRate) const ageRaw = getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage']) !== undefined ? getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage']) : getFirstDefined(normalized, ['age', 'userage', 'heartrateage', 'hrage']) const restingHeartRateRaw = getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting']) !== undefined ? getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting']) : getFirstDefined(normalized, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting']) const userWeightRaw = getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight']) !== undefined ? getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight']) : getFirstDefined(normalized, ['userweightkg', 'weightkg', 'weight']) const telemetryOverrides: Partial = {} if (ageRaw !== undefined) { telemetryOverrides.heartRateAge = Number(ageRaw) } if (restingHeartRateRaw !== undefined) { telemetryOverrides.restingHeartRateBpm = Number(restingHeartRateRaw) } if (userWeightRaw !== undefined) { telemetryOverrides.userWeightKg = Number(userWeightRaw) } return mergeTelemetryConfig(telemetryOverrides) } function normalizeObjectRecord(rawValue: unknown): Record { if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) { return {} } const normalized: Record = {} const keys = Object.keys(rawValue as Record) for (const key of keys) { normalized[key.toLowerCase()] = (rawValue as Record)[key] } return normalized } function getFirstDefined(record: Record, keys: string[]): unknown { for (const key of keys) { if (record[key] !== undefined) { return record[key] } } return undefined } function resolveAudioSrc(baseUrl: string, rawValue: unknown): string | undefined { if (typeof rawValue !== 'string') { return undefined } const trimmed = rawValue.trim() if (!trimmed) { return undefined } if (/^https?:\/\//i.test(trimmed)) { return trimmed } if (trimmed.startsWith('/assets/')) { return trimmed } if (trimmed.startsWith('assets/')) { return `/${trimmed}` } return resolveUrl(baseUrl, trimmed) } function buildAudioCueOverride(rawValue: unknown, baseUrl: string): PartialAudioCueConfig | null { if (typeof rawValue === 'string') { const src = resolveAudioSrc(baseUrl, rawValue) return src ? { src } : null } const normalized = normalizeObjectRecord(rawValue) if (!Object.keys(normalized).length) { return null } const src = resolveAudioSrc(baseUrl, getFirstDefined(normalized, ['src', 'url', 'path'])) const volumeRaw = getFirstDefined(normalized, ['volume']) const loopRaw = getFirstDefined(normalized, ['loop']) const loopGapRaw = getFirstDefined(normalized, ['loopgapms', 'loopgap']) const cue: PartialAudioCueConfig = {} if (src) { cue.src = src } if (volumeRaw !== undefined) { cue.volume = parsePositiveNumber(volumeRaw, 1) } if (loopRaw !== undefined) { cue.loop = parseBoolean(loopRaw, false) } if (loopGapRaw !== undefined) { cue.loopGapMs = parsePositiveNumber(loopGapRaw, 0) } return cue.src || cue.volume !== undefined || cue.loop !== undefined || cue.loopGapMs !== undefined ? cue : null } function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig { const normalized = normalizeObjectRecord(rawValue) if (!Object.keys(normalized).length) { return mergeGameAudioConfig() } const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events'])) const cueMap: Array<{ key: AudioCueKey; aliases: string[] }> = [ { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start', 'session_start'] }, { key: 'control_completed:start', aliases: ['control_completed:start', 'controlcompleted:start', 'start_completed', 'startcomplete', 'start-complete'] }, { key: 'control_completed:control', aliases: ['control_completed:control', 'controlcompleted:control', 'control_completed', 'controlcompleted', 'control_complete', 'controlcomplete'] }, { 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'] }, ] const cues: GameAudioConfigOverrides['cues'] = {} for (const cueDef of cueMap) { const cueRaw = getFirstDefined(normalizedCues, cueDef.aliases) const cue = buildAudioCueOverride(cueRaw, baseUrl) if (cue) { cues[cueDef.key] = cue } } return mergeGameAudioConfig({ enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined, masterVolume: normalized.mastervolume !== undefined ? parsePositiveNumber(normalized.mastervolume, 1) : normalized.volume !== undefined ? 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, }) } function parseLooseJsonObject(text: string): Record { const parsed: Record = {} const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g let match: RegExpExecArray | null while ((match = pairPattern.exec(text))) { const rawValue = match[2] let value: unknown = rawValue if (rawValue === 'true' || rawValue === 'false') { value = rawValue === 'true' } else if (rawValue === 'null') { value = null } else if (rawValue.startsWith('"') && rawValue.endsWith('"')) { value = match[3] || '' } else { const numericValue = Number(rawValue) value = Number.isFinite(numericValue) ? numericValue : rawValue } parsed[match[1]] = value } return parsed } function parseHapticPattern(rawValue: unknown): 'short' | 'long' | undefined { if (rawValue === 'short' || rawValue === 'long') { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if (normalized === 'short' || normalized === 'long') { return normalized } } return undefined } function parsePunchFeedbackMotion(rawValue: unknown): 'none' | 'pop' | 'success' | 'warning' | undefined { if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'success' || rawValue === 'warning') { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if (normalized === 'none' || normalized === 'pop' || normalized === 'success' || normalized === 'warning') { return normalized } } return undefined } function parseContentCardMotion(rawValue: unknown): 'none' | 'pop' | 'finish' | undefined { if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'finish') { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if (normalized === 'none' || normalized === 'pop' || normalized === 'finish') { return normalized } } return undefined } function parsePunchButtonMotion(rawValue: unknown): 'none' | 'ready' | 'warning' | undefined { if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'warning') { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if (normalized === 'none' || normalized === 'ready' || normalized === 'warning') { return normalized } } return undefined } function parseMapPulseMotion(rawValue: unknown): 'none' | 'ready' | 'control' | 'finish' | undefined { if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'control' || rawValue === 'finish') { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if (normalized === 'none' || normalized === 'ready' || normalized === 'control' || normalized === 'finish') { return normalized } } return undefined } function normalizeHexColor(rawValue: unknown, fallbackValue: string): string { if (typeof rawValue !== 'string') { return fallbackValue } const trimmed = rawValue.trim() if (!trimmed) { return fallbackValue } if (/^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{8}$/.test(trimmed)) { return trimmed.toLowerCase() } return fallbackValue } function parseControlPointStyleId(rawValue: unknown, fallbackValue: ControlPointStyleId): ControlPointStyleId { if (rawValue === 'classic-ring' || rawValue === 'solid-dot' || rawValue === 'double-ring' || rawValue === 'badge' || rawValue === 'pulse-core') { return rawValue } return fallbackValue } function parseCourseLegStyleId(rawValue: unknown, fallbackValue: CourseLegStyleId): CourseLegStyleId { if (rawValue === 'classic-leg' || rawValue === 'dashed-leg' || rawValue === 'glow-leg' || rawValue === 'progress-leg') { return rawValue } return fallbackValue } function parseControlPointStyleEntry(rawValue: unknown, fallbackValue: ControlPointStyleEntry): ControlPointStyleEntry { const normalized = normalizeObjectRecord(rawValue) const sizeScale = parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackValue.sizeScale || 1) const accentRingScale = parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackValue.accentRingScale || 0) const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0) const labelScale = parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackValue.labelScale || 1) return { style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style), colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex), sizeScale, accentRingScale, glowStrength: clamp(glowStrength, 0, 1.2), labelScale, labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackValue.labelColorHex || ''), } } function parseCourseLegStyleEntry(rawValue: unknown, fallbackValue: CourseLegStyleEntry): CourseLegStyleEntry { const normalized = normalizeObjectRecord(rawValue) const widthScale = parsePositiveNumber(getFirstDefined(normalized, ['widthscale']), fallbackValue.widthScale || 1) const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0) return { style: parseCourseLegStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style), colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex), widthScale, glowStrength: clamp(glowStrength, 0, 1.2), } } function parseScoreBandStyleEntries(rawValue: unknown, fallbackValue: ScoreBandStyleEntry[]): ScoreBandStyleEntry[] { if (!Array.isArray(rawValue) || !rawValue.length) { return fallbackValue } const parsed: ScoreBandStyleEntry[] = [] for (let index = 0; index < rawValue.length; index += 1) { const item = rawValue[index] if (!item || typeof item !== 'object' || Array.isArray(item)) { continue } const normalized = normalizeObjectRecord(item) const fallbackItem = fallbackValue[Math.min(index, fallbackValue.length - 1)] const minValue = Number(getFirstDefined(normalized, ['min'])) const maxValue = Number(getFirstDefined(normalized, ['max'])) parsed.push({ min: Number.isFinite(minValue) ? Math.round(minValue) : fallbackItem.min, max: Number.isFinite(maxValue) ? Math.round(maxValue) : fallbackItem.max, style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackItem.style), colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackItem.colorHex), sizeScale: parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackItem.sizeScale || 1), accentRingScale: parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackItem.accentRingScale || 0), glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackItem.glowStrength || 0), 0, 1.2), labelScale: parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackItem.labelScale || 1), labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackItem.labelColorHex || ''), }) } return parsed.length ? parsed : fallbackValue } function parseCourseStyleConfig(rawValue: unknown): CourseStyleConfig { const normalized = normalizeObjectRecord(rawValue) const sequential = normalizeObjectRecord(getFirstDefined(normalized, ['sequential', 'classicsequential', 'classic'])) const sequentialControls = normalizeObjectRecord(getFirstDefined(sequential, ['controls'])) const sequentialLegs = normalizeObjectRecord(getFirstDefined(sequential, ['legs'])) const scoreO = normalizeObjectRecord(getFirstDefined(normalized, ['scoreo', 'score'])) const scoreOControls = normalizeObjectRecord(getFirstDefined(scoreO, ['controls'])) return { sequential: { controls: { default: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default), current: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['current', 'active']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.current), completed: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.completed), skipped: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['skipped']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.skipped), start: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.start), finish: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.finish), }, legs: { default: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default), completed: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.completed), }, }, scoreO: { controls: { default: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default), focused: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['focused', 'active']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.focused), collected: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['collected', 'completed']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.collected), start: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.start), finish: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.finish), scoreBands: parseScoreBandStyleEntries(getFirstDefined(scoreOControls, ['scorebands', 'bands']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.scoreBands), }, }, } } function parseIndexedLegOverrideKey(rawKey: string): number | null { if (typeof rawKey !== 'string') { return null } const normalized = rawKey.trim().toLowerCase() const legMatch = normalized.match(/^leg-(\d+)$/) if (legMatch) { const oneBasedIndex = Number(legMatch[1]) return Number.isFinite(oneBasedIndex) && oneBasedIndex > 0 ? oneBasedIndex - 1 : null } const numericIndex = Number(normalized) return Number.isFinite(numericIndex) && numericIndex >= 0 ? Math.floor(numericIndex) : null } function parseContentCardCtas(rawValue: unknown): GameControlDisplayContentOverride['ctas'] | undefined { if (!Array.isArray(rawValue)) { return undefined } const parsed = rawValue .map((item) => { const normalized = normalizeObjectRecord(item) if (!Object.keys(normalized).length) { return null } const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : '' if (typeValue !== 'detail' && typeValue !== 'photo' && typeValue !== 'audio' && typeValue !== 'quiz') { return null } const labelValue = typeof normalized.label === 'string' ? normalized.label.trim() : '' if (typeValue !== 'quiz') { return { type: typeValue as 'detail' | 'photo' | 'audio', ...(labelValue ? { label: labelValue } : {}), } } const quizRaw = { ...normalizeObjectRecord(normalized.quiz), ...(normalized.bonusScore !== undefined ? { bonusScore: normalized.bonusScore } : {}), ...(normalized.countdownSeconds !== undefined ? { countdownSeconds: normalized.countdownSeconds } : {}), ...(normalized.minValue !== undefined ? { minValue: normalized.minValue } : {}), ...(normalized.maxValue !== undefined ? { maxValue: normalized.maxValue } : {}), ...(normalized.allowSubtraction !== undefined ? { allowSubtraction: normalized.allowSubtraction } : {}), } const minValue = Number(quizRaw.minValue) const maxValue = Number(quizRaw.maxValue) const countdownSeconds = Number(quizRaw.countdownSeconds) const bonusScore = Number(quizRaw.bonusScore) return { type: 'quiz' as const, ...(labelValue ? { label: labelValue } : {}), ...(Number.isFinite(minValue) ? { minValue: Math.max(10, Math.round(minValue)) } : {}), ...(Number.isFinite(maxValue) ? { maxValue: Math.max(99, Math.round(maxValue)) } : {}), ...(typeof quizRaw.allowSubtraction === 'boolean' ? { allowSubtraction: quizRaw.allowSubtraction } : {}), ...(Number.isFinite(countdownSeconds) ? { countdownSeconds: Math.max(3, Math.round(countdownSeconds)) } : {}), ...(Number.isFinite(bonusScore) ? { bonusScore: Math.max(0, Math.round(bonusScore)) } : {}), } }) .filter((item): item is NonNullable => !!item) return parsed.length ? parsed : undefined } function parseStageMotion(rawValue: unknown): 'none' | 'finish' | undefined { if (rawValue === 'none' || rawValue === 'finish') { return rawValue } if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase() if (normalized === 'none' || normalized === 'finish') { return normalized } } return undefined } function buildHapticsCueOverride(rawValue: unknown): PartialHapticCueConfig | null { if (typeof rawValue === 'boolean') { return { enabled: rawValue } } const pattern = parseHapticPattern(rawValue) if (pattern) { return { enabled: true, pattern } } const normalized = normalizeObjectRecord(rawValue) if (!Object.keys(normalized).length) { return null } const cue: PartialHapticCueConfig = {} if (normalized.enabled !== undefined) { cue.enabled = parseBoolean(normalized.enabled, true) } const parsedPattern = parseHapticPattern(getFirstDefined(normalized, ['pattern', 'type'])) if (parsedPattern) { cue.pattern = parsedPattern } return cue.enabled !== undefined || cue.pattern !== undefined ? cue : null } function buildUiCueOverride(rawValue: unknown): PartialUiCueConfig | null { const normalized = normalizeObjectRecord(rawValue) if (!Object.keys(normalized).length) { return null } const cue: PartialUiCueConfig = {} if (normalized.enabled !== undefined) { cue.enabled = parseBoolean(normalized.enabled, true) } const punchFeedbackMotion = parsePunchFeedbackMotion(getFirstDefined(normalized, ['punchfeedbackmotion', 'feedbackmotion', 'toastmotion'])) if (punchFeedbackMotion) { cue.punchFeedbackMotion = punchFeedbackMotion } const contentCardMotion = parseContentCardMotion(getFirstDefined(normalized, ['contentcardmotion', 'cardmotion'])) if (contentCardMotion) { cue.contentCardMotion = contentCardMotion } const punchButtonMotion = parsePunchButtonMotion(getFirstDefined(normalized, ['punchbuttonmotion', 'buttonmotion'])) if (punchButtonMotion) { cue.punchButtonMotion = punchButtonMotion } const mapPulseMotion = parseMapPulseMotion(getFirstDefined(normalized, ['mappulsemotion', 'mapmotion'])) if (mapPulseMotion) { cue.mapPulseMotion = mapPulseMotion } const stageMotion = parseStageMotion(getFirstDefined(normalized, ['stagemotion', 'screenmotion'])) if (stageMotion) { cue.stageMotion = stageMotion } const durationRaw = getFirstDefined(normalized, ['durationms', 'duration']) if (durationRaw !== undefined) { cue.durationMs = parsePositiveNumber(durationRaw, 0) } return cue.enabled !== undefined || cue.punchFeedbackMotion !== undefined || cue.contentCardMotion !== undefined || cue.punchButtonMotion !== undefined || cue.mapPulseMotion !== undefined || cue.stageMotion !== undefined || cue.durationMs !== undefined ? cue : null } function parseHapticsConfig(rawValue: unknown): GameHapticsConfig { const normalized = normalizeObjectRecord(rawValue) if (!Object.keys(normalized).length) { return mergeGameHapticsConfig() } const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events'])) const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [ { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] }, { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] }, { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] }, { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] }, { 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'] }, ] const cues: GameHapticsConfigOverrides['cues'] = {} for (const cueDef of cueMap) { const cue = buildHapticsCueOverride(getFirstDefined(normalizedCues, cueDef.aliases)) if (cue) { cues[cueDef.key] = cue } } return mergeGameHapticsConfig({ enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined, cues, }) } function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig { const normalized = normalizeObjectRecord(rawValue) if (!Object.keys(normalized).length) { return mergeGameUiEffectsConfig() } const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events'])) const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [ { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] }, { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] }, { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] }, { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] }, { 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'] }, ] const cues: GameUiEffectsConfigOverrides['cues'] = {} for (const cueDef of cueMap) { const cue = buildUiCueOverride(getFirstDefined(normalizedCues, cueDef.aliases)) if (cue) { cues[cueDef.key] = cue } } return mergeGameUiEffectsConfig({ enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined, cues, }) } function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGameConfig { let parsed: Record try { parsed = JSON.parse(text) } catch { parsed = parseLooseJsonObject(text) } const normalized: Record = {} const keys = Object.keys(parsed) for (const key of keys) { normalized[key.toLowerCase()] = parsed[key] } const rawGame = parsed.game && typeof parsed.game === 'object' && !Array.isArray(parsed.game) ? parsed.game as Record : null const rawApp = parsed.app && typeof parsed.app === 'object' && !Array.isArray(parsed.app) ? parsed.app as Record : null const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map) ? parsed.map as Record : null const rawSettings = parsed.settings && typeof parsed.settings === 'object' && !Array.isArray(parsed.settings) ? parsed.settings as Record : null const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield) ? parsed.playfield as Record : null const rawPlayfieldSource = rawPlayfield && rawPlayfield.source && typeof rawPlayfield.source === 'object' && !Array.isArray(rawPlayfield.source) ? rawPlayfield.source as Record : null const rawGamePresentation = rawGame && rawGame.presentation && typeof rawGame.presentation === 'object' && !Array.isArray(rawGame.presentation) ? rawGame.presentation as Record : null const normalizedGamePresentation = normalizeObjectRecord(rawGamePresentation) const normalizedGame: Record = {} if (rawGame) { const gameKeys = Object.keys(rawGame) for (const key of gameKeys) { normalizedGame[key.toLowerCase()] = rawGame[key] } } const rawAudio = rawGame && rawGame.audio !== undefined ? rawGame.audio : parsed.audio const rawTelemetry = rawGame && rawGame.telemetry !== undefined ? rawGame.telemetry : parsed.telemetry const rawHaptics = rawGame && rawGame.haptics !== undefined ? rawGame.haptics : parsed.haptics const rawUiEffects = rawGame && rawGame.uiEffects !== undefined ? rawGame.uiEffects : rawGame && rawGame.uieffects !== undefined ? rawGame.uieffects : rawGame && rawGame.ui !== undefined ? rawGame.ui : (parsed as Record).uiEffects !== undefined ? (parsed as Record).uiEffects : (parsed as Record).uieffects !== undefined ? (parsed as Record).uieffects : (parsed as Record).ui const rawSession = rawGame && rawGame.session && typeof rawGame.session === 'object' && !Array.isArray(rawGame.session) ? rawGame.session as Record : null const rawPunch = rawGame && rawGame.punch && typeof rawGame.punch === 'object' && !Array.isArray(rawGame.punch) ? rawGame.punch as Record : null const rawSequence = rawGame && rawGame.sequence && typeof rawGame.sequence === 'object' && !Array.isArray(rawGame.sequence) ? rawGame.sequence as Record : null const rawSkip = rawSequence && rawSequence.skip && typeof rawSequence.skip === 'object' && !Array.isArray(rawSequence.skip) ? rawSequence.skip as Record : null const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring) ? rawGame.scoring as Record : null const rawGameSettings = rawGame && rawGame.settings && typeof rawGame.settings === 'object' && !Array.isArray(rawGame.settings) ? rawGame.settings as Record : null const mapRoot = rawMap && typeof rawMap.tiles === 'string' ? rawMap.tiles : typeof normalized.map === 'string' ? normalized.map : '' const mapMeta = rawMap && typeof rawMap.mapmeta === 'string' ? rawMap.mapmeta : typeof normalized.mapmeta === 'string' ? normalized.mapmeta : '' if (!mapRoot || !mapMeta) { throw new Error('game.json 缺少 map 或 mapmeta 字段') } 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 : null const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides) ? rawPlayfield.controlOverrides as Record : null const defaultControlScoreFromPlayfield = rawControlDefaults ? Number(getFirstDefined(normalizeObjectRecord(rawControlDefaults), ['score'])) : Number.NaN const defaultControlContentOverride = parseControlDisplayContentOverride(rawControlDefaults, gameConfigUrl) const defaultControlPointStyleOverride = parseControlPointStyleOverride(rawControlDefaults, fallbackPointStyle) const controlScoreOverrides: Record = {} const controlContentOverrides: Record = {} const controlPointStyleOverrides: Record = {} if (rawControlOverrides) { const keys = Object.keys(rawControlOverrides) for (const key of keys) { const item = rawControlOverrides[key] if (!item || typeof item !== 'object' || Array.isArray(item)) { continue } const scoreValue = Number((item as Record).score) if (Number.isFinite(scoreValue)) { controlScoreOverrides[key] = scoreValue } 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 : null const defaultLegStyleOverride = parseLegStyleOverride(rawLegDefaults, fallbackLegStyle) const rawLegOverrides = rawPlayfield && rawPlayfield.legOverrides && typeof rawPlayfield.legOverrides === 'object' && !Array.isArray(rawPlayfield.legOverrides) ? rawPlayfield.legOverrides as Record : null const legStyleOverrides: Record = {} if (rawLegOverrides) { const legKeys = Object.keys(rawLegOverrides) for (const rawKey of legKeys) { const item = rawLegOverrides[rawKey] const index = parseIndexedLegOverrideKey(rawKey) if (index === null || !item || typeof item !== 'object' || Array.isArray(item)) { continue } 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).requiresfocusselection !== undefined ? (rawPunch as Record).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 : '', schemaVersion: typeof parsed.schemaVersion === 'string' ? parsed.schemaVersion : '1', version: typeof parsed.version === 'string' ? parsed.version : '', playfieldKind: rawPlayfield && typeof rawPlayfield.kind === 'string' ? rawPlayfield.kind : '', mapRoot, mapMeta, course: rawPlayfieldSource && typeof rawPlayfieldSource.url === 'string' ? rawPlayfieldSource.url : typeof normalized.course === 'string' ? normalized.course : null, cpRadiusMeters: parsePositiveNumber( rawPlayfield && rawPlayfield.CPRadius !== undefined ? rawPlayfield.CPRadius : normalized.cpradius, 5, ), defaultZoom: rawMap && rawMap.initialView && typeof rawMap.initialView === 'object' && !Array.isArray(rawMap.initialView) ? parsePositiveNumber((rawMap.initialView as Record).zoom, 17) : null, gameMode, sessionCloseAfterMs, sessionCloseWarningMs, minCompletedControlsBeforeFinish, punchPolicy: parsePunchPolicy( rawPunch && rawPunch.policy !== undefined ? rawPunch.policy : normalizedGame.punchpolicy !== undefined ? normalizedGame.punchpolicy : normalized.punchpolicy, ), punchRadiusMeters, requiresFocusSelection, skipEnabled, skipRadiusMeters, skipRequiresConfirm: parseBoolean( rawSkip && rawSkip.requiresConfirm !== undefined ? rawSkip.requiresConfirm : normalizedGame.skiprequiresconfirm !== undefined ? normalizedGame.skiprequiresconfirm : normalized.skiprequiresconfirm, modeDefaults.skipRequiresConfirm, ), autoFinishOnLastControl, controlScoreOverrides, controlContentOverrides, defaultControlContentOverride, defaultControlPointStyleOverride, controlPointStyleOverrides, defaultLegStyleOverride, legStyleOverrides, 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'])), telemetryConfig: parseTelemetryConfig(rawTelemetry), audioConfig: parseAudioConfig(rawAudio, gameConfigUrl), hapticsConfig: parseHapticsConfig(rawHaptics), uiEffectsConfig: parseUiEffectsConfig(rawUiEffects), systemSettingsConfig: parseSystemSettingsConfig(rawGameSettings || rawSettings), declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination), } } function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGameConfig { const config: Record = {} const lines = text.split(/\r?\n/) for (const rawLine of lines) { const line = rawLine.trim() if (!line || line.startsWith('#')) { continue } const match = line.match(/^([A-Za-z0-9_-]+)\s*(?:=|:)\s*(.+)$/) if (!match) { continue } config[match[1].trim().toLowerCase()] = match[2].trim() } const mapRoot = config.map const mapMeta = config.mapmeta if (!mapRoot || !mapMeta) { throw new Error('game.yaml 缺少 map 或 mapmeta 字段') } const gameMode = parseGameMode(config.gamemode) const modeDefaults = getGameModeDefaults(gameMode) const punchRadiusMeters = parsePositiveNumber( config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius, 5, ) return { title: '', appId: '', schemaVersion: '1', version: '', playfieldKind: typeof config.playfieldkind === 'string' ? config.playfieldkind : '', mapRoot, mapMeta, course: typeof config.course === 'string' ? config.course : null, cpRadiusMeters: parsePositiveNumber(config.cpradius, 5), defaultZoom: null, gameMode, sessionCloseAfterMs: modeDefaults.sessionCloseAfterMs, sessionCloseWarningMs: modeDefaults.sessionCloseWarningMs, minCompletedControlsBeforeFinish: modeDefaults.minCompletedControlsBeforeFinish, punchPolicy: parsePunchPolicy(config.punchpolicy), punchRadiusMeters, requiresFocusSelection: parseBoolean(config.requiresfocusselection, modeDefaults.requiresFocusSelection), skipEnabled: parseBoolean(config.skipenabled, modeDefaults.skipEnabled), skipRadiusMeters: parsePositiveNumber( config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius, getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters), ), skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, modeDefaults.skipRequiresConfirm), autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, modeDefaults.autoFinishOnLastControl), controlScoreOverrides: {}, controlContentOverrides: {}, defaultControlContentOverride: null, defaultControlPointStyleOverride: null, controlPointStyleOverrides: {}, defaultLegStyleOverride: null, legStyleOverrides: {}, defaultControlScore: modeDefaults.defaultControlScore, courseStyleConfig: DEFAULT_COURSE_STYLE_CONFIG, trackStyleConfig: DEFAULT_TRACK_VISUALIZATION_CONFIG, gpsMarkerStyleConfig: DEFAULT_GPS_MARKER_STYLE_CONFIG, telemetryConfig: parseTelemetryConfig({ heartRate: { age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage, restingHeartRateBpm: config.restingheartratebpm !== undefined ? config.restingheartratebpm : config.restingheartrate !== undefined ? config.restingheartrate : config.telemetryrestingheartratebpm !== undefined ? config.telemetryrestingheartratebpm : config.telemetryrestingheartrate, }, }), audioConfig: parseAudioConfig({ 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, 'control_completed:control': config.audiocontrolcomplete, '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, }, }, gameConfigUrl), hapticsConfig: parseHapticsConfig({ enabled: config.hapticsenabled, cues: { session_started: config.hapticsstart, session_finished: config.hapticsfinish, 'control_completed:start': config.hapticsstartcomplete, 'control_completed:control': config.hapticscontrolcomplete, '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, }, }), uiEffectsConfig: parseUiEffectsConfig({ enabled: config.uieffectsenabled, cues: { session_started: { enabled: config.uistartenabled, punchButtonMotion: config.uistartbuttonmotion }, session_finished: { enabled: config.uifinishenabled, contentCardMotion: config.uifinishcardmotion }, 'control_completed:start': { enabled: config.uistartcompleteenabled, contentCardMotion: config.uistartcompletecardmotion, punchFeedbackMotion: config.uistartcompletetoastmotion }, 'control_completed:control': { enabled: config.uicontrolcompleteenabled, contentCardMotion: config.uicontrolcompletecardmotion, punchFeedbackMotion: config.uicontrolcompletetoastmotion }, 'control_completed:finish': { enabled: config.uifinishcompleteenabled, contentCardMotion: config.uifinishcompletecardmotion, punchFeedbackMotion: config.uifinishcompletetoastmotion }, 'punch_feedback:warning': { enabled: config.uiwarningenabled, punchFeedbackMotion: config.uiwarningtoastmotion, punchButtonMotion: config.uiwarningbuttonmotion, durationMs: config.uiwarningdurationms }, 'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms }, }, }), systemSettingsConfig: { values: {}, locks: {} }, declinationDeg: parseDeclinationValue(config.declination), } } function parseGameConfig(text: string, gameConfigUrl: string): ParsedGameConfig { const trimmedText = text.trim() const isJson = trimmedText.startsWith('{') || trimmedText.startsWith('[') || /\.json(?:[?#].*)?$/i.test(gameConfigUrl) return isJson ? parseGameConfigFromJson(trimmedText, gameConfigUrl) : parseGameConfigFromYaml(trimmedText, gameConfigUrl) } function extractStringField(text: string, key: string): string | null { const pattern = new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`) const match = text.match(pattern) return match ? match[1] : null } function extractNumberField(text: string, key: string): number | null { const pattern = new RegExp(`"${key}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`) const match = text.match(pattern) if (!match) { return null } const value = Number(match[1]) return Number.isFinite(value) ? value : null } function extractNumberArrayField(text: string, key: string): number[] | null { const pattern = new RegExp(`"${key}"\\s*:\\s*\\[([^\\]]+)\\]`) const match = text.match(pattern) if (!match) { return null } const numberMatches = match[1].match(/-?\d+(?:\.\d+)?/g) if (!numberMatches || !numberMatches.length) { return null } const values = numberMatches .map((item) => Number(item)) .filter((item) => Number.isFinite(item)) return values.length ? values : null } function parseMapMeta(text: string): ParsedMapMeta { const tileSizeField = extractNumberField(text, 'tileSize') const tileSize = tileSizeField === null ? 256 : tileSizeField const minZoom = extractNumberField(text, 'minZoom') const maxZoom = extractNumberField(text, 'maxZoom') const projectionField = extractStringField(text, 'projection') const projection = projectionField === null ? 'EPSG:3857' : projectionField const tilePathTemplate = extractStringField(text, 'tilePathTemplate') const tileFormatFromField = extractStringField(text, 'tileFormat') const boundsValues = extractNumberArrayField(text, 'bounds') if (!Number.isFinite(minZoom) || !Number.isFinite(maxZoom) || !tilePathTemplate) { throw new Error('meta.json 缺少必要字段') } let tileFormat = tileFormatFromField || '' if (!tileFormat) { const extensionMatch = tilePathTemplate.match(/\.([A-Za-z0-9]+)$/) tileFormat = extensionMatch ? extensionMatch[1].toLowerCase() : 'png' } return { tileSize, minZoom: minZoom as number, maxZoom: maxZoom as number, projection, tileFormat, tilePathTemplate, bounds: boundsValues && boundsValues.length >= 4 ? [boundsValues[0], boundsValues[1], boundsValues[2], boundsValues[3]] : null, } } function getBoundsCorners( bounds: [number, number, number, number], projection: string, ): { northWest: LonLatPoint; southEast: LonLatPoint; center: LonLatPoint } { if (projection === 'EPSG:3857') { const minX = bounds[0] const minY = bounds[1] const maxX = bounds[2] const maxY = bounds[3] return { northWest: webMercatorToLonLat({ x: minX, y: maxY }), southEast: webMercatorToLonLat({ x: maxX, y: minY }), center: webMercatorToLonLat({ x: (minX + maxX) / 2, y: (minY + maxY) / 2 }), } } if (projection === 'EPSG:4326') { const minLon = bounds[0] const minLat = bounds[1] const maxLon = bounds[2] const maxLat = bounds[3] return { northWest: { lon: minLon, lat: maxLat }, southEast: { lon: maxLon, lat: minLat }, center: { lon: (minLon + maxLon) / 2, lat: (minLat + maxLat) / 2 }, } } throw new Error(`暂不支持的投影: ${projection}`) } function buildTileBoundsByZoom( bounds: [number, number, number, number] | null, projection: string, minZoom: number, maxZoom: number, ): Record { const boundsByZoom: Record = {} if (!bounds) { return boundsByZoom } const corners = getBoundsCorners(bounds, projection) for (let zoom = minZoom; zoom <= maxZoom; zoom += 1) { const northWestWorld = lonLatToWorldTile(corners.northWest, zoom) const southEastWorld = lonLatToWorldTile(corners.southEast, zoom) const minX = Math.floor(Math.min(northWestWorld.x, southEastWorld.x)) const maxX = Math.ceil(Math.max(northWestWorld.x, southEastWorld.x)) - 1 const minY = Math.floor(Math.min(northWestWorld.y, southEastWorld.y)) const maxY = Math.ceil(Math.max(northWestWorld.y, southEastWorld.y)) - 1 boundsByZoom[zoom] = { minX, maxX, minY, maxY, } } return boundsByZoom } function getProjectionModeText(projection: string): string { return `${projection} -> XYZ Tile -> Camera -> Screen` } export function isTileWithinBounds( tileBoundsByZoom: Record | null | undefined, zoom: number, x: number, y: number, ): boolean { if (!tileBoundsByZoom) { return true } const bounds = tileBoundsByZoom[zoom] if (!bounds) { return true } return x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY } export async function loadRemoteMapConfig(gameConfigUrl: string): Promise { const gameConfigText = await requestText(gameConfigUrl) const gameConfig = parseGameConfig(gameConfigText, gameConfigUrl) const mapMetaUrl = resolveUrl(gameConfigUrl, gameConfig.mapMeta) const mapRootUrl = resolveUrl(gameConfigUrl, gameConfig.mapRoot) const courseUrl = gameConfig.course ? resolveUrl(gameConfigUrl, gameConfig.course) : null const mapMetaText = await requestText(mapMetaUrl) const mapMeta = parseMapMeta(mapMetaText) let course: OrienteeringCourseData | null = null let courseStatusText = courseUrl ? '路线待加载' : '未配置路线' if (courseUrl) { try { const courseText = await requestText(courseUrl) course = parseOrienteeringCourseKml(courseText) courseStatusText = `路线已载入 (${course.layers.controls.length} controls)` } catch (error) { const message = error instanceof Error ? error.message : '未知错误' courseStatusText = `路线加载失败: ${message}` } } const defaultZoom = clamp(gameConfig.defaultZoom || 17, mapMeta.minZoom, mapMeta.maxZoom) const boundsCorners = mapMeta.bounds ? getBoundsCorners(mapMeta.bounds, mapMeta.projection) : null const centerWorldTile = boundsCorners ? lonLatToWorldTile(boundsCorners.center, defaultZoom) : { x: 0, y: 0 } return { configTitle: gameConfig.title || '未命名配置', configAppId: gameConfig.appId || '', configSchemaVersion: gameConfig.schemaVersion || '1', configVersion: gameConfig.version || '', playfieldKind: gameConfig.playfieldKind || '', tileSource: resolveUrl(mapRootUrl, mapMeta.tilePathTemplate), minZoom: mapMeta.minZoom, maxZoom: mapMeta.maxZoom, defaultZoom, initialCenterTileX: Math.round(centerWorldTile.x), initialCenterTileY: Math.round(centerWorldTile.y), projection: mapMeta.projection, projectionModeText: getProjectionModeText(mapMeta.projection), magneticDeclinationDeg: gameConfig.declinationDeg, magneticDeclinationText: formatDeclinationText(gameConfig.declinationDeg), tileFormat: mapMeta.tileFormat, tileSize: mapMeta.tileSize, bounds: mapMeta.bounds, tileBoundsByZoom: buildTileBoundsByZoom(mapMeta.bounds, mapMeta.projection, mapMeta.minZoom, mapMeta.maxZoom), mapMetaUrl, mapRootUrl, courseUrl, course, 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, skipEnabled: gameConfig.skipEnabled, skipRadiusMeters: gameConfig.skipRadiusMeters, skipRequiresConfirm: gameConfig.skipRequiresConfirm, 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, trackStyleConfig: gameConfig.trackStyleConfig, gpsMarkerStyleConfig: gameConfig.gpsMarkerStyleConfig, telemetryConfig: gameConfig.telemetryConfig, audioConfig: gameConfig.audioConfig, hapticsConfig: gameConfig.hapticsConfig, uiEffectsConfig: gameConfig.uiEffectsConfig, systemSettingsConfig: gameConfig.systemSettingsConfig, } }