| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041 |
- 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 {
- 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'
- punchPolicy: 'enter' | 'enter-confirm'
- punchRadiusMeters: number
- autoFinishOnLastControl: boolean
- telemetryConfig: TelemetryConfig
- audioConfig: GameAudioConfig
- hapticsConfig: GameHapticsConfig
- uiEffectsConfig: GameUiEffectsConfig
- }
- interface ParsedGameConfig {
- mapRoot: string
- mapMeta: string
- course: string | null
- cpRadiusMeters: number
- gameMode: 'classic-sequential'
- punchPolicy: 'enter' | 'enter-confirm'
- punchRadiusMeters: number
- autoFinishOnLastControl: boolean
- 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 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 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 mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
- const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
- if (!mapRoot || !mapMeta) {
- throw new Error('game.json 缺少 map 或 mapmeta 字段')
- }
- const gameMode = 'classic-sequential' as const
- const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
- if (typeof modeValue === 'string' && modeValue !== gameMode) {
- throw new Error(`暂不支持的 game.mode: ${modeValue}`)
- }
- return {
- mapRoot,
- mapMeta,
- course: typeof normalized.course === 'string' ? normalized.course : null,
- cpRadiusMeters: parsePositiveNumber(normalized.cpradius, 5),
- gameMode,
- punchPolicy: parsePunchPolicy(normalizedGame.punchpolicy !== undefined ? normalizedGame.punchpolicy : normalized.punchpolicy),
- punchRadiusMeters: parsePositiveNumber(
- normalizedGame.punchradiusmeters !== undefined
- ? normalizedGame.punchradiusmeters
- : normalizedGame.punchradius !== undefined
- ? normalizedGame.punchradius
- : normalized.punchradiusmeters !== undefined
- ? normalized.punchradiusmeters
- : normalized.punchradius,
- 5,
- ),
- autoFinishOnLastControl: parseBoolean(
- normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
- true,
- ),
- telemetryConfig: parseTelemetryConfig(rawTelemetry),
- audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
- hapticsConfig: parseHapticsConfig(rawHaptics),
- uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
- declinationDeg: parseDeclinationValue(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 = 'classic-sequential' as const
- if (config.gamemode && config.gamemode !== gameMode) {
- throw new Error(`暂不支持的 game.mode: ${config.gamemode}`)
- }
- return {
- mapRoot,
- mapMeta,
- course: typeof config.course === 'string' ? config.course : null,
- cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
- gameMode,
- punchPolicy: parsePunchPolicy(config.punchpolicy),
- punchRadiusMeters: parsePositiveNumber(
- config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
- 5,
- ),
- autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
- 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(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 {
- 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,
- autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
- telemetryConfig: gameConfig.telemetryConfig,
- audioConfig: gameConfig.audioConfig,
- hapticsConfig: gameConfig.hapticsConfig,
- uiEffectsConfig: gameConfig.uiEffectsConfig,
- }
- }
|