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' export interface TileZoomBounds { minX: number maxX: number minY: number maxY: number } export interface RemoteMapConfig { configTitle: string configAppId: string configSchemaVersion: string configVersion: 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' punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number requiresFocusSelection: boolean skipEnabled: boolean skipRadiusMeters: number skipRequiresConfirm: boolean autoFinishOnLastControl: boolean controlScoreOverrides: Record controlContentOverrides: Record defaultControlScore: number | null telemetryConfig: TelemetryConfig audioConfig: GameAudioConfig hapticsConfig: GameHapticsConfig uiEffectsConfig: GameUiEffectsConfig } interface ParsedGameConfig { title: string appId: string schemaVersion: string version: string mapRoot: string mapMeta: string course: string | null cpRadiusMeters: number defaultZoom: number | null gameMode: 'classic-sequential' | 'score-o' punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number requiresFocusSelection: boolean skipEnabled: boolean skipRadiusMeters: number skipRequiresConfirm: boolean autoFinishOnLastControl: boolean controlScoreOverrides: Record controlContentOverrides: Record defaultControlScore: number | null telemetryConfig: TelemetryConfig audioConfig: GameAudioConfig hapticsConfig: GameHapticsConfig uiEffectsConfig: GameUiEffectsConfig 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 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 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', } } 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' return { type: 'h5', url: resolveUrl(baseUrl, rawUrl), bridge: bridgeValue, fallback: 'native', } } 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 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: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, approachDistanceMeters: normalized.approachdistancemeters !== undefined ? parsePositiveNumber(normalized.approachdistancemeters, 20) : normalized.approachdistance !== undefined ? parsePositiveNumber(normalized.approachdistance, 20) : 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 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: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: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 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 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 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 rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides) ? rawPlayfield.controlOverrides as Record : null const controlScoreOverrides: Record = {} const controlContentOverrides: 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 titleValue = typeof (item as Record).title === 'string' ? ((item as Record).title as string).trim() : '' const bodyValue = typeof (item as Record).body === 'string' ? ((item as Record).body as string).trim() : '' const clickTitleValue = typeof (item as Record).clickTitle === 'string' ? ((item as Record).clickTitle as string).trim() : '' const clickBodyValue = typeof (item as Record).clickBody === 'string' ? ((item as Record).clickBody as string).trim() : '' const autoPopupValue = (item as Record).autoPopup const onceValue = (item as Record).once const priorityNumeric = Number((item as Record).priority) const contentExperienceValue = parseContentExperienceOverride((item as Record).contentExperience, gameConfigUrl) const clickExperienceValue = parseContentExperienceOverride((item as Record).clickExperience, gameConfigUrl) const hasAutoPopup = typeof autoPopupValue === 'boolean' const hasOnce = typeof onceValue === 'boolean' const hasPriority = Number.isFinite(priorityNumeric) if ( titleValue || bodyValue || clickTitleValue || clickBodyValue || hasAutoPopup || hasOnce || hasPriority || contentExperienceValue || clickExperienceValue ) { controlContentOverrides[key] = { ...(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)) } : {}), ...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}), ...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}), } } } } 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 : '', 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, punchPolicy: parsePunchPolicy( rawPunch && rawPunch.policy !== undefined ? rawPunch.policy : normalizedGame.punchpolicy !== undefined ? 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).requiresfocusselection !== undefined ? (rawPunch as Record).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, ), 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, ), controlScoreOverrides, controlContentOverrides, defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined ? parsePositiveNumber(rawScoring.defaultControlScore, 10) : null, telemetryConfig: parseTelemetryConfig(rawTelemetry), audioConfig: parseAudioConfig(rawAudio, gameConfigUrl), hapticsConfig: parseHapticsConfig(rawHaptics), uiEffectsConfig: parseUiEffectsConfig(rawUiEffects), 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) return { title: '', appId: '', schemaVersion: '1', version: '', mapRoot, mapMeta, course: typeof config.course === 'string' ? config.course : null, cpRadiusMeters: parsePositiveNumber(config.cpradius, 5), defaultZoom: null, gameMode, punchPolicy: parsePunchPolicy(config.punchpolicy), punchRadiusMeters: parsePositiveNumber( config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius, 5, ), requiresFocusSelection: parseBoolean(config.requiresfocusselection, false), skipEnabled: parseBoolean(config.skipenabled, false), skipRadiusMeters: parsePositiveNumber( config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius, 30, ), skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, true), autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true), controlScoreOverrides: {}, controlContentOverrides: {}, defaultControlScore: null, 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, approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance, 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: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: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 }, }, }), 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 || '', 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, 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, defaultControlScore: gameConfig.defaultControlScore, telemetryConfig: gameConfig.telemetryConfig, audioConfig: gameConfig.audioConfig, hapticsConfig: gameConfig.hapticsConfig, uiEffectsConfig: gameConfig.uiEffectsConfig, } }