| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216 |
- import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
- import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
- import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
- import { mergeTelemetryConfig, type TelemetryConfig } from '../game/telemetry/telemetryConfig'
- import {
- type GameContentExperienceConfigOverride,
- type GameControlDisplayContentOverride,
- } from '../game/core/gameDefinition'
- import {
- mergeGameHapticsConfig,
- mergeGameUiEffectsConfig,
- type FeedbackCueKey,
- type GameHapticsConfig,
- type GameHapticsConfigOverrides,
- type GameUiEffectsConfig,
- type GameUiEffectsConfigOverrides,
- type PartialHapticCueConfig,
- type PartialUiCueConfig,
- } from '../game/feedback/feedbackConfig'
- import {
- DEFAULT_COURSE_STYLE_CONFIG,
- type ControlPointStyleEntry,
- type ControlPointStyleId,
- type CourseLegStyleEntry,
- type CourseLegStyleId,
- type CourseStyleConfig,
- type ScoreBandStyleEntry,
- } from '../game/presentation/courseStyleConfig'
- import {
- DEFAULT_TRACK_VISUALIZATION_CONFIG,
- TRACK_COLOR_PRESET_MAP,
- TRACK_TAIL_LENGTH_METERS,
- type TrackColorPreset,
- type TrackDisplayMode,
- type TrackTailLengthPreset,
- type TrackStyleProfile,
- type TrackVisualizationConfig,
- } from '../game/presentation/trackStyleConfig'
- import {
- DEFAULT_GPS_MARKER_STYLE_CONFIG,
- GPS_MARKER_COLOR_PRESET_MAP,
- type GpsMarkerAnimationProfile,
- type GpsMarkerColorPreset,
- type GpsMarkerSizePreset,
- type GpsMarkerStyleConfig,
- type GpsMarkerStyleId,
- } from '../game/presentation/gpsMarkerStyleConfig'
- import {
- getDefaultSkipRadiusMeters,
- getGameModeDefaults,
- resolveDefaultControlScore,
- } from '../game/core/gameModeDefaults'
- import {
- type SystemSettingsConfig,
- type SettingLockKey,
- type StoredUserSettings,
- } from '../game/core/systemSettingsState'
- export interface TileZoomBounds {
- minX: number
- maxX: number
- minY: number
- maxY: number
- }
- export interface RemoteMapConfig {
- configTitle: string
- configAppId: string
- configSchemaVersion: string
- configVersion: string
- playfieldKind: string
- tileSource: string
- minZoom: number
- maxZoom: number
- defaultZoom: number
- initialCenterTileX: number
- initialCenterTileY: number
- projection: string
- projectionModeText: string
- magneticDeclinationDeg: number
- magneticDeclinationText: string
- tileFormat: string
- tileSize: number
- bounds: [number, number, number, number] | null
- tileBoundsByZoom: Record<number, TileZoomBounds>
- mapMetaUrl: string
- mapRootUrl: string
- courseUrl: string | null
- course: OrienteeringCourseData | null
- courseStatusText: string
- cpRadiusMeters: number
- gameMode: 'classic-sequential' | 'score-o'
- sessionCloseAfterMs: number
- sessionCloseWarningMs: number
- minCompletedControlsBeforeFinish: number
- punchPolicy: 'enter' | 'enter-confirm'
- punchRadiusMeters: number
- requiresFocusSelection: boolean
- skipEnabled: boolean
- skipRadiusMeters: number
- skipRequiresConfirm: boolean
- autoFinishOnLastControl: boolean
- controlScoreOverrides: Record<string, number>
- controlContentOverrides: Record<string, GameControlDisplayContentOverride>
- defaultControlContentOverride: GameControlDisplayContentOverride | null
- defaultControlPointStyleOverride: ControlPointStyleEntry | null
- controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
- defaultLegStyleOverride: CourseLegStyleEntry | null
- legStyleOverrides: Record<number, CourseLegStyleEntry>
- defaultControlScore: number | null
- courseStyleConfig: CourseStyleConfig
- trackStyleConfig: TrackVisualizationConfig
- gpsMarkerStyleConfig: GpsMarkerStyleConfig
- telemetryConfig: TelemetryConfig
- audioConfig: GameAudioConfig
- hapticsConfig: GameHapticsConfig
- uiEffectsConfig: GameUiEffectsConfig
- systemSettingsConfig: SystemSettingsConfig
- }
- interface ParsedGameConfig {
- title: string
- appId: string
- schemaVersion: string
- version: string
- playfieldKind: string
- mapRoot: string
- mapMeta: string
- course: string | null
- cpRadiusMeters: number
- defaultZoom: number | null
- gameMode: 'classic-sequential' | 'score-o'
- sessionCloseAfterMs: number
- sessionCloseWarningMs: number
- minCompletedControlsBeforeFinish: number
- punchPolicy: 'enter' | 'enter-confirm'
- punchRadiusMeters: number
- requiresFocusSelection: boolean
- skipEnabled: boolean
- skipRadiusMeters: number
- skipRequiresConfirm: boolean
- autoFinishOnLastControl: boolean
- controlScoreOverrides: Record<string, number>
- controlContentOverrides: Record<string, GameControlDisplayContentOverride>
- defaultControlContentOverride: GameControlDisplayContentOverride | null
- defaultControlPointStyleOverride: ControlPointStyleEntry | null
- controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
- defaultLegStyleOverride: CourseLegStyleEntry | null
- legStyleOverrides: Record<number, CourseLegStyleEntry>
- defaultControlScore: number | null
- courseStyleConfig: CourseStyleConfig
- trackStyleConfig: TrackVisualizationConfig
- gpsMarkerStyleConfig: GpsMarkerStyleConfig
- telemetryConfig: TelemetryConfig
- audioConfig: GameAudioConfig
- hapticsConfig: GameHapticsConfig
- uiEffectsConfig: GameUiEffectsConfig
- systemSettingsConfig: SystemSettingsConfig
- declinationDeg: number
- }
- interface ParsedMapMeta {
- tileSize: number
- minZoom: number
- maxZoom: number
- projection: string
- tileFormat: string
- tilePathTemplate: string
- bounds: [number, number, number, number] | null
- }
- function requestTextViaRequest(url: string): Promise<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 parseNumber(rawValue: unknown, fallbackValue: number): number {
- const numericValue = Number(rawValue)
- return Number.isFinite(numericValue) ? numericValue : fallbackValue
- }
- function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean {
- if (typeof rawValue === 'boolean') {
- return rawValue
- }
- if (typeof rawValue === 'string') {
- const normalized = rawValue.trim().toLowerCase()
- if (normalized === 'true') {
- return true
- }
- if (normalized === 'false') {
- return false
- }
- }
- return fallbackValue
- }
- function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
- return rawValue === 'enter' ? 'enter' : 'enter-confirm'
- }
- function parseSettingLockKey(rawValue: string): SettingLockKey | null {
- const normalized = rawValue.trim().toLowerCase()
- const table: Record<string, SettingLockKey> = {
- animationlevel: 'lockAnimationLevel',
- trackdisplaymode: 'lockTrackMode',
- trackmode: 'lockTrackMode',
- tracktaillength: 'lockTrackTailLength',
- trackcolorpreset: 'lockTrackColor',
- trackcolor: 'lockTrackColor',
- trackstyleprofile: 'lockTrackStyle',
- trackstyle: 'lockTrackStyle',
- gpsmarkervisible: 'lockGpsMarkerVisible',
- gpsmarkerstyle: 'lockGpsMarkerStyle',
- gpsmarkersize: 'lockGpsMarkerSize',
- gpsmarkercolorpreset: 'lockGpsMarkerColor',
- gpsmarkercolor: 'lockGpsMarkerColor',
- sidebuttonplacement: 'lockSideButtonPlacement',
- autorotateenabled: 'lockAutoRotate',
- autorotate: 'lockAutoRotate',
- compasstuningprofile: 'lockCompassTuning',
- compasstuning: 'lockCompassTuning',
- showcenterscaleruler: 'lockScaleRulerVisible',
- centerscaleruleranchormode: 'lockScaleRulerAnchor',
- centerruleranchor: 'lockScaleRulerAnchor',
- northreferencemode: 'lockNorthReference',
- northreference: 'lockNorthReference',
- heartratedevice: 'lockHeartRateDevice',
- }
- return table[normalized] || null
- }
- function assignParsedSettingValue(
- target: Partial<StoredUserSettings>,
- key: string,
- rawValue: unknown,
- ): void {
- const normalized = key.trim().toLowerCase()
- if (normalized === 'animationlevel') {
- if (rawValue === 'standard' || rawValue === 'lite') {
- target.animationLevel = rawValue
- }
- return
- }
- if (normalized === 'trackdisplaymode' || normalized === 'trackmode') {
- const parsed = parseTrackDisplayMode(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.mode)
- target.trackDisplayMode = parsed
- return
- }
- if (normalized === 'tracktaillength') {
- if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') {
- target.trackTailLength = rawValue
- }
- return
- }
- if (normalized === 'trackcolorpreset' || normalized === 'trackcolor') {
- const parsed = parseTrackColorPreset(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.colorPreset)
- target.trackColorPreset = parsed
- return
- }
- if (normalized === 'trackstyleprofile' || normalized === 'trackstyle') {
- const parsed = parseTrackStyleProfile(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.style)
- target.trackStyleProfile = parsed
- return
- }
- if (normalized === 'gpsmarkervisible') {
- target.gpsMarkerVisible = parseBoolean(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.visible)
- return
- }
- if (normalized === 'gpsmarkerstyle') {
- target.gpsMarkerStyle = parseGpsMarkerStyleId(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.style)
- return
- }
- if (normalized === 'gpsmarkersize') {
- target.gpsMarkerSize = parseGpsMarkerSizePreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.size)
- return
- }
- if (normalized === 'gpsmarkercolorpreset' || normalized === 'gpsmarkercolor') {
- target.gpsMarkerColorPreset = parseGpsMarkerColorPreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.colorPreset)
- return
- }
- if (normalized === 'sidebuttonplacement') {
- if (rawValue === 'left' || rawValue === 'right') {
- target.sideButtonPlacement = rawValue
- }
- return
- }
- if (normalized === 'autorotateenabled' || normalized === 'autorotate') {
- target.autoRotateEnabled = parseBoolean(rawValue, true)
- return
- }
- if (normalized === 'compasstuningprofile' || normalized === 'compasstuning') {
- if (rawValue === 'smooth' || rawValue === 'balanced' || rawValue === 'responsive') {
- target.compassTuningProfile = rawValue
- }
- return
- }
- if (normalized === 'northreferencemode' || normalized === 'northreference') {
- if (rawValue === 'magnetic' || rawValue === 'true') {
- target.northReferenceMode = rawValue
- }
- return
- }
- if (normalized === 'showcenterscaleruler') {
- target.showCenterScaleRuler = parseBoolean(rawValue, false)
- return
- }
- if (normalized === 'centerscaleruleranchormode' || normalized === 'centerruleranchor') {
- if (rawValue === 'screen-center' || rawValue === 'compass-center') {
- target.centerScaleRulerAnchorMode = rawValue
- }
- }
- }
- function parseSystemSettingsConfig(rawValue: unknown): SystemSettingsConfig {
- const normalized = normalizeObjectRecord(rawValue)
- if (!Object.keys(normalized).length) {
- return { values: {}, locks: {} }
- }
- const values: Partial<StoredUserSettings> = {}
- const locks: Partial<Record<SettingLockKey, boolean>> = {}
- for (const [key, entry] of Object.entries(normalized)) {
- const normalizedEntry = normalizeObjectRecord(entry)
- if (Object.keys(normalizedEntry).length) {
- const hasValue = Object.prototype.hasOwnProperty.call(normalizedEntry, 'value')
- const hasLocked = Object.prototype.hasOwnProperty.call(normalizedEntry, 'islocked')
- if (hasValue) {
- assignParsedSettingValue(values, key, normalizedEntry.value)
- }
- if (hasLocked) {
- const lockKey = parseSettingLockKey(key)
- if (lockKey) {
- locks[lockKey] = parseBoolean(normalizedEntry.islocked, false)
- }
- }
- continue
- }
- assignParsedSettingValue(values, key, entry)
- }
- return { values, locks }
- }
- function parseContentExperienceOverride(
- rawValue: unknown,
- baseUrl: string,
- ): GameContentExperienceConfigOverride | undefined {
- const normalized = normalizeObjectRecord(rawValue)
- if (!Object.keys(normalized).length) {
- return undefined
- }
- const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : ''
- if (typeValue === 'native') {
- return {
- type: 'native',
- fallback: 'native',
- presentation: 'sheet',
- }
- }
- if (typeValue !== 'h5') {
- return undefined
- }
- const rawUrl = typeof normalized.url === 'string' ? normalized.url.trim() : ''
- if (!rawUrl) {
- return undefined
- }
- const bridgeValue = typeof normalized.bridge === 'string' && normalized.bridge.trim()
- ? normalized.bridge.trim()
- : 'content-v1'
- const rawPresentation = typeof normalized.presentation === 'string'
- ? normalized.presentation.trim().toLowerCase()
- : ''
- const presentationValue = rawPresentation === 'dialog' || rawPresentation === 'fullscreen'
- ? rawPresentation
- : 'sheet'
- return {
- type: 'h5',
- url: resolveUrl(baseUrl, rawUrl),
- bridge: bridgeValue,
- fallback: 'native',
- presentation: presentationValue,
- }
- }
- function parseControlDisplayContentOverride(
- rawValue: unknown,
- baseUrl: string,
- ): GameControlDisplayContentOverride | null {
- const item = normalizeObjectRecord(rawValue)
- if (!Object.keys(item).length) {
- return null
- }
- const titleValue = typeof item.title === 'string' ? item.title.trim() : ''
- const templateRaw = typeof item.template === 'string' ? item.template.trim().toLowerCase() : ''
- const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus'
- ? templateRaw
- : ''
- const bodyValue = typeof item.body === 'string' ? item.body.trim() : ''
- const clickTitleValue = typeof item.clickTitle === 'string' ? item.clickTitle.trim() : ''
- const clickBodyValue = typeof item.clickBody === 'string' ? item.clickBody.trim() : ''
- const autoPopupValue = item.autoPopup
- const onceValue = item.once
- const priorityNumeric = Number(item.priority)
- const ctasValue = parseContentCardCtas(item.ctas)
- const contentExperienceValue = parseContentExperienceOverride(item.contentExperience, baseUrl)
- const clickExperienceValue = parseContentExperienceOverride(item.clickExperience, baseUrl)
- const hasAutoPopup = typeof autoPopupValue === 'boolean'
- const hasOnce = typeof onceValue === 'boolean'
- const hasPriority = Number.isFinite(priorityNumeric)
- if (
- !templateValue
- && !titleValue
- && !bodyValue
- && !clickTitleValue
- && !clickBodyValue
- && !hasAutoPopup
- && !hasOnce
- && !hasPriority
- && !ctasValue
- && !contentExperienceValue
- && !clickExperienceValue
- ) {
- return null
- }
- const parsed: GameControlDisplayContentOverride = {}
- if (templateValue) {
- parsed.template = templateValue
- }
- if (titleValue) {
- parsed.title = titleValue
- }
- if (bodyValue) {
- parsed.body = bodyValue
- }
- if (clickTitleValue) {
- parsed.clickTitle = clickTitleValue
- }
- if (clickBodyValue) {
- parsed.clickBody = clickBodyValue
- }
- if (hasAutoPopup) {
- parsed.autoPopup = !!autoPopupValue
- }
- if (hasOnce) {
- parsed.once = !!onceValue
- }
- if (hasPriority) {
- parsed.priority = Math.max(0, Math.round(priorityNumeric))
- }
- if (ctasValue) {
- parsed.ctas = ctasValue
- }
- if (contentExperienceValue) {
- parsed.contentExperience = contentExperienceValue
- }
- if (clickExperienceValue) {
- parsed.clickExperience = clickExperienceValue
- }
- return parsed
- }
- function parseControlPointStyleOverride(
- rawValue: unknown,
- fallbackPointStyle: ControlPointStyleEntry,
- ): ControlPointStyleEntry | null {
- const item = normalizeObjectRecord(rawValue)
- if (!Object.keys(item).length) {
- return null
- }
- const rawPointStyle = getFirstDefined(item, ['pointstyle', 'style'])
- const rawPointColor = getFirstDefined(item, ['pointcolorhex', 'pointcolor', 'color', 'colorhex'])
- const rawPointSizeScale = getFirstDefined(item, ['pointsizescale', 'sizescale'])
- const rawPointAccentRingScale = getFirstDefined(item, ['pointaccentringscale', 'accentringscale'])
- const rawPointGlowStrength = getFirstDefined(item, ['pointglowstrength', 'glowstrength'])
- const rawPointLabelScale = getFirstDefined(item, ['pointlabelscale', 'labelscale'])
- const rawPointLabelColor = getFirstDefined(item, ['pointlabelcolorhex', 'pointlabelcolor', 'labelcolor', 'labelcolorhex'])
- if (
- rawPointStyle === undefined
- && rawPointColor === undefined
- && rawPointSizeScale === undefined
- && rawPointAccentRingScale === undefined
- && rawPointGlowStrength === undefined
- && rawPointLabelScale === undefined
- && rawPointLabelColor === undefined
- ) {
- return null
- }
- return {
- style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style),
- colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex),
- sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1),
- accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0),
- glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2),
- labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1),
- labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''),
- }
- }
- function parseLegStyleOverride(
- rawValue: unknown,
- fallbackLegStyle: CourseLegStyleEntry,
- ): CourseLegStyleEntry | null {
- const normalized = normalizeObjectRecord(rawValue)
- const rawStyle = getFirstDefined(normalized, ['style'])
- const rawColor = getFirstDefined(normalized, ['color', 'colorhex'])
- const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
- const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
- if (
- rawStyle === undefined
- && rawColor === undefined
- && rawWidthScale === undefined
- && rawGlowStrength === undefined
- ) {
- return null
- }
- return {
- style: parseCourseLegStyleId(rawStyle, fallbackLegStyle.style),
- colorHex: normalizeHexColor(rawColor, fallbackLegStyle.colorHex),
- widthScale: parsePositiveNumber(rawWidthScale, fallbackLegStyle.widthScale || 1),
- glowStrength: clamp(parseNumber(rawGlowStrength, fallbackLegStyle.glowStrength || 0), 0, 1.2),
- }
- }
- function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
- if (typeof rawValue !== 'string') {
- return 'classic-sequential'
- }
- const normalized = rawValue.trim().toLowerCase()
- if (normalized === 'classic-sequential' || normalized === 'classic' || normalized === 'sequential') {
- return 'classic-sequential'
- }
- if (normalized === 'score-o' || normalized === 'scoreo' || normalized === 'score') {
- return 'score-o'
- }
- throw new Error(`暂不支持的 game.mode: ${rawValue}`)
- }
- function parseTrackDisplayMode(rawValue: unknown, fallbackValue: TrackDisplayMode): TrackDisplayMode {
- if (rawValue === 'none' || rawValue === 'full' || rawValue === 'tail') {
- return rawValue
- }
- if (typeof rawValue === 'string') {
- const normalized = rawValue.trim().toLowerCase()
- if (normalized === 'none' || normalized === 'full' || normalized === 'tail') {
- return normalized
- }
- }
- return fallbackValue
- }
- function parseTrackStyleProfile(rawValue: unknown, fallbackValue: TrackStyleProfile): TrackStyleProfile {
- if (rawValue === 'classic' || rawValue === 'neon') {
- return rawValue
- }
- if (typeof rawValue === 'string') {
- const normalized = rawValue.trim().toLowerCase()
- if (normalized === 'classic' || normalized === 'neon') {
- return normalized
- }
- }
- return fallbackValue
- }
- function parseTrackTailLengthPreset(rawValue: unknown, fallbackValue: TrackTailLengthPreset): TrackTailLengthPreset {
- if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') {
- return rawValue
- }
- if (typeof rawValue === 'string') {
- const normalized = rawValue.trim().toLowerCase()
- if (normalized === 'short' || normalized === 'medium' || normalized === 'long') {
- return normalized
- }
- }
- return fallbackValue
- }
- function parseTrackColorPreset(rawValue: unknown, fallbackValue: TrackColorPreset): TrackColorPreset {
- if (
- rawValue === 'mint'
- || rawValue === 'cyan'
- || rawValue === 'sky'
- || rawValue === 'blue'
- || rawValue === 'violet'
- || rawValue === 'pink'
- || rawValue === 'orange'
- || rawValue === 'yellow'
- ) {
- return rawValue
- }
- if (typeof rawValue === 'string') {
- const normalized = rawValue.trim().toLowerCase()
- if (
- normalized === 'mint'
- || normalized === 'cyan'
- || normalized === 'sky'
- || normalized === 'blue'
- || normalized === 'violet'
- || normalized === 'pink'
- || normalized === 'orange'
- || normalized === 'yellow'
- ) {
- return normalized
- }
- }
- return fallbackValue
- }
- function parseTrackVisualizationConfig(rawValue: unknown): TrackVisualizationConfig {
- const normalized = normalizeObjectRecord(rawValue)
- if (!Object.keys(normalized).length) {
- return DEFAULT_TRACK_VISUALIZATION_CONFIG
- }
- const fallback = DEFAULT_TRACK_VISUALIZATION_CONFIG
- const tailLength = parseTrackTailLengthPreset(getFirstDefined(normalized, ['taillength', 'tailpreset']), fallback.tailLength)
- const colorPreset = parseTrackColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset)
- const presetColors = TRACK_COLOR_PRESET_MAP[colorPreset]
- const rawTailMeters = getFirstDefined(normalized, ['tailmeters'])
- const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex'])
- const rawHeadColorHex = getFirstDefined(normalized, ['headcolor', 'headcolorhex'])
- return {
- mode: parseTrackDisplayMode(getFirstDefined(normalized, ['mode']), fallback.mode),
- style: parseTrackStyleProfile(getFirstDefined(normalized, ['style', 'profile']), fallback.style),
- tailLength,
- colorPreset,
- tailMeters: rawTailMeters !== undefined
- ? parsePositiveNumber(rawTailMeters, TRACK_TAIL_LENGTH_METERS[tailLength])
- : TRACK_TAIL_LENGTH_METERS[tailLength],
- tailMaxSeconds: parsePositiveNumber(getFirstDefined(normalized, ['tailmaxseconds', 'maxseconds']), fallback.tailMaxSeconds),
- fadeOutWhenStill: parseBoolean(getFirstDefined(normalized, ['fadeoutwhenstill', 'fadewhenstill']), fallback.fadeOutWhenStill),
- stillSpeedKmh: parsePositiveNumber(getFirstDefined(normalized, ['stillspeedkmh', 'stillspeed']), fallback.stillSpeedKmh),
- fadeOutDurationMs: parsePositiveNumber(getFirstDefined(normalized, ['fadeoutdurationms', 'fadeoutms']), fallback.fadeOutDurationMs),
- colorHex: normalizeHexColor(rawColorHex, presetColors.colorHex),
- headColorHex: normalizeHexColor(rawHeadColorHex, presetColors.headColorHex),
- widthPx: parsePositiveNumber(getFirstDefined(normalized, ['widthpx', 'width']), fallback.widthPx),
- headWidthPx: parsePositiveNumber(getFirstDefined(normalized, ['headwidthpx', 'headwidth']), fallback.headWidthPx),
- glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallback.glowStrength), 0, 1.5),
- }
- }
- function parseGpsMarkerStyleId(rawValue: unknown, fallbackValue: GpsMarkerStyleId): GpsMarkerStyleId {
- if (rawValue === 'dot' || rawValue === 'beacon' || rawValue === 'disc' || rawValue === 'badge') {
- return rawValue
- }
- if (typeof rawValue === 'string') {
- const normalized = rawValue.trim().toLowerCase()
- if (normalized === 'dot' || normalized === 'beacon' || normalized === 'disc' || normalized === 'badge') {
- return normalized
- }
- }
- return fallbackValue
- }
- function parseGpsMarkerSizePreset(rawValue: unknown, fallbackValue: GpsMarkerSizePreset): GpsMarkerSizePreset {
- if (rawValue === 'small' || rawValue === 'medium' || rawValue === 'large') {
- return rawValue
- }
- if (typeof rawValue === 'string') {
- const normalized = rawValue.trim().toLowerCase()
- if (normalized === 'small' || normalized === 'medium' || normalized === 'large') {
- return normalized
- }
- }
- return fallbackValue
- }
- function parseGpsMarkerColorPreset(rawValue: unknown, fallbackValue: GpsMarkerColorPreset): GpsMarkerColorPreset {
- if (
- rawValue === 'mint'
- || rawValue === 'cyan'
- || rawValue === 'sky'
- || rawValue === 'blue'
- || rawValue === 'violet'
- || rawValue === 'pink'
- || rawValue === 'orange'
- || rawValue === 'yellow'
- ) {
- return rawValue
- }
- if (typeof rawValue === 'string') {
- const normalized = rawValue.trim().toLowerCase()
- if (
- normalized === 'mint'
- || normalized === 'cyan'
- || normalized === 'sky'
- || normalized === 'blue'
- || normalized === 'violet'
- || normalized === 'pink'
- || normalized === 'orange'
- || normalized === 'yellow'
- ) {
- return normalized
- }
- }
- return fallbackValue
- }
- function parseGpsMarkerAnimationProfile(
- rawValue: unknown,
- fallbackValue: GpsMarkerAnimationProfile,
- ): GpsMarkerAnimationProfile {
- if (rawValue === 'minimal' || rawValue === 'dynamic-runner' || rawValue === 'warning-reactive') {
- return rawValue
- }
- return fallbackValue
- }
- function parseGpsMarkerStyleConfig(rawValue: unknown): GpsMarkerStyleConfig {
- const normalized = normalizeObjectRecord(rawValue)
- if (!Object.keys(normalized).length) {
- return DEFAULT_GPS_MARKER_STYLE_CONFIG
- }
- const fallback = DEFAULT_GPS_MARKER_STYLE_CONFIG
- const colorPreset = parseGpsMarkerColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset)
- const presetColors = GPS_MARKER_COLOR_PRESET_MAP[colorPreset]
- const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex'])
- const rawRingColorHex = getFirstDefined(normalized, ['ringcolor', 'ringcolorhex'])
- const rawIndicatorColorHex = getFirstDefined(normalized, ['indicatorcolor', 'indicatorcolorhex'])
- return {
- visible: parseBoolean(getFirstDefined(normalized, ['visible', 'show']), fallback.visible),
- style: parseGpsMarkerStyleId(getFirstDefined(normalized, ['style', 'profile']), fallback.style),
- size: parseGpsMarkerSizePreset(getFirstDefined(normalized, ['size']), fallback.size),
- colorPreset,
- colorHex: typeof rawColorHex === 'string' && rawColorHex.trim() ? rawColorHex.trim() : presetColors.colorHex,
- ringColorHex: typeof rawRingColorHex === 'string' && rawRingColorHex.trim() ? rawRingColorHex.trim() : presetColors.ringColorHex,
- indicatorColorHex: typeof rawIndicatorColorHex === 'string' && rawIndicatorColorHex.trim() ? rawIndicatorColorHex.trim() : presetColors.indicatorColorHex,
- showHeadingIndicator: parseBoolean(getFirstDefined(normalized, ['showheadingindicator', 'showindicator']), fallback.showHeadingIndicator),
- animationProfile: parseGpsMarkerAnimationProfile(
- getFirstDefined(normalized, ['animationprofile', 'motionprofile']),
- fallback.animationProfile,
- ),
- motionState: fallback.motionState,
- motionIntensity: fallback.motionIntensity,
- pulseStrength: fallback.pulseStrength,
- headingAlpha: fallback.headingAlpha,
- effectScale: fallback.effectScale,
- wakeStrength: fallback.wakeStrength,
- warningGlowStrength: fallback.warningGlowStrength,
- indicatorScale: fallback.indicatorScale,
- logoScale: fallback.logoScale,
- logoUrl: typeof getFirstDefined(normalized, ['logourl']) === 'string' ? String(getFirstDefined(normalized, ['logourl'])).trim() : '',
- logoMode: 'center-badge',
- }
- }
- function parseTelemetryConfig(rawValue: unknown): TelemetryConfig {
- const normalized = normalizeObjectRecord(rawValue)
- if (!Object.keys(normalized).length) {
- return mergeTelemetryConfig()
- }
- const rawHeartRate = getFirstDefined(normalized, ['heartrate', 'heart_rate'])
- const normalizedHeartRate = normalizeObjectRecord(rawHeartRate)
- const ageRaw = getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage']) !== undefined
- ? getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage'])
- : getFirstDefined(normalized, ['age', 'userage', 'heartrateage', 'hrage'])
- const restingHeartRateRaw = getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
- !== undefined
- ? getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
- : getFirstDefined(normalized, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
- const userWeightRaw = getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight'])
- !== undefined
- ? getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight'])
- : getFirstDefined(normalized, ['userweightkg', 'weightkg', 'weight'])
- const telemetryOverrides: Partial<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:distant', aliases: ['guidance:distant', 'guidance_distant', 'distant', 'far', 'far_distance'] },
- { key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] },
- { key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] },
- ]
- const cues: GameAudioConfigOverrides['cues'] = {}
- for (const cueDef of cueMap) {
- const cueRaw = getFirstDefined(normalizedCues, cueDef.aliases)
- const cue = buildAudioCueOverride(cueRaw, baseUrl)
- if (cue) {
- cues[cueDef.key] = cue
- }
- }
- return mergeGameAudioConfig({
- enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
- masterVolume: normalized.mastervolume !== undefined
- ? parsePositiveNumber(normalized.mastervolume, 1)
- : normalized.volume !== undefined
- ? parsePositiveNumber(normalized.volume, 1)
- : undefined,
- obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined,
- distantDistanceMeters: normalized.distantdistancemeters !== undefined
- ? parsePositiveNumber(normalized.distantdistancemeters, 80)
- : normalized.distantdistance !== undefined
- ? parsePositiveNumber(normalized.distantdistance, 80)
- : normalized.fardistancemeters !== undefined
- ? parsePositiveNumber(normalized.fardistancemeters, 80)
- : normalized.fardistance !== undefined
- ? parsePositiveNumber(normalized.fardistance, 80)
- : undefined,
- approachDistanceMeters: normalized.approachdistancemeters !== undefined
- ? parsePositiveNumber(normalized.approachdistancemeters, 20)
- : normalized.approachdistance !== undefined
- ? parsePositiveNumber(normalized.approachdistance, 20)
- : undefined,
- readyDistanceMeters: normalized.readydistancemeters !== undefined
- ? parsePositiveNumber(normalized.readydistancemeters, 5)
- : normalized.readydistance !== undefined
- ? parsePositiveNumber(normalized.readydistance, 5)
- : normalized.punchreadydistancemeters !== undefined
- ? parsePositiveNumber(normalized.punchreadydistancemeters, 5)
- : normalized.punchreadydistance !== undefined
- ? parsePositiveNumber(normalized.punchreadydistance, 5)
- : undefined,
- cues,
- })
- }
- function parseLooseJsonObject(text: string): Record<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 normalizeHexColor(rawValue: unknown, fallbackValue: string): string {
- if (typeof rawValue !== 'string') {
- return fallbackValue
- }
- const trimmed = rawValue.trim()
- if (!trimmed) {
- return fallbackValue
- }
- if (/^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{8}$/.test(trimmed)) {
- return trimmed.toLowerCase()
- }
- return fallbackValue
- }
- function parseControlPointStyleId(rawValue: unknown, fallbackValue: ControlPointStyleId): ControlPointStyleId {
- if (rawValue === 'classic-ring' || rawValue === 'solid-dot' || rawValue === 'double-ring' || rawValue === 'badge' || rawValue === 'pulse-core') {
- return rawValue
- }
- return fallbackValue
- }
- function parseCourseLegStyleId(rawValue: unknown, fallbackValue: CourseLegStyleId): CourseLegStyleId {
- if (rawValue === 'classic-leg' || rawValue === 'dashed-leg' || rawValue === 'glow-leg' || rawValue === 'progress-leg') {
- return rawValue
- }
- return fallbackValue
- }
- function parseControlPointStyleEntry(rawValue: unknown, fallbackValue: ControlPointStyleEntry): ControlPointStyleEntry {
- const normalized = normalizeObjectRecord(rawValue)
- const sizeScale = parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackValue.sizeScale || 1)
- const accentRingScale = parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackValue.accentRingScale || 0)
- const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0)
- const labelScale = parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackValue.labelScale || 1)
- return {
- style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style),
- colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex),
- sizeScale,
- accentRingScale,
- glowStrength: clamp(glowStrength, 0, 1.2),
- labelScale,
- labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackValue.labelColorHex || ''),
- }
- }
- function parseCourseLegStyleEntry(rawValue: unknown, fallbackValue: CourseLegStyleEntry): CourseLegStyleEntry {
- const normalized = normalizeObjectRecord(rawValue)
- const widthScale = parsePositiveNumber(getFirstDefined(normalized, ['widthscale']), fallbackValue.widthScale || 1)
- const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0)
- return {
- style: parseCourseLegStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style),
- colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex),
- widthScale,
- glowStrength: clamp(glowStrength, 0, 1.2),
- }
- }
- function parseScoreBandStyleEntries(rawValue: unknown, fallbackValue: ScoreBandStyleEntry[]): ScoreBandStyleEntry[] {
- if (!Array.isArray(rawValue) || !rawValue.length) {
- return fallbackValue
- }
- const parsed: ScoreBandStyleEntry[] = []
- for (let index = 0; index < rawValue.length; index += 1) {
- const item = rawValue[index]
- if (!item || typeof item !== 'object' || Array.isArray(item)) {
- continue
- }
- const normalized = normalizeObjectRecord(item)
- const fallbackItem = fallbackValue[Math.min(index, fallbackValue.length - 1)]
- const minValue = Number(getFirstDefined(normalized, ['min']))
- const maxValue = Number(getFirstDefined(normalized, ['max']))
- parsed.push({
- min: Number.isFinite(minValue) ? Math.round(minValue) : fallbackItem.min,
- max: Number.isFinite(maxValue) ? Math.round(maxValue) : fallbackItem.max,
- style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackItem.style),
- colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackItem.colorHex),
- sizeScale: parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackItem.sizeScale || 1),
- accentRingScale: parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackItem.accentRingScale || 0),
- glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackItem.glowStrength || 0), 0, 1.2),
- labelScale: parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackItem.labelScale || 1),
- labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackItem.labelColorHex || ''),
- })
- }
- return parsed.length ? parsed : fallbackValue
- }
- function parseCourseStyleConfig(rawValue: unknown): CourseStyleConfig {
- const normalized = normalizeObjectRecord(rawValue)
- const sequential = normalizeObjectRecord(getFirstDefined(normalized, ['sequential', 'classicsequential', 'classic']))
- const sequentialControls = normalizeObjectRecord(getFirstDefined(sequential, ['controls']))
- const sequentialLegs = normalizeObjectRecord(getFirstDefined(sequential, ['legs']))
- const scoreO = normalizeObjectRecord(getFirstDefined(normalized, ['scoreo', 'score']))
- const scoreOControls = normalizeObjectRecord(getFirstDefined(scoreO, ['controls']))
- return {
- sequential: {
- controls: {
- default: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default),
- current: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['current', 'active']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.current),
- completed: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.completed),
- skipped: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['skipped']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.skipped),
- start: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.start),
- finish: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.finish),
- },
- legs: {
- default: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default),
- completed: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.completed),
- },
- },
- scoreO: {
- controls: {
- default: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default),
- focused: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['focused', 'active']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.focused),
- collected: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['collected', 'completed']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.collected),
- start: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.start),
- finish: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.finish),
- scoreBands: parseScoreBandStyleEntries(getFirstDefined(scoreOControls, ['scorebands', 'bands']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.scoreBands),
- },
- },
- }
- }
- function parseIndexedLegOverrideKey(rawKey: string): number | null {
- if (typeof rawKey !== 'string') {
- return null
- }
- const normalized = rawKey.trim().toLowerCase()
- const legMatch = normalized.match(/^leg-(\d+)$/)
- if (legMatch) {
- const oneBasedIndex = Number(legMatch[1])
- return Number.isFinite(oneBasedIndex) && oneBasedIndex > 0 ? oneBasedIndex - 1 : null
- }
- const numericIndex = Number(normalized)
- return Number.isFinite(numericIndex) && numericIndex >= 0 ? Math.floor(numericIndex) : null
- }
- function parseContentCardCtas(rawValue: unknown): GameControlDisplayContentOverride['ctas'] | undefined {
- if (!Array.isArray(rawValue)) {
- return undefined
- }
- const parsed = rawValue
- .map((item) => {
- const normalized = normalizeObjectRecord(item)
- if (!Object.keys(normalized).length) {
- return null
- }
- const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : ''
- if (typeValue !== 'detail' && typeValue !== 'photo' && typeValue !== 'audio' && typeValue !== 'quiz') {
- return null
- }
- const labelValue = typeof normalized.label === 'string' ? normalized.label.trim() : ''
- if (typeValue !== 'quiz') {
- return {
- type: typeValue as 'detail' | 'photo' | 'audio',
- ...(labelValue ? { label: labelValue } : {}),
- }
- }
- const quizRaw = {
- ...normalizeObjectRecord(normalized.quiz),
- ...(normalized.bonusScore !== undefined ? { bonusScore: normalized.bonusScore } : {}),
- ...(normalized.countdownSeconds !== undefined ? { countdownSeconds: normalized.countdownSeconds } : {}),
- ...(normalized.minValue !== undefined ? { minValue: normalized.minValue } : {}),
- ...(normalized.maxValue !== undefined ? { maxValue: normalized.maxValue } : {}),
- ...(normalized.allowSubtraction !== undefined ? { allowSubtraction: normalized.allowSubtraction } : {}),
- }
- const minValue = Number(quizRaw.minValue)
- const maxValue = Number(quizRaw.maxValue)
- const countdownSeconds = Number(quizRaw.countdownSeconds)
- const bonusScore = Number(quizRaw.bonusScore)
- return {
- type: 'quiz' as const,
- ...(labelValue ? { label: labelValue } : {}),
- ...(Number.isFinite(minValue) ? { minValue: Math.max(10, Math.round(minValue)) } : {}),
- ...(Number.isFinite(maxValue) ? { maxValue: Math.max(99, Math.round(maxValue)) } : {}),
- ...(typeof quizRaw.allowSubtraction === 'boolean' ? { allowSubtraction: quizRaw.allowSubtraction } : {}),
- ...(Number.isFinite(countdownSeconds) ? { countdownSeconds: Math.max(3, Math.round(countdownSeconds)) } : {}),
- ...(Number.isFinite(bonusScore) ? { bonusScore: Math.max(0, Math.round(bonusScore)) } : {}),
- }
- })
- .filter((item): item is NonNullable<typeof item> => !!item)
- return parsed.length ? parsed : undefined
- }
- function parseStageMotion(rawValue: unknown): 'none' | 'finish' | undefined {
- if (rawValue === 'none' || rawValue === 'finish') {
- return rawValue
- }
- if (typeof rawValue === 'string') {
- const normalized = rawValue.trim().toLowerCase()
- if (normalized === 'none' || normalized === 'finish') {
- return normalized
- }
- }
- return undefined
- }
- function buildHapticsCueOverride(rawValue: unknown): PartialHapticCueConfig | null {
- if (typeof rawValue === 'boolean') {
- return { enabled: rawValue }
- }
- const pattern = parseHapticPattern(rawValue)
- if (pattern) {
- return { enabled: true, pattern }
- }
- const normalized = normalizeObjectRecord(rawValue)
- if (!Object.keys(normalized).length) {
- return null
- }
- const cue: PartialHapticCueConfig = {}
- if (normalized.enabled !== undefined) {
- cue.enabled = parseBoolean(normalized.enabled, true)
- }
- const parsedPattern = parseHapticPattern(getFirstDefined(normalized, ['pattern', 'type']))
- if (parsedPattern) {
- cue.pattern = parsedPattern
- }
- return cue.enabled !== undefined || cue.pattern !== undefined ? cue : null
- }
- function buildUiCueOverride(rawValue: unknown): PartialUiCueConfig | null {
- const normalized = normalizeObjectRecord(rawValue)
- if (!Object.keys(normalized).length) {
- return null
- }
- const cue: PartialUiCueConfig = {}
- if (normalized.enabled !== undefined) {
- cue.enabled = parseBoolean(normalized.enabled, true)
- }
- const punchFeedbackMotion = parsePunchFeedbackMotion(getFirstDefined(normalized, ['punchfeedbackmotion', 'feedbackmotion', 'toastmotion']))
- if (punchFeedbackMotion) {
- cue.punchFeedbackMotion = punchFeedbackMotion
- }
- const contentCardMotion = parseContentCardMotion(getFirstDefined(normalized, ['contentcardmotion', 'cardmotion']))
- if (contentCardMotion) {
- cue.contentCardMotion = contentCardMotion
- }
- const punchButtonMotion = parsePunchButtonMotion(getFirstDefined(normalized, ['punchbuttonmotion', 'buttonmotion']))
- if (punchButtonMotion) {
- cue.punchButtonMotion = punchButtonMotion
- }
- const mapPulseMotion = parseMapPulseMotion(getFirstDefined(normalized, ['mappulsemotion', 'mapmotion']))
- if (mapPulseMotion) {
- cue.mapPulseMotion = mapPulseMotion
- }
- const stageMotion = parseStageMotion(getFirstDefined(normalized, ['stagemotion', 'screenmotion']))
- if (stageMotion) {
- cue.stageMotion = stageMotion
- }
- const durationRaw = getFirstDefined(normalized, ['durationms', 'duration'])
- if (durationRaw !== undefined) {
- cue.durationMs = parsePositiveNumber(durationRaw, 0)
- }
- return cue.enabled !== undefined ||
- cue.punchFeedbackMotion !== undefined ||
- cue.contentCardMotion !== undefined ||
- cue.punchButtonMotion !== undefined ||
- cue.mapPulseMotion !== undefined ||
- cue.stageMotion !== undefined ||
- cue.durationMs !== undefined
- ? cue
- : null
- }
- function parseHapticsConfig(rawValue: unknown): GameHapticsConfig {
- const normalized = normalizeObjectRecord(rawValue)
- if (!Object.keys(normalized).length) {
- return mergeGameHapticsConfig()
- }
- const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
- const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
- { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
- { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
- { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
- { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
- { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
- { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
- { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
- { key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] },
- { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
- { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
- ]
- const cues: GameHapticsConfigOverrides['cues'] = {}
- for (const cueDef of cueMap) {
- const cue = buildHapticsCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
- if (cue) {
- cues[cueDef.key] = cue
- }
- }
- return mergeGameHapticsConfig({
- enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
- cues,
- })
- }
- function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig {
- const normalized = normalizeObjectRecord(rawValue)
- if (!Object.keys(normalized).length) {
- return mergeGameUiEffectsConfig()
- }
- const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
- const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
- { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
- { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
- { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
- { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
- { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
- { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
- { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
- { key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] },
- { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
- { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
- ]
- const cues: GameUiEffectsConfigOverrides['cues'] = {}
- for (const cueDef of cueMap) {
- const cue = buildUiCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
- if (cue) {
- cues[cueDef.key] = cue
- }
- }
- return mergeGameUiEffectsConfig({
- enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
- cues,
- })
- }
- function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGameConfig {
- let parsed: Record<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 rawSettings = parsed.settings && typeof parsed.settings === 'object' && !Array.isArray(parsed.settings)
- ? parsed.settings as Record<string, unknown>
- : null
- const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield)
- ? parsed.playfield as Record<string, unknown>
- : null
- const rawPlayfieldSource = rawPlayfield && rawPlayfield.source && typeof rawPlayfield.source === 'object' && !Array.isArray(rawPlayfield.source)
- ? rawPlayfield.source as Record<string, unknown>
- : null
- const rawGamePresentation = rawGame && rawGame.presentation && typeof rawGame.presentation === 'object' && !Array.isArray(rawGame.presentation)
- ? rawGame.presentation as Record<string, unknown>
- : null
- const normalizedGamePresentation = normalizeObjectRecord(rawGamePresentation)
- 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 rawGameSettings = rawGame && rawGame.settings && typeof rawGame.settings === 'object' && !Array.isArray(rawGame.settings)
- ? rawGame.settings as Record<string, unknown>
- : null
- const mapRoot = rawMap && typeof rawMap.tiles === 'string'
- ? rawMap.tiles
- : typeof normalized.map === 'string'
- ? normalized.map
- : ''
- const mapMeta = rawMap && typeof rawMap.mapmeta === 'string'
- ? rawMap.mapmeta
- : typeof normalized.mapmeta === 'string'
- ? normalized.mapmeta
- : ''
- if (!mapRoot || !mapMeta) {
- throw new Error('game.json 缺少 map 或 mapmeta 字段')
- }
- const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
- const gameMode = parseGameMode(modeValue)
- const modeDefaults = getGameModeDefaults(gameMode)
- const fallbackPointStyle = gameMode === 'score-o'
- ? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default
- : DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default
- const fallbackLegStyle = DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default
- const rawControlDefaults = rawPlayfield && rawPlayfield.controlDefaults && typeof rawPlayfield.controlDefaults === 'object' && !Array.isArray(rawPlayfield.controlDefaults)
- ? rawPlayfield.controlDefaults as Record<string, unknown>
- : null
- const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides)
- ? rawPlayfield.controlOverrides as Record<string, unknown>
- : null
- const defaultControlScoreFromPlayfield = rawControlDefaults
- ? Number(getFirstDefined(normalizeObjectRecord(rawControlDefaults), ['score']))
- : Number.NaN
- const defaultControlContentOverride = parseControlDisplayContentOverride(rawControlDefaults, gameConfigUrl)
- const defaultControlPointStyleOverride = parseControlPointStyleOverride(rawControlDefaults, fallbackPointStyle)
- const controlScoreOverrides: Record<string, number> = {}
- const controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {}
- const controlPointStyleOverrides: Record<string, ControlPointStyleEntry> = {}
- 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
- }
- const styleOverride = parseControlPointStyleOverride(item, fallbackPointStyle)
- if (styleOverride) {
- controlPointStyleOverrides[key] = styleOverride
- }
- const contentOverride = parseControlDisplayContentOverride(item, gameConfigUrl)
- if (contentOverride) {
- controlContentOverrides[key] = contentOverride
- }
- }
- }
- const rawLegDefaults = rawPlayfield && rawPlayfield.legDefaults && typeof rawPlayfield.legDefaults === 'object' && !Array.isArray(rawPlayfield.legDefaults)
- ? rawPlayfield.legDefaults as Record<string, unknown>
- : null
- const defaultLegStyleOverride = parseLegStyleOverride(rawLegDefaults, fallbackLegStyle)
- const rawLegOverrides = rawPlayfield && rawPlayfield.legOverrides && typeof rawPlayfield.legOverrides === 'object' && !Array.isArray(rawPlayfield.legOverrides)
- ? rawPlayfield.legOverrides as Record<string, unknown>
- : null
- const legStyleOverrides: Record<number, CourseLegStyleEntry> = {}
- if (rawLegOverrides) {
- const legKeys = Object.keys(rawLegOverrides)
- for (const rawKey of legKeys) {
- const item = rawLegOverrides[rawKey]
- const index = parseIndexedLegOverrideKey(rawKey)
- if (index === null || !item || typeof item !== 'object' || Array.isArray(item)) {
- continue
- }
- const legOverride = parseLegStyleOverride(item, fallbackLegStyle)
- if (!legOverride) {
- continue
- }
- legStyleOverrides[index] = legOverride
- }
- }
- const punchRadiusMeters = parsePositiveNumber(
- rawPunch && rawPunch.radiusMeters !== undefined
- ? rawPunch.radiusMeters
- : normalizedGame.punchradiusmeters !== undefined
- ? normalizedGame.punchradiusmeters
- : normalizedGame.punchradius !== undefined
- ? normalizedGame.punchradius
- : normalized.punchradiusmeters !== undefined
- ? normalized.punchradiusmeters
- : normalized.punchradius,
- 5,
- )
- const sessionCloseAfterMs = parsePositiveNumber(
- rawSession && rawSession.closeAfterMs !== undefined
- ? rawSession.closeAfterMs
- : rawSession && rawSession.sessionCloseAfterMs !== undefined
- ? rawSession.sessionCloseAfterMs
- : normalizedGame.sessioncloseafterms !== undefined
- ? normalizedGame.sessioncloseafterms
- : normalized.sessioncloseafterms,
- modeDefaults.sessionCloseAfterMs,
- )
- const sessionCloseWarningMs = parsePositiveNumber(
- rawSession && rawSession.closeWarningMs !== undefined
- ? rawSession.closeWarningMs
- : rawSession && rawSession.sessionCloseWarningMs !== undefined
- ? rawSession.sessionCloseWarningMs
- : normalizedGame.sessionclosewarningms !== undefined
- ? normalizedGame.sessionclosewarningms
- : normalized.sessionclosewarningms,
- modeDefaults.sessionCloseWarningMs,
- )
- const minCompletedControlsBeforeFinish = Math.max(0, Math.floor(parseNumber(
- rawSession && rawSession.minCompletedControlsBeforeFinish !== undefined
- ? rawSession.minCompletedControlsBeforeFinish
- : rawSession && rawSession.minControlsBeforeFinish !== undefined
- ? rawSession.minControlsBeforeFinish
- : normalizedGame.mincompletedcontrolsbeforefinish !== undefined
- ? normalizedGame.mincompletedcontrolsbeforefinish
- : normalizedGame.mincontrolsbeforefinish !== undefined
- ? normalizedGame.mincontrolsbeforefinish
- : normalized.mincompletedcontrolsbeforefinish !== undefined
- ? normalized.mincompletedcontrolsbeforefinish
- : normalized.mincontrolsbeforefinish,
- modeDefaults.minCompletedControlsBeforeFinish,
- )))
- const requiresFocusSelection = parseBoolean(
- rawPunch && rawPunch.requiresFocusSelection !== undefined
- ? rawPunch.requiresFocusSelection
- : normalizedGame.requiresfocusselection !== undefined
- ? normalizedGame.requiresfocusselection
- : rawPunch && (rawPunch as Record<string, unknown>).requiresfocusselection !== undefined
- ? (rawPunch as Record<string, unknown>).requiresfocusselection
- : normalized.requiresfocusselection,
- modeDefaults.requiresFocusSelection,
- )
- const skipEnabled = parseBoolean(
- rawSkip && rawSkip.enabled !== undefined
- ? rawSkip.enabled
- : normalizedGame.skipenabled !== undefined
- ? normalizedGame.skipenabled
- : normalized.skipenabled,
- modeDefaults.skipEnabled,
- )
- const skipRadiusMeters = parsePositiveNumber(
- rawSkip && rawSkip.radiusMeters !== undefined
- ? rawSkip.radiusMeters
- : normalizedGame.skipradiusmeters !== undefined
- ? normalizedGame.skipradiusmeters
- : normalizedGame.skipradius !== undefined
- ? normalizedGame.skipradius
- : normalized.skipradiusmeters !== undefined
- ? normalized.skipradiusmeters
- : normalized.skipradius,
- getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters),
- )
- const autoFinishOnLastControl = parseBoolean(
- rawSession && rawSession.autoFinishOnLastControl !== undefined
- ? rawSession.autoFinishOnLastControl
- : normalizedGame.autofinishonlastcontrol !== undefined
- ? normalizedGame.autofinishonlastcontrol
- : normalized.autofinishonlastcontrol,
- modeDefaults.autoFinishOnLastControl,
- )
- return {
- title: rawApp && typeof rawApp.title === 'string' ? rawApp.title : '',
- appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '',
- schemaVersion: typeof parsed.schemaVersion === 'string' ? parsed.schemaVersion : '1',
- version: typeof parsed.version === 'string' ? parsed.version : '',
- playfieldKind: rawPlayfield && typeof rawPlayfield.kind === 'string' ? rawPlayfield.kind : '',
- mapRoot,
- mapMeta,
- course: rawPlayfieldSource && typeof rawPlayfieldSource.url === 'string'
- ? rawPlayfieldSource.url
- : typeof normalized.course === 'string'
- ? normalized.course
- : null,
- cpRadiusMeters: parsePositiveNumber(
- rawPlayfield && rawPlayfield.CPRadius !== undefined ? rawPlayfield.CPRadius : normalized.cpradius,
- 5,
- ),
- defaultZoom: rawMap && rawMap.initialView && typeof rawMap.initialView === 'object' && !Array.isArray(rawMap.initialView)
- ? parsePositiveNumber((rawMap.initialView as Record<string, unknown>).zoom, 17)
- : null,
- gameMode,
- sessionCloseAfterMs,
- sessionCloseWarningMs,
- minCompletedControlsBeforeFinish,
- punchPolicy: parsePunchPolicy(
- rawPunch && rawPunch.policy !== undefined
- ? rawPunch.policy
- : normalizedGame.punchpolicy !== undefined
- ? normalizedGame.punchpolicy
- : normalized.punchpolicy,
- ),
- punchRadiusMeters,
- requiresFocusSelection,
- skipEnabled,
- skipRadiusMeters,
- skipRequiresConfirm: parseBoolean(
- rawSkip && rawSkip.requiresConfirm !== undefined
- ? rawSkip.requiresConfirm
- : normalizedGame.skiprequiresconfirm !== undefined
- ? normalizedGame.skiprequiresconfirm
- : normalized.skiprequiresconfirm,
- modeDefaults.skipRequiresConfirm,
- ),
- autoFinishOnLastControl,
- controlScoreOverrides,
- controlContentOverrides,
- defaultControlContentOverride,
- defaultControlPointStyleOverride,
- controlPointStyleOverrides,
- defaultLegStyleOverride,
- legStyleOverrides,
- defaultControlScore: resolveDefaultControlScore(
- gameMode,
- Number.isFinite(defaultControlScoreFromPlayfield)
- ? defaultControlScoreFromPlayfield
- : rawScoring && rawScoring.defaultControlScore !== undefined
- ? parsePositiveNumber(rawScoring.defaultControlScore, modeDefaults.defaultControlScore)
- : null,
- ),
- courseStyleConfig: parseCourseStyleConfig(rawGamePresentation),
- trackStyleConfig: parseTrackVisualizationConfig(getFirstDefined(normalizedGamePresentation, ['track'])),
- gpsMarkerStyleConfig: parseGpsMarkerStyleConfig(getFirstDefined(normalizedGamePresentation, ['gpsmarker', 'gps'])),
- telemetryConfig: parseTelemetryConfig(rawTelemetry),
- audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
- hapticsConfig: parseHapticsConfig(rawHaptics),
- uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
- systemSettingsConfig: parseSystemSettingsConfig(rawGameSettings || rawSettings),
- declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination),
- }
- }
- function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGameConfig {
- const config: Record<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)
- const modeDefaults = getGameModeDefaults(gameMode)
- const punchRadiusMeters = parsePositiveNumber(
- config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
- 5,
- )
- return {
- title: '',
- appId: '',
- schemaVersion: '1',
- version: '',
- playfieldKind: typeof config.playfieldkind === 'string' ? config.playfieldkind : '',
- mapRoot,
- mapMeta,
- course: typeof config.course === 'string' ? config.course : null,
- cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
- defaultZoom: null,
- gameMode,
- sessionCloseAfterMs: modeDefaults.sessionCloseAfterMs,
- sessionCloseWarningMs: modeDefaults.sessionCloseWarningMs,
- minCompletedControlsBeforeFinish: modeDefaults.minCompletedControlsBeforeFinish,
- punchPolicy: parsePunchPolicy(config.punchpolicy),
- punchRadiusMeters,
- requiresFocusSelection: parseBoolean(config.requiresfocusselection, modeDefaults.requiresFocusSelection),
- skipEnabled: parseBoolean(config.skipenabled, modeDefaults.skipEnabled),
- skipRadiusMeters: parsePositiveNumber(
- config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius,
- getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters),
- ),
- skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, modeDefaults.skipRequiresConfirm),
- autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, modeDefaults.autoFinishOnLastControl),
- controlScoreOverrides: {},
- controlContentOverrides: {},
- defaultControlContentOverride: null,
- defaultControlPointStyleOverride: null,
- controlPointStyleOverrides: {},
- defaultLegStyleOverride: null,
- legStyleOverrides: {},
- defaultControlScore: modeDefaults.defaultControlScore,
- courseStyleConfig: DEFAULT_COURSE_STYLE_CONFIG,
- trackStyleConfig: DEFAULT_TRACK_VISUALIZATION_CONFIG,
- gpsMarkerStyleConfig: DEFAULT_GPS_MARKER_STYLE_CONFIG,
- telemetryConfig: parseTelemetryConfig({
- heartRate: {
- age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage,
- restingHeartRateBpm: config.restingheartratebpm !== undefined
- ? config.restingheartratebpm
- : config.restingheartrate !== undefined
- ? config.restingheartrate
- : config.telemetryrestingheartratebpm !== undefined
- ? config.telemetryrestingheartratebpm
- : config.telemetryrestingheartrate,
- },
- }),
- audioConfig: parseAudioConfig({
- enabled: config.audioenabled,
- masterVolume: config.audiomastervolume,
- obeyMuteSwitch: config.audioobeymuteswitch,
- distantDistanceMeters: config.audiodistantdistancemeters !== undefined
- ? config.audiodistantdistancemeters
- : config.audiodistantdistance !== undefined
- ? config.audiodistantdistance
- : config.audiofardistancemeters !== undefined
- ? config.audiofardistancemeters
- : config.audiofardistance,
- approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance,
- readyDistanceMeters: config.audioreadydistancemeters !== undefined
- ? config.audioreadydistancemeters
- : config.audioreadydistance !== undefined
- ? config.audioreadydistance
- : config.audiopunchreadydistancemeters !== undefined
- ? config.audiopunchreadydistancemeters
- : config.audiopunchreadydistance,
- cues: {
- session_started: config.audiosessionstarted,
- 'control_completed:start': config.audiostartcomplete,
- 'control_completed:control': config.audiocontrolcomplete,
- 'control_completed:finish': config.audiofinishcomplete,
- 'punch_feedback:warning': config.audiowarning,
- 'guidance:searching': config.audiosearching,
- 'guidance:distant': config.audiodistant,
- 'guidance:approaching': config.audioapproaching,
- 'guidance:ready': config.audioready,
- },
- }, gameConfigUrl),
- hapticsConfig: parseHapticsConfig({
- enabled: config.hapticsenabled,
- cues: {
- session_started: config.hapticsstart,
- session_finished: config.hapticsfinish,
- 'control_completed:start': config.hapticsstartcomplete,
- 'control_completed:control': config.hapticscontrolcomplete,
- 'control_completed:finish': config.hapticsfinishcomplete,
- 'punch_feedback:warning': config.hapticswarning,
- 'guidance:searching': config.hapticssearching,
- 'guidance:distant': config.hapticsdistant,
- 'guidance:approaching': config.hapticsapproaching,
- 'guidance:ready': config.hapticsready,
- },
- }),
- uiEffectsConfig: parseUiEffectsConfig({
- enabled: config.uieffectsenabled,
- cues: {
- session_started: { enabled: config.uistartenabled, punchButtonMotion: config.uistartbuttonmotion },
- session_finished: { enabled: config.uifinishenabled, contentCardMotion: config.uifinishcardmotion },
- 'control_completed:start': { enabled: config.uistartcompleteenabled, contentCardMotion: config.uistartcompletecardmotion, punchFeedbackMotion: config.uistartcompletetoastmotion },
- 'control_completed:control': { enabled: config.uicontrolcompleteenabled, contentCardMotion: config.uicontrolcompletecardmotion, punchFeedbackMotion: config.uicontrolcompletetoastmotion },
- 'control_completed:finish': { enabled: config.uifinishcompleteenabled, contentCardMotion: config.uifinishcompletecardmotion, punchFeedbackMotion: config.uifinishcompletetoastmotion },
- 'punch_feedback:warning': { enabled: config.uiwarningenabled, punchFeedbackMotion: config.uiwarningtoastmotion, punchButtonMotion: config.uiwarningbuttonmotion, durationMs: config.uiwarningdurationms },
- 'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms },
- },
- }),
- systemSettingsConfig: { values: {}, locks: {} },
- declinationDeg: parseDeclinationValue(config.declination),
- }
- }
- function parseGameConfig(text: string, gameConfigUrl: string): ParsedGameConfig {
- const trimmedText = text.trim()
- const isJson =
- trimmedText.startsWith('{') ||
- trimmedText.startsWith('[') ||
- /\.json(?:[?#].*)?$/i.test(gameConfigUrl)
- return isJson ? parseGameConfigFromJson(trimmedText, gameConfigUrl) : parseGameConfigFromYaml(trimmedText, gameConfigUrl)
- }
- function extractStringField(text: string, key: string): string | null {
- const pattern = new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)
- const match = text.match(pattern)
- return match ? match[1] : null
- }
- function extractNumberField(text: string, key: string): number | null {
- const pattern = new RegExp(`"${key}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`)
- const match = text.match(pattern)
- if (!match) {
- return null
- }
- const value = Number(match[1])
- return Number.isFinite(value) ? value : null
- }
- function extractNumberArrayField(text: string, key: string): number[] | null {
- const pattern = new RegExp(`"${key}"\\s*:\\s*\\[([^\\]]+)\\]`)
- const match = text.match(pattern)
- if (!match) {
- return null
- }
- const numberMatches = match[1].match(/-?\d+(?:\.\d+)?/g)
- if (!numberMatches || !numberMatches.length) {
- return null
- }
- const values = numberMatches
- .map((item) => Number(item))
- .filter((item) => Number.isFinite(item))
- return values.length ? values : null
- }
- function parseMapMeta(text: string): ParsedMapMeta {
- const tileSizeField = extractNumberField(text, 'tileSize')
- const tileSize = tileSizeField === null ? 256 : tileSizeField
- const minZoom = extractNumberField(text, 'minZoom')
- const maxZoom = extractNumberField(text, 'maxZoom')
- const projectionField = extractStringField(text, 'projection')
- const projection = projectionField === null ? 'EPSG:3857' : projectionField
- const tilePathTemplate = extractStringField(text, 'tilePathTemplate')
- const tileFormatFromField = extractStringField(text, 'tileFormat')
- const boundsValues = extractNumberArrayField(text, 'bounds')
- if (!Number.isFinite(minZoom) || !Number.isFinite(maxZoom) || !tilePathTemplate) {
- throw new Error('meta.json 缺少必要字段')
- }
- let tileFormat = tileFormatFromField || ''
- if (!tileFormat) {
- const extensionMatch = tilePathTemplate.match(/\.([A-Za-z0-9]+)$/)
- tileFormat = extensionMatch ? extensionMatch[1].toLowerCase() : 'png'
- }
- return {
- tileSize,
- minZoom: minZoom as number,
- maxZoom: maxZoom as number,
- projection,
- tileFormat,
- tilePathTemplate,
- bounds: boundsValues && boundsValues.length >= 4
- ? [boundsValues[0], boundsValues[1], boundsValues[2], boundsValues[3]]
- : null,
- }
- }
- function getBoundsCorners(
- bounds: [number, number, number, number],
- projection: string,
- ): { northWest: LonLatPoint; southEast: LonLatPoint; center: LonLatPoint } {
- if (projection === 'EPSG:3857') {
- const minX = bounds[0]
- const minY = bounds[1]
- const maxX = bounds[2]
- const maxY = bounds[3]
- return {
- northWest: webMercatorToLonLat({ x: minX, y: maxY }),
- southEast: webMercatorToLonLat({ x: maxX, y: minY }),
- center: webMercatorToLonLat({ x: (minX + maxX) / 2, y: (minY + maxY) / 2 }),
- }
- }
- if (projection === 'EPSG:4326') {
- const minLon = bounds[0]
- const minLat = bounds[1]
- const maxLon = bounds[2]
- const maxLat = bounds[3]
- return {
- northWest: { lon: minLon, lat: maxLat },
- southEast: { lon: maxLon, lat: minLat },
- center: { lon: (minLon + maxLon) / 2, lat: (minLat + maxLat) / 2 },
- }
- }
- throw new Error(`暂不支持的投影: ${projection}`)
- }
- function buildTileBoundsByZoom(
- bounds: [number, number, number, number] | null,
- projection: string,
- minZoom: number,
- maxZoom: number,
- ): Record<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 || '',
- playfieldKind: gameConfig.playfieldKind || '',
- tileSource: resolveUrl(mapRootUrl, mapMeta.tilePathTemplate),
- minZoom: mapMeta.minZoom,
- maxZoom: mapMeta.maxZoom,
- defaultZoom,
- initialCenterTileX: Math.round(centerWorldTile.x),
- initialCenterTileY: Math.round(centerWorldTile.y),
- projection: mapMeta.projection,
- projectionModeText: getProjectionModeText(mapMeta.projection),
- magneticDeclinationDeg: gameConfig.declinationDeg,
- magneticDeclinationText: formatDeclinationText(gameConfig.declinationDeg),
- tileFormat: mapMeta.tileFormat,
- tileSize: mapMeta.tileSize,
- bounds: mapMeta.bounds,
- tileBoundsByZoom: buildTileBoundsByZoom(mapMeta.bounds, mapMeta.projection, mapMeta.minZoom, mapMeta.maxZoom),
- mapMetaUrl,
- mapRootUrl,
- courseUrl,
- course,
- courseStatusText,
- cpRadiusMeters: gameConfig.cpRadiusMeters,
- gameMode: gameConfig.gameMode,
- sessionCloseAfterMs: gameConfig.sessionCloseAfterMs,
- sessionCloseWarningMs: gameConfig.sessionCloseWarningMs,
- minCompletedControlsBeforeFinish: gameConfig.minCompletedControlsBeforeFinish,
- punchPolicy: gameConfig.punchPolicy,
- punchRadiusMeters: gameConfig.punchRadiusMeters,
- requiresFocusSelection: gameConfig.requiresFocusSelection,
- skipEnabled: gameConfig.skipEnabled,
- skipRadiusMeters: gameConfig.skipRadiusMeters,
- skipRequiresConfirm: gameConfig.skipRequiresConfirm,
- autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
- controlScoreOverrides: gameConfig.controlScoreOverrides,
- controlContentOverrides: gameConfig.controlContentOverrides,
- defaultControlContentOverride: gameConfig.defaultControlContentOverride,
- defaultControlPointStyleOverride: gameConfig.defaultControlPointStyleOverride,
- controlPointStyleOverrides: gameConfig.controlPointStyleOverrides,
- defaultLegStyleOverride: gameConfig.defaultLegStyleOverride,
- legStyleOverrides: gameConfig.legStyleOverrides,
- defaultControlScore: gameConfig.defaultControlScore,
- courseStyleConfig: gameConfig.courseStyleConfig,
- trackStyleConfig: gameConfig.trackStyleConfig,
- gpsMarkerStyleConfig: gameConfig.gpsMarkerStyleConfig,
- telemetryConfig: gameConfig.telemetryConfig,
- audioConfig: gameConfig.audioConfig,
- hapticsConfig: gameConfig.hapticsConfig,
- uiEffectsConfig: gameConfig.uiEffectsConfig,
- systemSettingsConfig: gameConfig.systemSettingsConfig,
- }
- }
|