Files
cmr-mini/miniprogram/utils/remoteMapConfig.ts

1219 lines
44 KiB
TypeScript

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 {
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<number, TileZoomBounds>
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<string, number>
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<string, number>
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<string> {
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<string> {
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<string> {
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 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<TelemetryConfig> = {}
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<string, unknown> {
if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
return {}
}
const normalized: Record<string, unknown> = {}
const keys = Object.keys(rawValue as Record<string, unknown>)
for (const key of keys) {
normalized[key.toLowerCase()] = (rawValue as Record<string, unknown>)[key]
}
return normalized
}
function getFirstDefined(record: Record<string, unknown>, 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<string, unknown> {
const parsed: Record<string, unknown> = {}
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<string, unknown>
try {
parsed = JSON.parse(text)
} catch {
parsed = parseLooseJsonObject(text)
}
const normalized: Record<string, unknown> = {}
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<string, unknown>
: null
const rawApp = parsed.app && typeof parsed.app === 'object' && !Array.isArray(parsed.app)
? parsed.app as Record<string, unknown>
: null
const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map)
? parsed.map as Record<string, unknown>
: null
const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield)
? parsed.playfield as Record<string, unknown>
: null
const rawPlayfieldSource = rawPlayfield && rawPlayfield.source && typeof rawPlayfield.source === 'object' && !Array.isArray(rawPlayfield.source)
? rawPlayfield.source as Record<string, unknown>
: null
const normalizedGame: Record<string, unknown> = {}
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<string, unknown>).uiEffects !== undefined
? (parsed as Record<string, unknown>).uiEffects
: (parsed as Record<string, unknown>).uieffects !== undefined
? (parsed as Record<string, unknown>).uieffects
: (parsed as Record<string, unknown>).ui
const rawSession = rawGame && rawGame.session && typeof rawGame.session === 'object' && !Array.isArray(rawGame.session)
? rawGame.session as Record<string, unknown>
: null
const rawPunch = rawGame && rawGame.punch && typeof rawGame.punch === 'object' && !Array.isArray(rawGame.punch)
? rawGame.punch as Record<string, unknown>
: null
const rawSequence = rawGame && rawGame.sequence && typeof rawGame.sequence === 'object' && !Array.isArray(rawGame.sequence)
? rawGame.sequence as Record<string, unknown>
: null
const rawSkip = rawSequence && rawSequence.skip && typeof rawSequence.skip === 'object' && !Array.isArray(rawSequence.skip)
? rawSequence.skip as Record<string, unknown>
: null
const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring)
? rawGame.scoring as Record<string, unknown>
: 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<string, unknown>
: null
const controlScoreOverrides: Record<string, number> = {}
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<string, unknown>).score)
if (Number.isFinite(scoreValue)) {
controlScoreOverrides[key] = scoreValue
}
}
}
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<string, unknown>).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<string, unknown>).requiresfocusselection !== undefined
? (rawPunch as Record<string, unknown>).requiresfocusselection
: normalized.requiresfocusselection,
false,
),
skipEnabled: parseBoolean(
rawSkip && rawSkip.enabled !== undefined
? rawSkip.enabled
: normalizedGame.skipenabled !== undefined
? normalizedGame.skipenabled
: normalized.skipenabled,
false,
),
skipRadiusMeters: parsePositiveNumber(
rawSkip && rawSkip.radiusMeters !== undefined
? rawSkip.radiusMeters
: normalizedGame.skipradiusmeters !== undefined
? normalizedGame.skipradiusmeters
: normalizedGame.skipradius !== undefined
? normalizedGame.skipradius
: normalized.skipradiusmeters !== undefined
? normalized.skipradiusmeters
: normalized.skipradius,
30,
),
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,
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<string, string> = {}
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: {},
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<number, TileZoomBounds> {
const boundsByZoom: Record<number, TileZoomBounds> = {}
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<number, TileZoomBounds> | 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<RemoteMapConfig> {
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,
defaultControlScore: gameConfig.defaultControlScore,
telemetryConfig: gameConfig.telemetryConfig,
audioConfig: gameConfig.audioConfig,
hapticsConfig: gameConfig.hapticsConfig,
uiEffectsConfig: gameConfig.uiEffectsConfig,
}
}