remoteMapConfig.ts 87 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216
  1. import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
  2. import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
  3. import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
  4. import { mergeTelemetryConfig, type TelemetryConfig } from '../game/telemetry/telemetryConfig'
  5. import {
  6. type GameContentExperienceConfigOverride,
  7. type GameControlDisplayContentOverride,
  8. } from '../game/core/gameDefinition'
  9. import {
  10. mergeGameHapticsConfig,
  11. mergeGameUiEffectsConfig,
  12. type FeedbackCueKey,
  13. type GameHapticsConfig,
  14. type GameHapticsConfigOverrides,
  15. type GameUiEffectsConfig,
  16. type GameUiEffectsConfigOverrides,
  17. type PartialHapticCueConfig,
  18. type PartialUiCueConfig,
  19. } from '../game/feedback/feedbackConfig'
  20. import {
  21. DEFAULT_COURSE_STYLE_CONFIG,
  22. type ControlPointStyleEntry,
  23. type ControlPointStyleId,
  24. type CourseLegStyleEntry,
  25. type CourseLegStyleId,
  26. type CourseStyleConfig,
  27. type ScoreBandStyleEntry,
  28. } from '../game/presentation/courseStyleConfig'
  29. import {
  30. DEFAULT_TRACK_VISUALIZATION_CONFIG,
  31. TRACK_COLOR_PRESET_MAP,
  32. TRACK_TAIL_LENGTH_METERS,
  33. type TrackColorPreset,
  34. type TrackDisplayMode,
  35. type TrackTailLengthPreset,
  36. type TrackStyleProfile,
  37. type TrackVisualizationConfig,
  38. } from '../game/presentation/trackStyleConfig'
  39. import {
  40. DEFAULT_GPS_MARKER_STYLE_CONFIG,
  41. GPS_MARKER_COLOR_PRESET_MAP,
  42. type GpsMarkerAnimationProfile,
  43. type GpsMarkerColorPreset,
  44. type GpsMarkerSizePreset,
  45. type GpsMarkerStyleConfig,
  46. type GpsMarkerStyleId,
  47. } from '../game/presentation/gpsMarkerStyleConfig'
  48. import {
  49. getDefaultSkipRadiusMeters,
  50. getGameModeDefaults,
  51. resolveDefaultControlScore,
  52. } from '../game/core/gameModeDefaults'
  53. import {
  54. type SystemSettingsConfig,
  55. type SettingLockKey,
  56. type StoredUserSettings,
  57. } from '../game/core/systemSettingsState'
  58. export interface TileZoomBounds {
  59. minX: number
  60. maxX: number
  61. minY: number
  62. maxY: number
  63. }
  64. export interface RemoteMapConfig {
  65. configTitle: string
  66. configAppId: string
  67. configSchemaVersion: string
  68. configVersion: string
  69. playfieldKind: string
  70. tileSource: string
  71. minZoom: number
  72. maxZoom: number
  73. defaultZoom: number
  74. initialCenterTileX: number
  75. initialCenterTileY: number
  76. projection: string
  77. projectionModeText: string
  78. magneticDeclinationDeg: number
  79. magneticDeclinationText: string
  80. tileFormat: string
  81. tileSize: number
  82. bounds: [number, number, number, number] | null
  83. tileBoundsByZoom: Record<number, TileZoomBounds>
  84. mapMetaUrl: string
  85. mapRootUrl: string
  86. courseUrl: string | null
  87. course: OrienteeringCourseData | null
  88. courseStatusText: string
  89. cpRadiusMeters: number
  90. gameMode: 'classic-sequential' | 'score-o'
  91. sessionCloseAfterMs: number
  92. sessionCloseWarningMs: number
  93. minCompletedControlsBeforeFinish: number
  94. punchPolicy: 'enter' | 'enter-confirm'
  95. punchRadiusMeters: number
  96. requiresFocusSelection: boolean
  97. skipEnabled: boolean
  98. skipRadiusMeters: number
  99. skipRequiresConfirm: boolean
  100. autoFinishOnLastControl: boolean
  101. controlScoreOverrides: Record<string, number>
  102. controlContentOverrides: Record<string, GameControlDisplayContentOverride>
  103. defaultControlContentOverride: GameControlDisplayContentOverride | null
  104. defaultControlPointStyleOverride: ControlPointStyleEntry | null
  105. controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
  106. defaultLegStyleOverride: CourseLegStyleEntry | null
  107. legStyleOverrides: Record<number, CourseLegStyleEntry>
  108. defaultControlScore: number | null
  109. courseStyleConfig: CourseStyleConfig
  110. trackStyleConfig: TrackVisualizationConfig
  111. gpsMarkerStyleConfig: GpsMarkerStyleConfig
  112. telemetryConfig: TelemetryConfig
  113. audioConfig: GameAudioConfig
  114. hapticsConfig: GameHapticsConfig
  115. uiEffectsConfig: GameUiEffectsConfig
  116. systemSettingsConfig: SystemSettingsConfig
  117. }
  118. interface ParsedGameConfig {
  119. title: string
  120. appId: string
  121. schemaVersion: string
  122. version: string
  123. playfieldKind: string
  124. mapRoot: string
  125. mapMeta: string
  126. course: string | null
  127. cpRadiusMeters: number
  128. defaultZoom: number | null
  129. gameMode: 'classic-sequential' | 'score-o'
  130. sessionCloseAfterMs: number
  131. sessionCloseWarningMs: number
  132. minCompletedControlsBeforeFinish: number
  133. punchPolicy: 'enter' | 'enter-confirm'
  134. punchRadiusMeters: number
  135. requiresFocusSelection: boolean
  136. skipEnabled: boolean
  137. skipRadiusMeters: number
  138. skipRequiresConfirm: boolean
  139. autoFinishOnLastControl: boolean
  140. controlScoreOverrides: Record<string, number>
  141. controlContentOverrides: Record<string, GameControlDisplayContentOverride>
  142. defaultControlContentOverride: GameControlDisplayContentOverride | null
  143. defaultControlPointStyleOverride: ControlPointStyleEntry | null
  144. controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
  145. defaultLegStyleOverride: CourseLegStyleEntry | null
  146. legStyleOverrides: Record<number, CourseLegStyleEntry>
  147. defaultControlScore: number | null
  148. courseStyleConfig: CourseStyleConfig
  149. trackStyleConfig: TrackVisualizationConfig
  150. gpsMarkerStyleConfig: GpsMarkerStyleConfig
  151. telemetryConfig: TelemetryConfig
  152. audioConfig: GameAudioConfig
  153. hapticsConfig: GameHapticsConfig
  154. uiEffectsConfig: GameUiEffectsConfig
  155. systemSettingsConfig: SystemSettingsConfig
  156. declinationDeg: number
  157. }
  158. interface ParsedMapMeta {
  159. tileSize: number
  160. minZoom: number
  161. maxZoom: number
  162. projection: string
  163. tileFormat: string
  164. tilePathTemplate: string
  165. bounds: [number, number, number, number] | null
  166. }
  167. function requestTextViaRequest(url: string): Promise<string> {
  168. return new Promise((resolve, reject) => {
  169. wx.request({
  170. url,
  171. method: 'GET',
  172. responseType: 'text' as any,
  173. success: (response) => {
  174. if (response.statusCode !== 200) {
  175. reject(new Error(`request失败: ${response.statusCode} ${url}`))
  176. return
  177. }
  178. if (typeof response.data === 'string') {
  179. resolve(response.data)
  180. return
  181. }
  182. resolve(JSON.stringify(response.data))
  183. },
  184. fail: () => {
  185. reject(new Error(`request失败: ${url}`))
  186. },
  187. })
  188. })
  189. }
  190. function requestTextViaDownload(url: string): Promise<string> {
  191. return new Promise((resolve, reject) => {
  192. const fileSystemManager = wx.getFileSystemManager()
  193. wx.downloadFile({
  194. url,
  195. success: (response) => {
  196. if (response.statusCode !== 200 || !response.tempFilePath) {
  197. reject(new Error(`download失败: ${response.statusCode} ${url}`))
  198. return
  199. }
  200. fileSystemManager.readFile({
  201. filePath: response.tempFilePath,
  202. encoding: 'utf8',
  203. success: (readResult) => {
  204. if (typeof readResult.data === 'string') {
  205. resolve(readResult.data)
  206. return
  207. }
  208. reject(new Error(`read失败: ${url}`))
  209. },
  210. fail: () => {
  211. reject(new Error(`read失败: ${url}`))
  212. },
  213. })
  214. },
  215. fail: () => {
  216. reject(new Error(`download失败: ${url}`))
  217. },
  218. })
  219. })
  220. }
  221. async function requestText(url: string): Promise<string> {
  222. try {
  223. return await requestTextViaRequest(url)
  224. } catch (requestError) {
  225. try {
  226. return await requestTextViaDownload(url)
  227. } catch (downloadError) {
  228. const requestMessage = requestError instanceof Error ? requestError.message : 'request失败'
  229. const downloadMessage = downloadError instanceof Error ? downloadError.message : 'download失败'
  230. throw new Error(`${requestMessage}; ${downloadMessage}`)
  231. }
  232. }
  233. }
  234. function clamp(value: number, min: number, max: number): number {
  235. return Math.max(min, Math.min(max, value))
  236. }
  237. function resolveUrl(baseUrl: string, relativePath: string): string {
  238. if (/^https?:\/\//i.test(relativePath)) {
  239. return relativePath
  240. }
  241. const originMatch = baseUrl.match(/^(https?:\/\/[^/]+)/i)
  242. const origin = originMatch ? originMatch[1] : ''
  243. if (relativePath.startsWith('/')) {
  244. return `${origin}${relativePath}`
  245. }
  246. const baseDir = baseUrl.slice(0, baseUrl.lastIndexOf('/') + 1)
  247. const normalizedRelativePath = relativePath.replace(/^\.\//, '')
  248. return `${baseDir}${normalizedRelativePath}`
  249. }
  250. function formatDeclinationText(declinationDeg: number): string {
  251. const suffix = declinationDeg < 0 ? 'W' : 'E'
  252. return `${Math.abs(declinationDeg).toFixed(2)}° ${suffix}`
  253. }
  254. function parseDeclinationValue(rawValue: unknown): number {
  255. const numericValue = Number(rawValue)
  256. return Number.isFinite(numericValue) ? -Math.abs(numericValue) : -6.91
  257. }
  258. function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number {
  259. const numericValue = Number(rawValue)
  260. return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
  261. }
  262. function parseNumber(rawValue: unknown, fallbackValue: number): number {
  263. const numericValue = Number(rawValue)
  264. return Number.isFinite(numericValue) ? numericValue : fallbackValue
  265. }
  266. function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean {
  267. if (typeof rawValue === 'boolean') {
  268. return rawValue
  269. }
  270. if (typeof rawValue === 'string') {
  271. const normalized = rawValue.trim().toLowerCase()
  272. if (normalized === 'true') {
  273. return true
  274. }
  275. if (normalized === 'false') {
  276. return false
  277. }
  278. }
  279. return fallbackValue
  280. }
  281. function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
  282. return rawValue === 'enter' ? 'enter' : 'enter-confirm'
  283. }
  284. function parseSettingLockKey(rawValue: string): SettingLockKey | null {
  285. const normalized = rawValue.trim().toLowerCase()
  286. const table: Record<string, SettingLockKey> = {
  287. animationlevel: 'lockAnimationLevel',
  288. trackdisplaymode: 'lockTrackMode',
  289. trackmode: 'lockTrackMode',
  290. tracktaillength: 'lockTrackTailLength',
  291. trackcolorpreset: 'lockTrackColor',
  292. trackcolor: 'lockTrackColor',
  293. trackstyleprofile: 'lockTrackStyle',
  294. trackstyle: 'lockTrackStyle',
  295. gpsmarkervisible: 'lockGpsMarkerVisible',
  296. gpsmarkerstyle: 'lockGpsMarkerStyle',
  297. gpsmarkersize: 'lockGpsMarkerSize',
  298. gpsmarkercolorpreset: 'lockGpsMarkerColor',
  299. gpsmarkercolor: 'lockGpsMarkerColor',
  300. sidebuttonplacement: 'lockSideButtonPlacement',
  301. autorotateenabled: 'lockAutoRotate',
  302. autorotate: 'lockAutoRotate',
  303. compasstuningprofile: 'lockCompassTuning',
  304. compasstuning: 'lockCompassTuning',
  305. showcenterscaleruler: 'lockScaleRulerVisible',
  306. centerscaleruleranchormode: 'lockScaleRulerAnchor',
  307. centerruleranchor: 'lockScaleRulerAnchor',
  308. northreferencemode: 'lockNorthReference',
  309. northreference: 'lockNorthReference',
  310. heartratedevice: 'lockHeartRateDevice',
  311. }
  312. return table[normalized] || null
  313. }
  314. function assignParsedSettingValue(
  315. target: Partial<StoredUserSettings>,
  316. key: string,
  317. rawValue: unknown,
  318. ): void {
  319. const normalized = key.trim().toLowerCase()
  320. if (normalized === 'animationlevel') {
  321. if (rawValue === 'standard' || rawValue === 'lite') {
  322. target.animationLevel = rawValue
  323. }
  324. return
  325. }
  326. if (normalized === 'trackdisplaymode' || normalized === 'trackmode') {
  327. const parsed = parseTrackDisplayMode(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.mode)
  328. target.trackDisplayMode = parsed
  329. return
  330. }
  331. if (normalized === 'tracktaillength') {
  332. if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') {
  333. target.trackTailLength = rawValue
  334. }
  335. return
  336. }
  337. if (normalized === 'trackcolorpreset' || normalized === 'trackcolor') {
  338. const parsed = parseTrackColorPreset(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.colorPreset)
  339. target.trackColorPreset = parsed
  340. return
  341. }
  342. if (normalized === 'trackstyleprofile' || normalized === 'trackstyle') {
  343. const parsed = parseTrackStyleProfile(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.style)
  344. target.trackStyleProfile = parsed
  345. return
  346. }
  347. if (normalized === 'gpsmarkervisible') {
  348. target.gpsMarkerVisible = parseBoolean(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.visible)
  349. return
  350. }
  351. if (normalized === 'gpsmarkerstyle') {
  352. target.gpsMarkerStyle = parseGpsMarkerStyleId(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.style)
  353. return
  354. }
  355. if (normalized === 'gpsmarkersize') {
  356. target.gpsMarkerSize = parseGpsMarkerSizePreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.size)
  357. return
  358. }
  359. if (normalized === 'gpsmarkercolorpreset' || normalized === 'gpsmarkercolor') {
  360. target.gpsMarkerColorPreset = parseGpsMarkerColorPreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.colorPreset)
  361. return
  362. }
  363. if (normalized === 'sidebuttonplacement') {
  364. if (rawValue === 'left' || rawValue === 'right') {
  365. target.sideButtonPlacement = rawValue
  366. }
  367. return
  368. }
  369. if (normalized === 'autorotateenabled' || normalized === 'autorotate') {
  370. target.autoRotateEnabled = parseBoolean(rawValue, true)
  371. return
  372. }
  373. if (normalized === 'compasstuningprofile' || normalized === 'compasstuning') {
  374. if (rawValue === 'smooth' || rawValue === 'balanced' || rawValue === 'responsive') {
  375. target.compassTuningProfile = rawValue
  376. }
  377. return
  378. }
  379. if (normalized === 'northreferencemode' || normalized === 'northreference') {
  380. if (rawValue === 'magnetic' || rawValue === 'true') {
  381. target.northReferenceMode = rawValue
  382. }
  383. return
  384. }
  385. if (normalized === 'showcenterscaleruler') {
  386. target.showCenterScaleRuler = parseBoolean(rawValue, false)
  387. return
  388. }
  389. if (normalized === 'centerscaleruleranchormode' || normalized === 'centerruleranchor') {
  390. if (rawValue === 'screen-center' || rawValue === 'compass-center') {
  391. target.centerScaleRulerAnchorMode = rawValue
  392. }
  393. }
  394. }
  395. function parseSystemSettingsConfig(rawValue: unknown): SystemSettingsConfig {
  396. const normalized = normalizeObjectRecord(rawValue)
  397. if (!Object.keys(normalized).length) {
  398. return { values: {}, locks: {} }
  399. }
  400. const values: Partial<StoredUserSettings> = {}
  401. const locks: Partial<Record<SettingLockKey, boolean>> = {}
  402. for (const [key, entry] of Object.entries(normalized)) {
  403. const normalizedEntry = normalizeObjectRecord(entry)
  404. if (Object.keys(normalizedEntry).length) {
  405. const hasValue = Object.prototype.hasOwnProperty.call(normalizedEntry, 'value')
  406. const hasLocked = Object.prototype.hasOwnProperty.call(normalizedEntry, 'islocked')
  407. if (hasValue) {
  408. assignParsedSettingValue(values, key, normalizedEntry.value)
  409. }
  410. if (hasLocked) {
  411. const lockKey = parseSettingLockKey(key)
  412. if (lockKey) {
  413. locks[lockKey] = parseBoolean(normalizedEntry.islocked, false)
  414. }
  415. }
  416. continue
  417. }
  418. assignParsedSettingValue(values, key, entry)
  419. }
  420. return { values, locks }
  421. }
  422. function parseContentExperienceOverride(
  423. rawValue: unknown,
  424. baseUrl: string,
  425. ): GameContentExperienceConfigOverride | undefined {
  426. const normalized = normalizeObjectRecord(rawValue)
  427. if (!Object.keys(normalized).length) {
  428. return undefined
  429. }
  430. const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : ''
  431. if (typeValue === 'native') {
  432. return {
  433. type: 'native',
  434. fallback: 'native',
  435. presentation: 'sheet',
  436. }
  437. }
  438. if (typeValue !== 'h5') {
  439. return undefined
  440. }
  441. const rawUrl = typeof normalized.url === 'string' ? normalized.url.trim() : ''
  442. if (!rawUrl) {
  443. return undefined
  444. }
  445. const bridgeValue = typeof normalized.bridge === 'string' && normalized.bridge.trim()
  446. ? normalized.bridge.trim()
  447. : 'content-v1'
  448. const rawPresentation = typeof normalized.presentation === 'string'
  449. ? normalized.presentation.trim().toLowerCase()
  450. : ''
  451. const presentationValue = rawPresentation === 'dialog' || rawPresentation === 'fullscreen'
  452. ? rawPresentation
  453. : 'sheet'
  454. return {
  455. type: 'h5',
  456. url: resolveUrl(baseUrl, rawUrl),
  457. bridge: bridgeValue,
  458. fallback: 'native',
  459. presentation: presentationValue,
  460. }
  461. }
  462. function parseControlDisplayContentOverride(
  463. rawValue: unknown,
  464. baseUrl: string,
  465. ): GameControlDisplayContentOverride | null {
  466. const item = normalizeObjectRecord(rawValue)
  467. if (!Object.keys(item).length) {
  468. return null
  469. }
  470. const titleValue = typeof item.title === 'string' ? item.title.trim() : ''
  471. const templateRaw = typeof item.template === 'string' ? item.template.trim().toLowerCase() : ''
  472. const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus'
  473. ? templateRaw
  474. : ''
  475. const bodyValue = typeof item.body === 'string' ? item.body.trim() : ''
  476. const clickTitleValue = typeof item.clickTitle === 'string' ? item.clickTitle.trim() : ''
  477. const clickBodyValue = typeof item.clickBody === 'string' ? item.clickBody.trim() : ''
  478. const autoPopupValue = item.autoPopup
  479. const onceValue = item.once
  480. const priorityNumeric = Number(item.priority)
  481. const ctasValue = parseContentCardCtas(item.ctas)
  482. const contentExperienceValue = parseContentExperienceOverride(item.contentExperience, baseUrl)
  483. const clickExperienceValue = parseContentExperienceOverride(item.clickExperience, baseUrl)
  484. const hasAutoPopup = typeof autoPopupValue === 'boolean'
  485. const hasOnce = typeof onceValue === 'boolean'
  486. const hasPriority = Number.isFinite(priorityNumeric)
  487. if (
  488. !templateValue
  489. && !titleValue
  490. && !bodyValue
  491. && !clickTitleValue
  492. && !clickBodyValue
  493. && !hasAutoPopup
  494. && !hasOnce
  495. && !hasPriority
  496. && !ctasValue
  497. && !contentExperienceValue
  498. && !clickExperienceValue
  499. ) {
  500. return null
  501. }
  502. const parsed: GameControlDisplayContentOverride = {}
  503. if (templateValue) {
  504. parsed.template = templateValue
  505. }
  506. if (titleValue) {
  507. parsed.title = titleValue
  508. }
  509. if (bodyValue) {
  510. parsed.body = bodyValue
  511. }
  512. if (clickTitleValue) {
  513. parsed.clickTitle = clickTitleValue
  514. }
  515. if (clickBodyValue) {
  516. parsed.clickBody = clickBodyValue
  517. }
  518. if (hasAutoPopup) {
  519. parsed.autoPopup = !!autoPopupValue
  520. }
  521. if (hasOnce) {
  522. parsed.once = !!onceValue
  523. }
  524. if (hasPriority) {
  525. parsed.priority = Math.max(0, Math.round(priorityNumeric))
  526. }
  527. if (ctasValue) {
  528. parsed.ctas = ctasValue
  529. }
  530. if (contentExperienceValue) {
  531. parsed.contentExperience = contentExperienceValue
  532. }
  533. if (clickExperienceValue) {
  534. parsed.clickExperience = clickExperienceValue
  535. }
  536. return parsed
  537. }
  538. function parseControlPointStyleOverride(
  539. rawValue: unknown,
  540. fallbackPointStyle: ControlPointStyleEntry,
  541. ): ControlPointStyleEntry | null {
  542. const item = normalizeObjectRecord(rawValue)
  543. if (!Object.keys(item).length) {
  544. return null
  545. }
  546. const rawPointStyle = getFirstDefined(item, ['pointstyle', 'style'])
  547. const rawPointColor = getFirstDefined(item, ['pointcolorhex', 'pointcolor', 'color', 'colorhex'])
  548. const rawPointSizeScale = getFirstDefined(item, ['pointsizescale', 'sizescale'])
  549. const rawPointAccentRingScale = getFirstDefined(item, ['pointaccentringscale', 'accentringscale'])
  550. const rawPointGlowStrength = getFirstDefined(item, ['pointglowstrength', 'glowstrength'])
  551. const rawPointLabelScale = getFirstDefined(item, ['pointlabelscale', 'labelscale'])
  552. const rawPointLabelColor = getFirstDefined(item, ['pointlabelcolorhex', 'pointlabelcolor', 'labelcolor', 'labelcolorhex'])
  553. if (
  554. rawPointStyle === undefined
  555. && rawPointColor === undefined
  556. && rawPointSizeScale === undefined
  557. && rawPointAccentRingScale === undefined
  558. && rawPointGlowStrength === undefined
  559. && rawPointLabelScale === undefined
  560. && rawPointLabelColor === undefined
  561. ) {
  562. return null
  563. }
  564. return {
  565. style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style),
  566. colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex),
  567. sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1),
  568. accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0),
  569. glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2),
  570. labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1),
  571. labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''),
  572. }
  573. }
  574. function parseLegStyleOverride(
  575. rawValue: unknown,
  576. fallbackLegStyle: CourseLegStyleEntry,
  577. ): CourseLegStyleEntry | null {
  578. const normalized = normalizeObjectRecord(rawValue)
  579. const rawStyle = getFirstDefined(normalized, ['style'])
  580. const rawColor = getFirstDefined(normalized, ['color', 'colorhex'])
  581. const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
  582. const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
  583. if (
  584. rawStyle === undefined
  585. && rawColor === undefined
  586. && rawWidthScale === undefined
  587. && rawGlowStrength === undefined
  588. ) {
  589. return null
  590. }
  591. return {
  592. style: parseCourseLegStyleId(rawStyle, fallbackLegStyle.style),
  593. colorHex: normalizeHexColor(rawColor, fallbackLegStyle.colorHex),
  594. widthScale: parsePositiveNumber(rawWidthScale, fallbackLegStyle.widthScale || 1),
  595. glowStrength: clamp(parseNumber(rawGlowStrength, fallbackLegStyle.glowStrength || 0), 0, 1.2),
  596. }
  597. }
  598. function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
  599. if (typeof rawValue !== 'string') {
  600. return 'classic-sequential'
  601. }
  602. const normalized = rawValue.trim().toLowerCase()
  603. if (normalized === 'classic-sequential' || normalized === 'classic' || normalized === 'sequential') {
  604. return 'classic-sequential'
  605. }
  606. if (normalized === 'score-o' || normalized === 'scoreo' || normalized === 'score') {
  607. return 'score-o'
  608. }
  609. throw new Error(`暂不支持的 game.mode: ${rawValue}`)
  610. }
  611. function parseTrackDisplayMode(rawValue: unknown, fallbackValue: TrackDisplayMode): TrackDisplayMode {
  612. if (rawValue === 'none' || rawValue === 'full' || rawValue === 'tail') {
  613. return rawValue
  614. }
  615. if (typeof rawValue === 'string') {
  616. const normalized = rawValue.trim().toLowerCase()
  617. if (normalized === 'none' || normalized === 'full' || normalized === 'tail') {
  618. return normalized
  619. }
  620. }
  621. return fallbackValue
  622. }
  623. function parseTrackStyleProfile(rawValue: unknown, fallbackValue: TrackStyleProfile): TrackStyleProfile {
  624. if (rawValue === 'classic' || rawValue === 'neon') {
  625. return rawValue
  626. }
  627. if (typeof rawValue === 'string') {
  628. const normalized = rawValue.trim().toLowerCase()
  629. if (normalized === 'classic' || normalized === 'neon') {
  630. return normalized
  631. }
  632. }
  633. return fallbackValue
  634. }
  635. function parseTrackTailLengthPreset(rawValue: unknown, fallbackValue: TrackTailLengthPreset): TrackTailLengthPreset {
  636. if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') {
  637. return rawValue
  638. }
  639. if (typeof rawValue === 'string') {
  640. const normalized = rawValue.trim().toLowerCase()
  641. if (normalized === 'short' || normalized === 'medium' || normalized === 'long') {
  642. return normalized
  643. }
  644. }
  645. return fallbackValue
  646. }
  647. function parseTrackColorPreset(rawValue: unknown, fallbackValue: TrackColorPreset): TrackColorPreset {
  648. if (
  649. rawValue === 'mint'
  650. || rawValue === 'cyan'
  651. || rawValue === 'sky'
  652. || rawValue === 'blue'
  653. || rawValue === 'violet'
  654. || rawValue === 'pink'
  655. || rawValue === 'orange'
  656. || rawValue === 'yellow'
  657. ) {
  658. return rawValue
  659. }
  660. if (typeof rawValue === 'string') {
  661. const normalized = rawValue.trim().toLowerCase()
  662. if (
  663. normalized === 'mint'
  664. || normalized === 'cyan'
  665. || normalized === 'sky'
  666. || normalized === 'blue'
  667. || normalized === 'violet'
  668. || normalized === 'pink'
  669. || normalized === 'orange'
  670. || normalized === 'yellow'
  671. ) {
  672. return normalized
  673. }
  674. }
  675. return fallbackValue
  676. }
  677. function parseTrackVisualizationConfig(rawValue: unknown): TrackVisualizationConfig {
  678. const normalized = normalizeObjectRecord(rawValue)
  679. if (!Object.keys(normalized).length) {
  680. return DEFAULT_TRACK_VISUALIZATION_CONFIG
  681. }
  682. const fallback = DEFAULT_TRACK_VISUALIZATION_CONFIG
  683. const tailLength = parseTrackTailLengthPreset(getFirstDefined(normalized, ['taillength', 'tailpreset']), fallback.tailLength)
  684. const colorPreset = parseTrackColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset)
  685. const presetColors = TRACK_COLOR_PRESET_MAP[colorPreset]
  686. const rawTailMeters = getFirstDefined(normalized, ['tailmeters'])
  687. const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex'])
  688. const rawHeadColorHex = getFirstDefined(normalized, ['headcolor', 'headcolorhex'])
  689. return {
  690. mode: parseTrackDisplayMode(getFirstDefined(normalized, ['mode']), fallback.mode),
  691. style: parseTrackStyleProfile(getFirstDefined(normalized, ['style', 'profile']), fallback.style),
  692. tailLength,
  693. colorPreset,
  694. tailMeters: rawTailMeters !== undefined
  695. ? parsePositiveNumber(rawTailMeters, TRACK_TAIL_LENGTH_METERS[tailLength])
  696. : TRACK_TAIL_LENGTH_METERS[tailLength],
  697. tailMaxSeconds: parsePositiveNumber(getFirstDefined(normalized, ['tailmaxseconds', 'maxseconds']), fallback.tailMaxSeconds),
  698. fadeOutWhenStill: parseBoolean(getFirstDefined(normalized, ['fadeoutwhenstill', 'fadewhenstill']), fallback.fadeOutWhenStill),
  699. stillSpeedKmh: parsePositiveNumber(getFirstDefined(normalized, ['stillspeedkmh', 'stillspeed']), fallback.stillSpeedKmh),
  700. fadeOutDurationMs: parsePositiveNumber(getFirstDefined(normalized, ['fadeoutdurationms', 'fadeoutms']), fallback.fadeOutDurationMs),
  701. colorHex: normalizeHexColor(rawColorHex, presetColors.colorHex),
  702. headColorHex: normalizeHexColor(rawHeadColorHex, presetColors.headColorHex),
  703. widthPx: parsePositiveNumber(getFirstDefined(normalized, ['widthpx', 'width']), fallback.widthPx),
  704. headWidthPx: parsePositiveNumber(getFirstDefined(normalized, ['headwidthpx', 'headwidth']), fallback.headWidthPx),
  705. glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallback.glowStrength), 0, 1.5),
  706. }
  707. }
  708. function parseGpsMarkerStyleId(rawValue: unknown, fallbackValue: GpsMarkerStyleId): GpsMarkerStyleId {
  709. if (rawValue === 'dot' || rawValue === 'beacon' || rawValue === 'disc' || rawValue === 'badge') {
  710. return rawValue
  711. }
  712. if (typeof rawValue === 'string') {
  713. const normalized = rawValue.trim().toLowerCase()
  714. if (normalized === 'dot' || normalized === 'beacon' || normalized === 'disc' || normalized === 'badge') {
  715. return normalized
  716. }
  717. }
  718. return fallbackValue
  719. }
  720. function parseGpsMarkerSizePreset(rawValue: unknown, fallbackValue: GpsMarkerSizePreset): GpsMarkerSizePreset {
  721. if (rawValue === 'small' || rawValue === 'medium' || rawValue === 'large') {
  722. return rawValue
  723. }
  724. if (typeof rawValue === 'string') {
  725. const normalized = rawValue.trim().toLowerCase()
  726. if (normalized === 'small' || normalized === 'medium' || normalized === 'large') {
  727. return normalized
  728. }
  729. }
  730. return fallbackValue
  731. }
  732. function parseGpsMarkerColorPreset(rawValue: unknown, fallbackValue: GpsMarkerColorPreset): GpsMarkerColorPreset {
  733. if (
  734. rawValue === 'mint'
  735. || rawValue === 'cyan'
  736. || rawValue === 'sky'
  737. || rawValue === 'blue'
  738. || rawValue === 'violet'
  739. || rawValue === 'pink'
  740. || rawValue === 'orange'
  741. || rawValue === 'yellow'
  742. ) {
  743. return rawValue
  744. }
  745. if (typeof rawValue === 'string') {
  746. const normalized = rawValue.trim().toLowerCase()
  747. if (
  748. normalized === 'mint'
  749. || normalized === 'cyan'
  750. || normalized === 'sky'
  751. || normalized === 'blue'
  752. || normalized === 'violet'
  753. || normalized === 'pink'
  754. || normalized === 'orange'
  755. || normalized === 'yellow'
  756. ) {
  757. return normalized
  758. }
  759. }
  760. return fallbackValue
  761. }
  762. function parseGpsMarkerAnimationProfile(
  763. rawValue: unknown,
  764. fallbackValue: GpsMarkerAnimationProfile,
  765. ): GpsMarkerAnimationProfile {
  766. if (rawValue === 'minimal' || rawValue === 'dynamic-runner' || rawValue === 'warning-reactive') {
  767. return rawValue
  768. }
  769. return fallbackValue
  770. }
  771. function parseGpsMarkerStyleConfig(rawValue: unknown): GpsMarkerStyleConfig {
  772. const normalized = normalizeObjectRecord(rawValue)
  773. if (!Object.keys(normalized).length) {
  774. return DEFAULT_GPS_MARKER_STYLE_CONFIG
  775. }
  776. const fallback = DEFAULT_GPS_MARKER_STYLE_CONFIG
  777. const colorPreset = parseGpsMarkerColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset)
  778. const presetColors = GPS_MARKER_COLOR_PRESET_MAP[colorPreset]
  779. const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex'])
  780. const rawRingColorHex = getFirstDefined(normalized, ['ringcolor', 'ringcolorhex'])
  781. const rawIndicatorColorHex = getFirstDefined(normalized, ['indicatorcolor', 'indicatorcolorhex'])
  782. return {
  783. visible: parseBoolean(getFirstDefined(normalized, ['visible', 'show']), fallback.visible),
  784. style: parseGpsMarkerStyleId(getFirstDefined(normalized, ['style', 'profile']), fallback.style),
  785. size: parseGpsMarkerSizePreset(getFirstDefined(normalized, ['size']), fallback.size),
  786. colorPreset,
  787. colorHex: typeof rawColorHex === 'string' && rawColorHex.trim() ? rawColorHex.trim() : presetColors.colorHex,
  788. ringColorHex: typeof rawRingColorHex === 'string' && rawRingColorHex.trim() ? rawRingColorHex.trim() : presetColors.ringColorHex,
  789. indicatorColorHex: typeof rawIndicatorColorHex === 'string' && rawIndicatorColorHex.trim() ? rawIndicatorColorHex.trim() : presetColors.indicatorColorHex,
  790. showHeadingIndicator: parseBoolean(getFirstDefined(normalized, ['showheadingindicator', 'showindicator']), fallback.showHeadingIndicator),
  791. animationProfile: parseGpsMarkerAnimationProfile(
  792. getFirstDefined(normalized, ['animationprofile', 'motionprofile']),
  793. fallback.animationProfile,
  794. ),
  795. motionState: fallback.motionState,
  796. motionIntensity: fallback.motionIntensity,
  797. pulseStrength: fallback.pulseStrength,
  798. headingAlpha: fallback.headingAlpha,
  799. effectScale: fallback.effectScale,
  800. wakeStrength: fallback.wakeStrength,
  801. warningGlowStrength: fallback.warningGlowStrength,
  802. indicatorScale: fallback.indicatorScale,
  803. logoScale: fallback.logoScale,
  804. logoUrl: typeof getFirstDefined(normalized, ['logourl']) === 'string' ? String(getFirstDefined(normalized, ['logourl'])).trim() : '',
  805. logoMode: 'center-badge',
  806. }
  807. }
  808. function parseTelemetryConfig(rawValue: unknown): TelemetryConfig {
  809. const normalized = normalizeObjectRecord(rawValue)
  810. if (!Object.keys(normalized).length) {
  811. return mergeTelemetryConfig()
  812. }
  813. const rawHeartRate = getFirstDefined(normalized, ['heartrate', 'heart_rate'])
  814. const normalizedHeartRate = normalizeObjectRecord(rawHeartRate)
  815. const ageRaw = getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage']) !== undefined
  816. ? getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage'])
  817. : getFirstDefined(normalized, ['age', 'userage', 'heartrateage', 'hrage'])
  818. const restingHeartRateRaw = getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
  819. !== undefined
  820. ? getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
  821. : getFirstDefined(normalized, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
  822. const userWeightRaw = getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight'])
  823. !== undefined
  824. ? getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight'])
  825. : getFirstDefined(normalized, ['userweightkg', 'weightkg', 'weight'])
  826. const telemetryOverrides: Partial<TelemetryConfig> = {}
  827. if (ageRaw !== undefined) {
  828. telemetryOverrides.heartRateAge = Number(ageRaw)
  829. }
  830. if (restingHeartRateRaw !== undefined) {
  831. telemetryOverrides.restingHeartRateBpm = Number(restingHeartRateRaw)
  832. }
  833. if (userWeightRaw !== undefined) {
  834. telemetryOverrides.userWeightKg = Number(userWeightRaw)
  835. }
  836. return mergeTelemetryConfig(telemetryOverrides)
  837. }
  838. function normalizeObjectRecord(rawValue: unknown): Record<string, unknown> {
  839. if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
  840. return {}
  841. }
  842. const normalized: Record<string, unknown> = {}
  843. const keys = Object.keys(rawValue as Record<string, unknown>)
  844. for (const key of keys) {
  845. normalized[key.toLowerCase()] = (rawValue as Record<string, unknown>)[key]
  846. }
  847. return normalized
  848. }
  849. function getFirstDefined(record: Record<string, unknown>, keys: string[]): unknown {
  850. for (const key of keys) {
  851. if (record[key] !== undefined) {
  852. return record[key]
  853. }
  854. }
  855. return undefined
  856. }
  857. function resolveAudioSrc(baseUrl: string, rawValue: unknown): string | undefined {
  858. if (typeof rawValue !== 'string') {
  859. return undefined
  860. }
  861. const trimmed = rawValue.trim()
  862. if (!trimmed) {
  863. return undefined
  864. }
  865. if (/^https?:\/\//i.test(trimmed)) {
  866. return trimmed
  867. }
  868. if (trimmed.startsWith('/assets/')) {
  869. return trimmed
  870. }
  871. if (trimmed.startsWith('assets/')) {
  872. return `/${trimmed}`
  873. }
  874. return resolveUrl(baseUrl, trimmed)
  875. }
  876. function buildAudioCueOverride(rawValue: unknown, baseUrl: string): PartialAudioCueConfig | null {
  877. if (typeof rawValue === 'string') {
  878. const src = resolveAudioSrc(baseUrl, rawValue)
  879. return src ? { src } : null
  880. }
  881. const normalized = normalizeObjectRecord(rawValue)
  882. if (!Object.keys(normalized).length) {
  883. return null
  884. }
  885. const src = resolveAudioSrc(baseUrl, getFirstDefined(normalized, ['src', 'url', 'path']))
  886. const volumeRaw = getFirstDefined(normalized, ['volume'])
  887. const loopRaw = getFirstDefined(normalized, ['loop'])
  888. const loopGapRaw = getFirstDefined(normalized, ['loopgapms', 'loopgap'])
  889. const cue: PartialAudioCueConfig = {}
  890. if (src) {
  891. cue.src = src
  892. }
  893. if (volumeRaw !== undefined) {
  894. cue.volume = parsePositiveNumber(volumeRaw, 1)
  895. }
  896. if (loopRaw !== undefined) {
  897. cue.loop = parseBoolean(loopRaw, false)
  898. }
  899. if (loopGapRaw !== undefined) {
  900. cue.loopGapMs = parsePositiveNumber(loopGapRaw, 0)
  901. }
  902. return cue.src || cue.volume !== undefined || cue.loop !== undefined || cue.loopGapMs !== undefined ? cue : null
  903. }
  904. function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
  905. const normalized = normalizeObjectRecord(rawValue)
  906. if (!Object.keys(normalized).length) {
  907. return mergeGameAudioConfig()
  908. }
  909. const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
  910. const cueMap: Array<{ key: AudioCueKey; aliases: string[] }> = [
  911. { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start', 'session_start'] },
  912. { key: 'control_completed:start', aliases: ['control_completed:start', 'controlcompleted:start', 'start_completed', 'startcomplete', 'start-complete'] },
  913. { key: 'control_completed:control', aliases: ['control_completed:control', 'controlcompleted:control', 'control_completed', 'controlcompleted', 'control_complete', 'controlcomplete'] },
  914. { key: 'control_completed:finish', aliases: ['control_completed:finish', 'controlcompleted:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
  915. { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'punchfeedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
  916. { key: 'guidance:searching', aliases: ['guidance:searching', 'guidance_searching', 'searching', 'search', 'normal_search'] },
  917. { key: 'guidance:distant', aliases: ['guidance:distant', 'guidance_distant', 'distant', 'far', 'far_distance'] },
  918. { key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] },
  919. { key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] },
  920. ]
  921. const cues: GameAudioConfigOverrides['cues'] = {}
  922. for (const cueDef of cueMap) {
  923. const cueRaw = getFirstDefined(normalizedCues, cueDef.aliases)
  924. const cue = buildAudioCueOverride(cueRaw, baseUrl)
  925. if (cue) {
  926. cues[cueDef.key] = cue
  927. }
  928. }
  929. return mergeGameAudioConfig({
  930. enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
  931. masterVolume: normalized.mastervolume !== undefined
  932. ? parsePositiveNumber(normalized.mastervolume, 1)
  933. : normalized.volume !== undefined
  934. ? parsePositiveNumber(normalized.volume, 1)
  935. : undefined,
  936. obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined,
  937. distantDistanceMeters: normalized.distantdistancemeters !== undefined
  938. ? parsePositiveNumber(normalized.distantdistancemeters, 80)
  939. : normalized.distantdistance !== undefined
  940. ? parsePositiveNumber(normalized.distantdistance, 80)
  941. : normalized.fardistancemeters !== undefined
  942. ? parsePositiveNumber(normalized.fardistancemeters, 80)
  943. : normalized.fardistance !== undefined
  944. ? parsePositiveNumber(normalized.fardistance, 80)
  945. : undefined,
  946. approachDistanceMeters: normalized.approachdistancemeters !== undefined
  947. ? parsePositiveNumber(normalized.approachdistancemeters, 20)
  948. : normalized.approachdistance !== undefined
  949. ? parsePositiveNumber(normalized.approachdistance, 20)
  950. : undefined,
  951. readyDistanceMeters: normalized.readydistancemeters !== undefined
  952. ? parsePositiveNumber(normalized.readydistancemeters, 5)
  953. : normalized.readydistance !== undefined
  954. ? parsePositiveNumber(normalized.readydistance, 5)
  955. : normalized.punchreadydistancemeters !== undefined
  956. ? parsePositiveNumber(normalized.punchreadydistancemeters, 5)
  957. : normalized.punchreadydistance !== undefined
  958. ? parsePositiveNumber(normalized.punchreadydistance, 5)
  959. : undefined,
  960. cues,
  961. })
  962. }
  963. function parseLooseJsonObject(text: string): Record<string, unknown> {
  964. const parsed: Record<string, unknown> = {}
  965. const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
  966. let match: RegExpExecArray | null
  967. while ((match = pairPattern.exec(text))) {
  968. const rawValue = match[2]
  969. let value: unknown = rawValue
  970. if (rawValue === 'true' || rawValue === 'false') {
  971. value = rawValue === 'true'
  972. } else if (rawValue === 'null') {
  973. value = null
  974. } else if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
  975. value = match[3] || ''
  976. } else {
  977. const numericValue = Number(rawValue)
  978. value = Number.isFinite(numericValue) ? numericValue : rawValue
  979. }
  980. parsed[match[1]] = value
  981. }
  982. return parsed
  983. }
  984. function parseHapticPattern(rawValue: unknown): 'short' | 'long' | undefined {
  985. if (rawValue === 'short' || rawValue === 'long') {
  986. return rawValue
  987. }
  988. if (typeof rawValue === 'string') {
  989. const normalized = rawValue.trim().toLowerCase()
  990. if (normalized === 'short' || normalized === 'long') {
  991. return normalized
  992. }
  993. }
  994. return undefined
  995. }
  996. function parsePunchFeedbackMotion(rawValue: unknown): 'none' | 'pop' | 'success' | 'warning' | undefined {
  997. if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'success' || rawValue === 'warning') {
  998. return rawValue
  999. }
  1000. if (typeof rawValue === 'string') {
  1001. const normalized = rawValue.trim().toLowerCase()
  1002. if (normalized === 'none' || normalized === 'pop' || normalized === 'success' || normalized === 'warning') {
  1003. return normalized
  1004. }
  1005. }
  1006. return undefined
  1007. }
  1008. function parseContentCardMotion(rawValue: unknown): 'none' | 'pop' | 'finish' | undefined {
  1009. if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'finish') {
  1010. return rawValue
  1011. }
  1012. if (typeof rawValue === 'string') {
  1013. const normalized = rawValue.trim().toLowerCase()
  1014. if (normalized === 'none' || normalized === 'pop' || normalized === 'finish') {
  1015. return normalized
  1016. }
  1017. }
  1018. return undefined
  1019. }
  1020. function parsePunchButtonMotion(rawValue: unknown): 'none' | 'ready' | 'warning' | undefined {
  1021. if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'warning') {
  1022. return rawValue
  1023. }
  1024. if (typeof rawValue === 'string') {
  1025. const normalized = rawValue.trim().toLowerCase()
  1026. if (normalized === 'none' || normalized === 'ready' || normalized === 'warning') {
  1027. return normalized
  1028. }
  1029. }
  1030. return undefined
  1031. }
  1032. function parseMapPulseMotion(rawValue: unknown): 'none' | 'ready' | 'control' | 'finish' | undefined {
  1033. if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'control' || rawValue === 'finish') {
  1034. return rawValue
  1035. }
  1036. if (typeof rawValue === 'string') {
  1037. const normalized = rawValue.trim().toLowerCase()
  1038. if (normalized === 'none' || normalized === 'ready' || normalized === 'control' || normalized === 'finish') {
  1039. return normalized
  1040. }
  1041. }
  1042. return undefined
  1043. }
  1044. function normalizeHexColor(rawValue: unknown, fallbackValue: string): string {
  1045. if (typeof rawValue !== 'string') {
  1046. return fallbackValue
  1047. }
  1048. const trimmed = rawValue.trim()
  1049. if (!trimmed) {
  1050. return fallbackValue
  1051. }
  1052. if (/^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{8}$/.test(trimmed)) {
  1053. return trimmed.toLowerCase()
  1054. }
  1055. return fallbackValue
  1056. }
  1057. function parseControlPointStyleId(rawValue: unknown, fallbackValue: ControlPointStyleId): ControlPointStyleId {
  1058. if (rawValue === 'classic-ring' || rawValue === 'solid-dot' || rawValue === 'double-ring' || rawValue === 'badge' || rawValue === 'pulse-core') {
  1059. return rawValue
  1060. }
  1061. return fallbackValue
  1062. }
  1063. function parseCourseLegStyleId(rawValue: unknown, fallbackValue: CourseLegStyleId): CourseLegStyleId {
  1064. if (rawValue === 'classic-leg' || rawValue === 'dashed-leg' || rawValue === 'glow-leg' || rawValue === 'progress-leg') {
  1065. return rawValue
  1066. }
  1067. return fallbackValue
  1068. }
  1069. function parseControlPointStyleEntry(rawValue: unknown, fallbackValue: ControlPointStyleEntry): ControlPointStyleEntry {
  1070. const normalized = normalizeObjectRecord(rawValue)
  1071. const sizeScale = parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackValue.sizeScale || 1)
  1072. const accentRingScale = parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackValue.accentRingScale || 0)
  1073. const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0)
  1074. const labelScale = parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackValue.labelScale || 1)
  1075. return {
  1076. style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style),
  1077. colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex),
  1078. sizeScale,
  1079. accentRingScale,
  1080. glowStrength: clamp(glowStrength, 0, 1.2),
  1081. labelScale,
  1082. labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackValue.labelColorHex || ''),
  1083. }
  1084. }
  1085. function parseCourseLegStyleEntry(rawValue: unknown, fallbackValue: CourseLegStyleEntry): CourseLegStyleEntry {
  1086. const normalized = normalizeObjectRecord(rawValue)
  1087. const widthScale = parsePositiveNumber(getFirstDefined(normalized, ['widthscale']), fallbackValue.widthScale || 1)
  1088. const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0)
  1089. return {
  1090. style: parseCourseLegStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style),
  1091. colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex),
  1092. widthScale,
  1093. glowStrength: clamp(glowStrength, 0, 1.2),
  1094. }
  1095. }
  1096. function parseScoreBandStyleEntries(rawValue: unknown, fallbackValue: ScoreBandStyleEntry[]): ScoreBandStyleEntry[] {
  1097. if (!Array.isArray(rawValue) || !rawValue.length) {
  1098. return fallbackValue
  1099. }
  1100. const parsed: ScoreBandStyleEntry[] = []
  1101. for (let index = 0; index < rawValue.length; index += 1) {
  1102. const item = rawValue[index]
  1103. if (!item || typeof item !== 'object' || Array.isArray(item)) {
  1104. continue
  1105. }
  1106. const normalized = normalizeObjectRecord(item)
  1107. const fallbackItem = fallbackValue[Math.min(index, fallbackValue.length - 1)]
  1108. const minValue = Number(getFirstDefined(normalized, ['min']))
  1109. const maxValue = Number(getFirstDefined(normalized, ['max']))
  1110. parsed.push({
  1111. min: Number.isFinite(minValue) ? Math.round(minValue) : fallbackItem.min,
  1112. max: Number.isFinite(maxValue) ? Math.round(maxValue) : fallbackItem.max,
  1113. style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackItem.style),
  1114. colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackItem.colorHex),
  1115. sizeScale: parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackItem.sizeScale || 1),
  1116. accentRingScale: parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackItem.accentRingScale || 0),
  1117. glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackItem.glowStrength || 0), 0, 1.2),
  1118. labelScale: parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackItem.labelScale || 1),
  1119. labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackItem.labelColorHex || ''),
  1120. })
  1121. }
  1122. return parsed.length ? parsed : fallbackValue
  1123. }
  1124. function parseCourseStyleConfig(rawValue: unknown): CourseStyleConfig {
  1125. const normalized = normalizeObjectRecord(rawValue)
  1126. const sequential = normalizeObjectRecord(getFirstDefined(normalized, ['sequential', 'classicsequential', 'classic']))
  1127. const sequentialControls = normalizeObjectRecord(getFirstDefined(sequential, ['controls']))
  1128. const sequentialLegs = normalizeObjectRecord(getFirstDefined(sequential, ['legs']))
  1129. const scoreO = normalizeObjectRecord(getFirstDefined(normalized, ['scoreo', 'score']))
  1130. const scoreOControls = normalizeObjectRecord(getFirstDefined(scoreO, ['controls']))
  1131. return {
  1132. sequential: {
  1133. controls: {
  1134. default: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default),
  1135. current: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['current', 'active']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.current),
  1136. completed: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.completed),
  1137. skipped: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['skipped']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.skipped),
  1138. start: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.start),
  1139. finish: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.finish),
  1140. },
  1141. legs: {
  1142. default: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default),
  1143. completed: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.completed),
  1144. },
  1145. },
  1146. scoreO: {
  1147. controls: {
  1148. default: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default),
  1149. focused: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['focused', 'active']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.focused),
  1150. collected: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['collected', 'completed']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.collected),
  1151. start: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.start),
  1152. finish: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.finish),
  1153. scoreBands: parseScoreBandStyleEntries(getFirstDefined(scoreOControls, ['scorebands', 'bands']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.scoreBands),
  1154. },
  1155. },
  1156. }
  1157. }
  1158. function parseIndexedLegOverrideKey(rawKey: string): number | null {
  1159. if (typeof rawKey !== 'string') {
  1160. return null
  1161. }
  1162. const normalized = rawKey.trim().toLowerCase()
  1163. const legMatch = normalized.match(/^leg-(\d+)$/)
  1164. if (legMatch) {
  1165. const oneBasedIndex = Number(legMatch[1])
  1166. return Number.isFinite(oneBasedIndex) && oneBasedIndex > 0 ? oneBasedIndex - 1 : null
  1167. }
  1168. const numericIndex = Number(normalized)
  1169. return Number.isFinite(numericIndex) && numericIndex >= 0 ? Math.floor(numericIndex) : null
  1170. }
  1171. function parseContentCardCtas(rawValue: unknown): GameControlDisplayContentOverride['ctas'] | undefined {
  1172. if (!Array.isArray(rawValue)) {
  1173. return undefined
  1174. }
  1175. const parsed = rawValue
  1176. .map((item) => {
  1177. const normalized = normalizeObjectRecord(item)
  1178. if (!Object.keys(normalized).length) {
  1179. return null
  1180. }
  1181. const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : ''
  1182. if (typeValue !== 'detail' && typeValue !== 'photo' && typeValue !== 'audio' && typeValue !== 'quiz') {
  1183. return null
  1184. }
  1185. const labelValue = typeof normalized.label === 'string' ? normalized.label.trim() : ''
  1186. if (typeValue !== 'quiz') {
  1187. return {
  1188. type: typeValue as 'detail' | 'photo' | 'audio',
  1189. ...(labelValue ? { label: labelValue } : {}),
  1190. }
  1191. }
  1192. const quizRaw = {
  1193. ...normalizeObjectRecord(normalized.quiz),
  1194. ...(normalized.bonusScore !== undefined ? { bonusScore: normalized.bonusScore } : {}),
  1195. ...(normalized.countdownSeconds !== undefined ? { countdownSeconds: normalized.countdownSeconds } : {}),
  1196. ...(normalized.minValue !== undefined ? { minValue: normalized.minValue } : {}),
  1197. ...(normalized.maxValue !== undefined ? { maxValue: normalized.maxValue } : {}),
  1198. ...(normalized.allowSubtraction !== undefined ? { allowSubtraction: normalized.allowSubtraction } : {}),
  1199. }
  1200. const minValue = Number(quizRaw.minValue)
  1201. const maxValue = Number(quizRaw.maxValue)
  1202. const countdownSeconds = Number(quizRaw.countdownSeconds)
  1203. const bonusScore = Number(quizRaw.bonusScore)
  1204. return {
  1205. type: 'quiz' as const,
  1206. ...(labelValue ? { label: labelValue } : {}),
  1207. ...(Number.isFinite(minValue) ? { minValue: Math.max(10, Math.round(minValue)) } : {}),
  1208. ...(Number.isFinite(maxValue) ? { maxValue: Math.max(99, Math.round(maxValue)) } : {}),
  1209. ...(typeof quizRaw.allowSubtraction === 'boolean' ? { allowSubtraction: quizRaw.allowSubtraction } : {}),
  1210. ...(Number.isFinite(countdownSeconds) ? { countdownSeconds: Math.max(3, Math.round(countdownSeconds)) } : {}),
  1211. ...(Number.isFinite(bonusScore) ? { bonusScore: Math.max(0, Math.round(bonusScore)) } : {}),
  1212. }
  1213. })
  1214. .filter((item): item is NonNullable<typeof item> => !!item)
  1215. return parsed.length ? parsed : undefined
  1216. }
  1217. function parseStageMotion(rawValue: unknown): 'none' | 'finish' | undefined {
  1218. if (rawValue === 'none' || rawValue === 'finish') {
  1219. return rawValue
  1220. }
  1221. if (typeof rawValue === 'string') {
  1222. const normalized = rawValue.trim().toLowerCase()
  1223. if (normalized === 'none' || normalized === 'finish') {
  1224. return normalized
  1225. }
  1226. }
  1227. return undefined
  1228. }
  1229. function buildHapticsCueOverride(rawValue: unknown): PartialHapticCueConfig | null {
  1230. if (typeof rawValue === 'boolean') {
  1231. return { enabled: rawValue }
  1232. }
  1233. const pattern = parseHapticPattern(rawValue)
  1234. if (pattern) {
  1235. return { enabled: true, pattern }
  1236. }
  1237. const normalized = normalizeObjectRecord(rawValue)
  1238. if (!Object.keys(normalized).length) {
  1239. return null
  1240. }
  1241. const cue: PartialHapticCueConfig = {}
  1242. if (normalized.enabled !== undefined) {
  1243. cue.enabled = parseBoolean(normalized.enabled, true)
  1244. }
  1245. const parsedPattern = parseHapticPattern(getFirstDefined(normalized, ['pattern', 'type']))
  1246. if (parsedPattern) {
  1247. cue.pattern = parsedPattern
  1248. }
  1249. return cue.enabled !== undefined || cue.pattern !== undefined ? cue : null
  1250. }
  1251. function buildUiCueOverride(rawValue: unknown): PartialUiCueConfig | null {
  1252. const normalized = normalizeObjectRecord(rawValue)
  1253. if (!Object.keys(normalized).length) {
  1254. return null
  1255. }
  1256. const cue: PartialUiCueConfig = {}
  1257. if (normalized.enabled !== undefined) {
  1258. cue.enabled = parseBoolean(normalized.enabled, true)
  1259. }
  1260. const punchFeedbackMotion = parsePunchFeedbackMotion(getFirstDefined(normalized, ['punchfeedbackmotion', 'feedbackmotion', 'toastmotion']))
  1261. if (punchFeedbackMotion) {
  1262. cue.punchFeedbackMotion = punchFeedbackMotion
  1263. }
  1264. const contentCardMotion = parseContentCardMotion(getFirstDefined(normalized, ['contentcardmotion', 'cardmotion']))
  1265. if (contentCardMotion) {
  1266. cue.contentCardMotion = contentCardMotion
  1267. }
  1268. const punchButtonMotion = parsePunchButtonMotion(getFirstDefined(normalized, ['punchbuttonmotion', 'buttonmotion']))
  1269. if (punchButtonMotion) {
  1270. cue.punchButtonMotion = punchButtonMotion
  1271. }
  1272. const mapPulseMotion = parseMapPulseMotion(getFirstDefined(normalized, ['mappulsemotion', 'mapmotion']))
  1273. if (mapPulseMotion) {
  1274. cue.mapPulseMotion = mapPulseMotion
  1275. }
  1276. const stageMotion = parseStageMotion(getFirstDefined(normalized, ['stagemotion', 'screenmotion']))
  1277. if (stageMotion) {
  1278. cue.stageMotion = stageMotion
  1279. }
  1280. const durationRaw = getFirstDefined(normalized, ['durationms', 'duration'])
  1281. if (durationRaw !== undefined) {
  1282. cue.durationMs = parsePositiveNumber(durationRaw, 0)
  1283. }
  1284. return cue.enabled !== undefined ||
  1285. cue.punchFeedbackMotion !== undefined ||
  1286. cue.contentCardMotion !== undefined ||
  1287. cue.punchButtonMotion !== undefined ||
  1288. cue.mapPulseMotion !== undefined ||
  1289. cue.stageMotion !== undefined ||
  1290. cue.durationMs !== undefined
  1291. ? cue
  1292. : null
  1293. }
  1294. function parseHapticsConfig(rawValue: unknown): GameHapticsConfig {
  1295. const normalized = normalizeObjectRecord(rawValue)
  1296. if (!Object.keys(normalized).length) {
  1297. return mergeGameHapticsConfig()
  1298. }
  1299. const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
  1300. const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
  1301. { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
  1302. { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
  1303. { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
  1304. { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
  1305. { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
  1306. { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
  1307. { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
  1308. { key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] },
  1309. { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
  1310. { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
  1311. ]
  1312. const cues: GameHapticsConfigOverrides['cues'] = {}
  1313. for (const cueDef of cueMap) {
  1314. const cue = buildHapticsCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
  1315. if (cue) {
  1316. cues[cueDef.key] = cue
  1317. }
  1318. }
  1319. return mergeGameHapticsConfig({
  1320. enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
  1321. cues,
  1322. })
  1323. }
  1324. function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig {
  1325. const normalized = normalizeObjectRecord(rawValue)
  1326. if (!Object.keys(normalized).length) {
  1327. return mergeGameUiEffectsConfig()
  1328. }
  1329. const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
  1330. const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
  1331. { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
  1332. { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
  1333. { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
  1334. { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
  1335. { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
  1336. { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
  1337. { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
  1338. { key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] },
  1339. { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
  1340. { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
  1341. ]
  1342. const cues: GameUiEffectsConfigOverrides['cues'] = {}
  1343. for (const cueDef of cueMap) {
  1344. const cue = buildUiCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
  1345. if (cue) {
  1346. cues[cueDef.key] = cue
  1347. }
  1348. }
  1349. return mergeGameUiEffectsConfig({
  1350. enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
  1351. cues,
  1352. })
  1353. }
  1354. function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGameConfig {
  1355. let parsed: Record<string, unknown>
  1356. try {
  1357. parsed = JSON.parse(text)
  1358. } catch {
  1359. parsed = parseLooseJsonObject(text)
  1360. }
  1361. const normalized: Record<string, unknown> = {}
  1362. const keys = Object.keys(parsed)
  1363. for (const key of keys) {
  1364. normalized[key.toLowerCase()] = parsed[key]
  1365. }
  1366. const rawGame = parsed.game && typeof parsed.game === 'object' && !Array.isArray(parsed.game)
  1367. ? parsed.game as Record<string, unknown>
  1368. : null
  1369. const rawApp = parsed.app && typeof parsed.app === 'object' && !Array.isArray(parsed.app)
  1370. ? parsed.app as Record<string, unknown>
  1371. : null
  1372. const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map)
  1373. ? parsed.map as Record<string, unknown>
  1374. : null
  1375. const rawSettings = parsed.settings && typeof parsed.settings === 'object' && !Array.isArray(parsed.settings)
  1376. ? parsed.settings as Record<string, unknown>
  1377. : null
  1378. const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield)
  1379. ? parsed.playfield as Record<string, unknown>
  1380. : null
  1381. const rawPlayfieldSource = rawPlayfield && rawPlayfield.source && typeof rawPlayfield.source === 'object' && !Array.isArray(rawPlayfield.source)
  1382. ? rawPlayfield.source as Record<string, unknown>
  1383. : null
  1384. const rawGamePresentation = rawGame && rawGame.presentation && typeof rawGame.presentation === 'object' && !Array.isArray(rawGame.presentation)
  1385. ? rawGame.presentation as Record<string, unknown>
  1386. : null
  1387. const normalizedGamePresentation = normalizeObjectRecord(rawGamePresentation)
  1388. const normalizedGame: Record<string, unknown> = {}
  1389. if (rawGame) {
  1390. const gameKeys = Object.keys(rawGame)
  1391. for (const key of gameKeys) {
  1392. normalizedGame[key.toLowerCase()] = rawGame[key]
  1393. }
  1394. }
  1395. const rawAudio = rawGame && rawGame.audio !== undefined ? rawGame.audio : parsed.audio
  1396. const rawTelemetry = rawGame && rawGame.telemetry !== undefined ? rawGame.telemetry : parsed.telemetry
  1397. const rawHaptics = rawGame && rawGame.haptics !== undefined ? rawGame.haptics : parsed.haptics
  1398. const rawUiEffects = rawGame && rawGame.uiEffects !== undefined
  1399. ? rawGame.uiEffects
  1400. : rawGame && rawGame.uieffects !== undefined
  1401. ? rawGame.uieffects
  1402. : rawGame && rawGame.ui !== undefined
  1403. ? rawGame.ui
  1404. : (parsed as Record<string, unknown>).uiEffects !== undefined
  1405. ? (parsed as Record<string, unknown>).uiEffects
  1406. : (parsed as Record<string, unknown>).uieffects !== undefined
  1407. ? (parsed as Record<string, unknown>).uieffects
  1408. : (parsed as Record<string, unknown>).ui
  1409. const rawSession = rawGame && rawGame.session && typeof rawGame.session === 'object' && !Array.isArray(rawGame.session)
  1410. ? rawGame.session as Record<string, unknown>
  1411. : null
  1412. const rawPunch = rawGame && rawGame.punch && typeof rawGame.punch === 'object' && !Array.isArray(rawGame.punch)
  1413. ? rawGame.punch as Record<string, unknown>
  1414. : null
  1415. const rawSequence = rawGame && rawGame.sequence && typeof rawGame.sequence === 'object' && !Array.isArray(rawGame.sequence)
  1416. ? rawGame.sequence as Record<string, unknown>
  1417. : null
  1418. const rawSkip = rawSequence && rawSequence.skip && typeof rawSequence.skip === 'object' && !Array.isArray(rawSequence.skip)
  1419. ? rawSequence.skip as Record<string, unknown>
  1420. : null
  1421. const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring)
  1422. ? rawGame.scoring as Record<string, unknown>
  1423. : null
  1424. const rawGameSettings = rawGame && rawGame.settings && typeof rawGame.settings === 'object' && !Array.isArray(rawGame.settings)
  1425. ? rawGame.settings as Record<string, unknown>
  1426. : null
  1427. const mapRoot = rawMap && typeof rawMap.tiles === 'string'
  1428. ? rawMap.tiles
  1429. : typeof normalized.map === 'string'
  1430. ? normalized.map
  1431. : ''
  1432. const mapMeta = rawMap && typeof rawMap.mapmeta === 'string'
  1433. ? rawMap.mapmeta
  1434. : typeof normalized.mapmeta === 'string'
  1435. ? normalized.mapmeta
  1436. : ''
  1437. if (!mapRoot || !mapMeta) {
  1438. throw new Error('game.json 缺少 map 或 mapmeta 字段')
  1439. }
  1440. const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
  1441. const gameMode = parseGameMode(modeValue)
  1442. const modeDefaults = getGameModeDefaults(gameMode)
  1443. const fallbackPointStyle = gameMode === 'score-o'
  1444. ? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default
  1445. : DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default
  1446. const fallbackLegStyle = DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default
  1447. const rawControlDefaults = rawPlayfield && rawPlayfield.controlDefaults && typeof rawPlayfield.controlDefaults === 'object' && !Array.isArray(rawPlayfield.controlDefaults)
  1448. ? rawPlayfield.controlDefaults as Record<string, unknown>
  1449. : null
  1450. const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides)
  1451. ? rawPlayfield.controlOverrides as Record<string, unknown>
  1452. : null
  1453. const defaultControlScoreFromPlayfield = rawControlDefaults
  1454. ? Number(getFirstDefined(normalizeObjectRecord(rawControlDefaults), ['score']))
  1455. : Number.NaN
  1456. const defaultControlContentOverride = parseControlDisplayContentOverride(rawControlDefaults, gameConfigUrl)
  1457. const defaultControlPointStyleOverride = parseControlPointStyleOverride(rawControlDefaults, fallbackPointStyle)
  1458. const controlScoreOverrides: Record<string, number> = {}
  1459. const controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {}
  1460. const controlPointStyleOverrides: Record<string, ControlPointStyleEntry> = {}
  1461. if (rawControlOverrides) {
  1462. const keys = Object.keys(rawControlOverrides)
  1463. for (const key of keys) {
  1464. const item = rawControlOverrides[key]
  1465. if (!item || typeof item !== 'object' || Array.isArray(item)) {
  1466. continue
  1467. }
  1468. const scoreValue = Number((item as Record<string, unknown>).score)
  1469. if (Number.isFinite(scoreValue)) {
  1470. controlScoreOverrides[key] = scoreValue
  1471. }
  1472. const styleOverride = parseControlPointStyleOverride(item, fallbackPointStyle)
  1473. if (styleOverride) {
  1474. controlPointStyleOverrides[key] = styleOverride
  1475. }
  1476. const contentOverride = parseControlDisplayContentOverride(item, gameConfigUrl)
  1477. if (contentOverride) {
  1478. controlContentOverrides[key] = contentOverride
  1479. }
  1480. }
  1481. }
  1482. const rawLegDefaults = rawPlayfield && rawPlayfield.legDefaults && typeof rawPlayfield.legDefaults === 'object' && !Array.isArray(rawPlayfield.legDefaults)
  1483. ? rawPlayfield.legDefaults as Record<string, unknown>
  1484. : null
  1485. const defaultLegStyleOverride = parseLegStyleOverride(rawLegDefaults, fallbackLegStyle)
  1486. const rawLegOverrides = rawPlayfield && rawPlayfield.legOverrides && typeof rawPlayfield.legOverrides === 'object' && !Array.isArray(rawPlayfield.legOverrides)
  1487. ? rawPlayfield.legOverrides as Record<string, unknown>
  1488. : null
  1489. const legStyleOverrides: Record<number, CourseLegStyleEntry> = {}
  1490. if (rawLegOverrides) {
  1491. const legKeys = Object.keys(rawLegOverrides)
  1492. for (const rawKey of legKeys) {
  1493. const item = rawLegOverrides[rawKey]
  1494. const index = parseIndexedLegOverrideKey(rawKey)
  1495. if (index === null || !item || typeof item !== 'object' || Array.isArray(item)) {
  1496. continue
  1497. }
  1498. const legOverride = parseLegStyleOverride(item, fallbackLegStyle)
  1499. if (!legOverride) {
  1500. continue
  1501. }
  1502. legStyleOverrides[index] = legOverride
  1503. }
  1504. }
  1505. const punchRadiusMeters = parsePositiveNumber(
  1506. rawPunch && rawPunch.radiusMeters !== undefined
  1507. ? rawPunch.radiusMeters
  1508. : normalizedGame.punchradiusmeters !== undefined
  1509. ? normalizedGame.punchradiusmeters
  1510. : normalizedGame.punchradius !== undefined
  1511. ? normalizedGame.punchradius
  1512. : normalized.punchradiusmeters !== undefined
  1513. ? normalized.punchradiusmeters
  1514. : normalized.punchradius,
  1515. 5,
  1516. )
  1517. const sessionCloseAfterMs = parsePositiveNumber(
  1518. rawSession && rawSession.closeAfterMs !== undefined
  1519. ? rawSession.closeAfterMs
  1520. : rawSession && rawSession.sessionCloseAfterMs !== undefined
  1521. ? rawSession.sessionCloseAfterMs
  1522. : normalizedGame.sessioncloseafterms !== undefined
  1523. ? normalizedGame.sessioncloseafterms
  1524. : normalized.sessioncloseafterms,
  1525. modeDefaults.sessionCloseAfterMs,
  1526. )
  1527. const sessionCloseWarningMs = parsePositiveNumber(
  1528. rawSession && rawSession.closeWarningMs !== undefined
  1529. ? rawSession.closeWarningMs
  1530. : rawSession && rawSession.sessionCloseWarningMs !== undefined
  1531. ? rawSession.sessionCloseWarningMs
  1532. : normalizedGame.sessionclosewarningms !== undefined
  1533. ? normalizedGame.sessionclosewarningms
  1534. : normalized.sessionclosewarningms,
  1535. modeDefaults.sessionCloseWarningMs,
  1536. )
  1537. const minCompletedControlsBeforeFinish = Math.max(0, Math.floor(parseNumber(
  1538. rawSession && rawSession.minCompletedControlsBeforeFinish !== undefined
  1539. ? rawSession.minCompletedControlsBeforeFinish
  1540. : rawSession && rawSession.minControlsBeforeFinish !== undefined
  1541. ? rawSession.minControlsBeforeFinish
  1542. : normalizedGame.mincompletedcontrolsbeforefinish !== undefined
  1543. ? normalizedGame.mincompletedcontrolsbeforefinish
  1544. : normalizedGame.mincontrolsbeforefinish !== undefined
  1545. ? normalizedGame.mincontrolsbeforefinish
  1546. : normalized.mincompletedcontrolsbeforefinish !== undefined
  1547. ? normalized.mincompletedcontrolsbeforefinish
  1548. : normalized.mincontrolsbeforefinish,
  1549. modeDefaults.minCompletedControlsBeforeFinish,
  1550. )))
  1551. const requiresFocusSelection = parseBoolean(
  1552. rawPunch && rawPunch.requiresFocusSelection !== undefined
  1553. ? rawPunch.requiresFocusSelection
  1554. : normalizedGame.requiresfocusselection !== undefined
  1555. ? normalizedGame.requiresfocusselection
  1556. : rawPunch && (rawPunch as Record<string, unknown>).requiresfocusselection !== undefined
  1557. ? (rawPunch as Record<string, unknown>).requiresfocusselection
  1558. : normalized.requiresfocusselection,
  1559. modeDefaults.requiresFocusSelection,
  1560. )
  1561. const skipEnabled = parseBoolean(
  1562. rawSkip && rawSkip.enabled !== undefined
  1563. ? rawSkip.enabled
  1564. : normalizedGame.skipenabled !== undefined
  1565. ? normalizedGame.skipenabled
  1566. : normalized.skipenabled,
  1567. modeDefaults.skipEnabled,
  1568. )
  1569. const skipRadiusMeters = parsePositiveNumber(
  1570. rawSkip && rawSkip.radiusMeters !== undefined
  1571. ? rawSkip.radiusMeters
  1572. : normalizedGame.skipradiusmeters !== undefined
  1573. ? normalizedGame.skipradiusmeters
  1574. : normalizedGame.skipradius !== undefined
  1575. ? normalizedGame.skipradius
  1576. : normalized.skipradiusmeters !== undefined
  1577. ? normalized.skipradiusmeters
  1578. : normalized.skipradius,
  1579. getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters),
  1580. )
  1581. const autoFinishOnLastControl = parseBoolean(
  1582. rawSession && rawSession.autoFinishOnLastControl !== undefined
  1583. ? rawSession.autoFinishOnLastControl
  1584. : normalizedGame.autofinishonlastcontrol !== undefined
  1585. ? normalizedGame.autofinishonlastcontrol
  1586. : normalized.autofinishonlastcontrol,
  1587. modeDefaults.autoFinishOnLastControl,
  1588. )
  1589. return {
  1590. title: rawApp && typeof rawApp.title === 'string' ? rawApp.title : '',
  1591. appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '',
  1592. schemaVersion: typeof parsed.schemaVersion === 'string' ? parsed.schemaVersion : '1',
  1593. version: typeof parsed.version === 'string' ? parsed.version : '',
  1594. playfieldKind: rawPlayfield && typeof rawPlayfield.kind === 'string' ? rawPlayfield.kind : '',
  1595. mapRoot,
  1596. mapMeta,
  1597. course: rawPlayfieldSource && typeof rawPlayfieldSource.url === 'string'
  1598. ? rawPlayfieldSource.url
  1599. : typeof normalized.course === 'string'
  1600. ? normalized.course
  1601. : null,
  1602. cpRadiusMeters: parsePositiveNumber(
  1603. rawPlayfield && rawPlayfield.CPRadius !== undefined ? rawPlayfield.CPRadius : normalized.cpradius,
  1604. 5,
  1605. ),
  1606. defaultZoom: rawMap && rawMap.initialView && typeof rawMap.initialView === 'object' && !Array.isArray(rawMap.initialView)
  1607. ? parsePositiveNumber((rawMap.initialView as Record<string, unknown>).zoom, 17)
  1608. : null,
  1609. gameMode,
  1610. sessionCloseAfterMs,
  1611. sessionCloseWarningMs,
  1612. minCompletedControlsBeforeFinish,
  1613. punchPolicy: parsePunchPolicy(
  1614. rawPunch && rawPunch.policy !== undefined
  1615. ? rawPunch.policy
  1616. : normalizedGame.punchpolicy !== undefined
  1617. ? normalizedGame.punchpolicy
  1618. : normalized.punchpolicy,
  1619. ),
  1620. punchRadiusMeters,
  1621. requiresFocusSelection,
  1622. skipEnabled,
  1623. skipRadiusMeters,
  1624. skipRequiresConfirm: parseBoolean(
  1625. rawSkip && rawSkip.requiresConfirm !== undefined
  1626. ? rawSkip.requiresConfirm
  1627. : normalizedGame.skiprequiresconfirm !== undefined
  1628. ? normalizedGame.skiprequiresconfirm
  1629. : normalized.skiprequiresconfirm,
  1630. modeDefaults.skipRequiresConfirm,
  1631. ),
  1632. autoFinishOnLastControl,
  1633. controlScoreOverrides,
  1634. controlContentOverrides,
  1635. defaultControlContentOverride,
  1636. defaultControlPointStyleOverride,
  1637. controlPointStyleOverrides,
  1638. defaultLegStyleOverride,
  1639. legStyleOverrides,
  1640. defaultControlScore: resolveDefaultControlScore(
  1641. gameMode,
  1642. Number.isFinite(defaultControlScoreFromPlayfield)
  1643. ? defaultControlScoreFromPlayfield
  1644. : rawScoring && rawScoring.defaultControlScore !== undefined
  1645. ? parsePositiveNumber(rawScoring.defaultControlScore, modeDefaults.defaultControlScore)
  1646. : null,
  1647. ),
  1648. courseStyleConfig: parseCourseStyleConfig(rawGamePresentation),
  1649. trackStyleConfig: parseTrackVisualizationConfig(getFirstDefined(normalizedGamePresentation, ['track'])),
  1650. gpsMarkerStyleConfig: parseGpsMarkerStyleConfig(getFirstDefined(normalizedGamePresentation, ['gpsmarker', 'gps'])),
  1651. telemetryConfig: parseTelemetryConfig(rawTelemetry),
  1652. audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
  1653. hapticsConfig: parseHapticsConfig(rawHaptics),
  1654. uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
  1655. systemSettingsConfig: parseSystemSettingsConfig(rawGameSettings || rawSettings),
  1656. declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination),
  1657. }
  1658. }
  1659. function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGameConfig {
  1660. const config: Record<string, string> = {}
  1661. const lines = text.split(/\r?\n/)
  1662. for (const rawLine of lines) {
  1663. const line = rawLine.trim()
  1664. if (!line || line.startsWith('#')) {
  1665. continue
  1666. }
  1667. const match = line.match(/^([A-Za-z0-9_-]+)\s*(?:=|:)\s*(.+)$/)
  1668. if (!match) {
  1669. continue
  1670. }
  1671. config[match[1].trim().toLowerCase()] = match[2].trim()
  1672. }
  1673. const mapRoot = config.map
  1674. const mapMeta = config.mapmeta
  1675. if (!mapRoot || !mapMeta) {
  1676. throw new Error('game.yaml 缺少 map 或 mapmeta 字段')
  1677. }
  1678. const gameMode = parseGameMode(config.gamemode)
  1679. const modeDefaults = getGameModeDefaults(gameMode)
  1680. const punchRadiusMeters = parsePositiveNumber(
  1681. config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
  1682. 5,
  1683. )
  1684. return {
  1685. title: '',
  1686. appId: '',
  1687. schemaVersion: '1',
  1688. version: '',
  1689. playfieldKind: typeof config.playfieldkind === 'string' ? config.playfieldkind : '',
  1690. mapRoot,
  1691. mapMeta,
  1692. course: typeof config.course === 'string' ? config.course : null,
  1693. cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
  1694. defaultZoom: null,
  1695. gameMode,
  1696. sessionCloseAfterMs: modeDefaults.sessionCloseAfterMs,
  1697. sessionCloseWarningMs: modeDefaults.sessionCloseWarningMs,
  1698. minCompletedControlsBeforeFinish: modeDefaults.minCompletedControlsBeforeFinish,
  1699. punchPolicy: parsePunchPolicy(config.punchpolicy),
  1700. punchRadiusMeters,
  1701. requiresFocusSelection: parseBoolean(config.requiresfocusselection, modeDefaults.requiresFocusSelection),
  1702. skipEnabled: parseBoolean(config.skipenabled, modeDefaults.skipEnabled),
  1703. skipRadiusMeters: parsePositiveNumber(
  1704. config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius,
  1705. getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters),
  1706. ),
  1707. skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, modeDefaults.skipRequiresConfirm),
  1708. autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, modeDefaults.autoFinishOnLastControl),
  1709. controlScoreOverrides: {},
  1710. controlContentOverrides: {},
  1711. defaultControlContentOverride: null,
  1712. defaultControlPointStyleOverride: null,
  1713. controlPointStyleOverrides: {},
  1714. defaultLegStyleOverride: null,
  1715. legStyleOverrides: {},
  1716. defaultControlScore: modeDefaults.defaultControlScore,
  1717. courseStyleConfig: DEFAULT_COURSE_STYLE_CONFIG,
  1718. trackStyleConfig: DEFAULT_TRACK_VISUALIZATION_CONFIG,
  1719. gpsMarkerStyleConfig: DEFAULT_GPS_MARKER_STYLE_CONFIG,
  1720. telemetryConfig: parseTelemetryConfig({
  1721. heartRate: {
  1722. age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage,
  1723. restingHeartRateBpm: config.restingheartratebpm !== undefined
  1724. ? config.restingheartratebpm
  1725. : config.restingheartrate !== undefined
  1726. ? config.restingheartrate
  1727. : config.telemetryrestingheartratebpm !== undefined
  1728. ? config.telemetryrestingheartratebpm
  1729. : config.telemetryrestingheartrate,
  1730. },
  1731. }),
  1732. audioConfig: parseAudioConfig({
  1733. enabled: config.audioenabled,
  1734. masterVolume: config.audiomastervolume,
  1735. obeyMuteSwitch: config.audioobeymuteswitch,
  1736. distantDistanceMeters: config.audiodistantdistancemeters !== undefined
  1737. ? config.audiodistantdistancemeters
  1738. : config.audiodistantdistance !== undefined
  1739. ? config.audiodistantdistance
  1740. : config.audiofardistancemeters !== undefined
  1741. ? config.audiofardistancemeters
  1742. : config.audiofardistance,
  1743. approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance,
  1744. readyDistanceMeters: config.audioreadydistancemeters !== undefined
  1745. ? config.audioreadydistancemeters
  1746. : config.audioreadydistance !== undefined
  1747. ? config.audioreadydistance
  1748. : config.audiopunchreadydistancemeters !== undefined
  1749. ? config.audiopunchreadydistancemeters
  1750. : config.audiopunchreadydistance,
  1751. cues: {
  1752. session_started: config.audiosessionstarted,
  1753. 'control_completed:start': config.audiostartcomplete,
  1754. 'control_completed:control': config.audiocontrolcomplete,
  1755. 'control_completed:finish': config.audiofinishcomplete,
  1756. 'punch_feedback:warning': config.audiowarning,
  1757. 'guidance:searching': config.audiosearching,
  1758. 'guidance:distant': config.audiodistant,
  1759. 'guidance:approaching': config.audioapproaching,
  1760. 'guidance:ready': config.audioready,
  1761. },
  1762. }, gameConfigUrl),
  1763. hapticsConfig: parseHapticsConfig({
  1764. enabled: config.hapticsenabled,
  1765. cues: {
  1766. session_started: config.hapticsstart,
  1767. session_finished: config.hapticsfinish,
  1768. 'control_completed:start': config.hapticsstartcomplete,
  1769. 'control_completed:control': config.hapticscontrolcomplete,
  1770. 'control_completed:finish': config.hapticsfinishcomplete,
  1771. 'punch_feedback:warning': config.hapticswarning,
  1772. 'guidance:searching': config.hapticssearching,
  1773. 'guidance:distant': config.hapticsdistant,
  1774. 'guidance:approaching': config.hapticsapproaching,
  1775. 'guidance:ready': config.hapticsready,
  1776. },
  1777. }),
  1778. uiEffectsConfig: parseUiEffectsConfig({
  1779. enabled: config.uieffectsenabled,
  1780. cues: {
  1781. session_started: { enabled: config.uistartenabled, punchButtonMotion: config.uistartbuttonmotion },
  1782. session_finished: { enabled: config.uifinishenabled, contentCardMotion: config.uifinishcardmotion },
  1783. 'control_completed:start': { enabled: config.uistartcompleteenabled, contentCardMotion: config.uistartcompletecardmotion, punchFeedbackMotion: config.uistartcompletetoastmotion },
  1784. 'control_completed:control': { enabled: config.uicontrolcompleteenabled, contentCardMotion: config.uicontrolcompletecardmotion, punchFeedbackMotion: config.uicontrolcompletetoastmotion },
  1785. 'control_completed:finish': { enabled: config.uifinishcompleteenabled, contentCardMotion: config.uifinishcompletecardmotion, punchFeedbackMotion: config.uifinishcompletetoastmotion },
  1786. 'punch_feedback:warning': { enabled: config.uiwarningenabled, punchFeedbackMotion: config.uiwarningtoastmotion, punchButtonMotion: config.uiwarningbuttonmotion, durationMs: config.uiwarningdurationms },
  1787. 'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms },
  1788. },
  1789. }),
  1790. systemSettingsConfig: { values: {}, locks: {} },
  1791. declinationDeg: parseDeclinationValue(config.declination),
  1792. }
  1793. }
  1794. function parseGameConfig(text: string, gameConfigUrl: string): ParsedGameConfig {
  1795. const trimmedText = text.trim()
  1796. const isJson =
  1797. trimmedText.startsWith('{') ||
  1798. trimmedText.startsWith('[') ||
  1799. /\.json(?:[?#].*)?$/i.test(gameConfigUrl)
  1800. return isJson ? parseGameConfigFromJson(trimmedText, gameConfigUrl) : parseGameConfigFromYaml(trimmedText, gameConfigUrl)
  1801. }
  1802. function extractStringField(text: string, key: string): string | null {
  1803. const pattern = new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)
  1804. const match = text.match(pattern)
  1805. return match ? match[1] : null
  1806. }
  1807. function extractNumberField(text: string, key: string): number | null {
  1808. const pattern = new RegExp(`"${key}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`)
  1809. const match = text.match(pattern)
  1810. if (!match) {
  1811. return null
  1812. }
  1813. const value = Number(match[1])
  1814. return Number.isFinite(value) ? value : null
  1815. }
  1816. function extractNumberArrayField(text: string, key: string): number[] | null {
  1817. const pattern = new RegExp(`"${key}"\\s*:\\s*\\[([^\\]]+)\\]`)
  1818. const match = text.match(pattern)
  1819. if (!match) {
  1820. return null
  1821. }
  1822. const numberMatches = match[1].match(/-?\d+(?:\.\d+)?/g)
  1823. if (!numberMatches || !numberMatches.length) {
  1824. return null
  1825. }
  1826. const values = numberMatches
  1827. .map((item) => Number(item))
  1828. .filter((item) => Number.isFinite(item))
  1829. return values.length ? values : null
  1830. }
  1831. function parseMapMeta(text: string): ParsedMapMeta {
  1832. const tileSizeField = extractNumberField(text, 'tileSize')
  1833. const tileSize = tileSizeField === null ? 256 : tileSizeField
  1834. const minZoom = extractNumberField(text, 'minZoom')
  1835. const maxZoom = extractNumberField(text, 'maxZoom')
  1836. const projectionField = extractStringField(text, 'projection')
  1837. const projection = projectionField === null ? 'EPSG:3857' : projectionField
  1838. const tilePathTemplate = extractStringField(text, 'tilePathTemplate')
  1839. const tileFormatFromField = extractStringField(text, 'tileFormat')
  1840. const boundsValues = extractNumberArrayField(text, 'bounds')
  1841. if (!Number.isFinite(minZoom) || !Number.isFinite(maxZoom) || !tilePathTemplate) {
  1842. throw new Error('meta.json 缺少必要字段')
  1843. }
  1844. let tileFormat = tileFormatFromField || ''
  1845. if (!tileFormat) {
  1846. const extensionMatch = tilePathTemplate.match(/\.([A-Za-z0-9]+)$/)
  1847. tileFormat = extensionMatch ? extensionMatch[1].toLowerCase() : 'png'
  1848. }
  1849. return {
  1850. tileSize,
  1851. minZoom: minZoom as number,
  1852. maxZoom: maxZoom as number,
  1853. projection,
  1854. tileFormat,
  1855. tilePathTemplate,
  1856. bounds: boundsValues && boundsValues.length >= 4
  1857. ? [boundsValues[0], boundsValues[1], boundsValues[2], boundsValues[3]]
  1858. : null,
  1859. }
  1860. }
  1861. function getBoundsCorners(
  1862. bounds: [number, number, number, number],
  1863. projection: string,
  1864. ): { northWest: LonLatPoint; southEast: LonLatPoint; center: LonLatPoint } {
  1865. if (projection === 'EPSG:3857') {
  1866. const minX = bounds[0]
  1867. const minY = bounds[1]
  1868. const maxX = bounds[2]
  1869. const maxY = bounds[3]
  1870. return {
  1871. northWest: webMercatorToLonLat({ x: minX, y: maxY }),
  1872. southEast: webMercatorToLonLat({ x: maxX, y: minY }),
  1873. center: webMercatorToLonLat({ x: (minX + maxX) / 2, y: (minY + maxY) / 2 }),
  1874. }
  1875. }
  1876. if (projection === 'EPSG:4326') {
  1877. const minLon = bounds[0]
  1878. const minLat = bounds[1]
  1879. const maxLon = bounds[2]
  1880. const maxLat = bounds[3]
  1881. return {
  1882. northWest: { lon: minLon, lat: maxLat },
  1883. southEast: { lon: maxLon, lat: minLat },
  1884. center: { lon: (minLon + maxLon) / 2, lat: (minLat + maxLat) / 2 },
  1885. }
  1886. }
  1887. throw new Error(`暂不支持的投影: ${projection}`)
  1888. }
  1889. function buildTileBoundsByZoom(
  1890. bounds: [number, number, number, number] | null,
  1891. projection: string,
  1892. minZoom: number,
  1893. maxZoom: number,
  1894. ): Record<number, TileZoomBounds> {
  1895. const boundsByZoom: Record<number, TileZoomBounds> = {}
  1896. if (!bounds) {
  1897. return boundsByZoom
  1898. }
  1899. const corners = getBoundsCorners(bounds, projection)
  1900. for (let zoom = minZoom; zoom <= maxZoom; zoom += 1) {
  1901. const northWestWorld = lonLatToWorldTile(corners.northWest, zoom)
  1902. const southEastWorld = lonLatToWorldTile(corners.southEast, zoom)
  1903. const minX = Math.floor(Math.min(northWestWorld.x, southEastWorld.x))
  1904. const maxX = Math.ceil(Math.max(northWestWorld.x, southEastWorld.x)) - 1
  1905. const minY = Math.floor(Math.min(northWestWorld.y, southEastWorld.y))
  1906. const maxY = Math.ceil(Math.max(northWestWorld.y, southEastWorld.y)) - 1
  1907. boundsByZoom[zoom] = {
  1908. minX,
  1909. maxX,
  1910. minY,
  1911. maxY,
  1912. }
  1913. }
  1914. return boundsByZoom
  1915. }
  1916. function getProjectionModeText(projection: string): string {
  1917. return `${projection} -> XYZ Tile -> Camera -> Screen`
  1918. }
  1919. export function isTileWithinBounds(
  1920. tileBoundsByZoom: Record<number, TileZoomBounds> | null | undefined,
  1921. zoom: number,
  1922. x: number,
  1923. y: number,
  1924. ): boolean {
  1925. if (!tileBoundsByZoom) {
  1926. return true
  1927. }
  1928. const bounds = tileBoundsByZoom[zoom]
  1929. if (!bounds) {
  1930. return true
  1931. }
  1932. return x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY
  1933. }
  1934. export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<RemoteMapConfig> {
  1935. const gameConfigText = await requestText(gameConfigUrl)
  1936. const gameConfig = parseGameConfig(gameConfigText, gameConfigUrl)
  1937. const mapMetaUrl = resolveUrl(gameConfigUrl, gameConfig.mapMeta)
  1938. const mapRootUrl = resolveUrl(gameConfigUrl, gameConfig.mapRoot)
  1939. const courseUrl = gameConfig.course ? resolveUrl(gameConfigUrl, gameConfig.course) : null
  1940. const mapMetaText = await requestText(mapMetaUrl)
  1941. const mapMeta = parseMapMeta(mapMetaText)
  1942. let course: OrienteeringCourseData | null = null
  1943. let courseStatusText = courseUrl ? '路线待加载' : '未配置路线'
  1944. if (courseUrl) {
  1945. try {
  1946. const courseText = await requestText(courseUrl)
  1947. course = parseOrienteeringCourseKml(courseText)
  1948. courseStatusText = `路线已载入 (${course.layers.controls.length} controls)`
  1949. } catch (error) {
  1950. const message = error instanceof Error ? error.message : '未知错误'
  1951. courseStatusText = `路线加载失败: ${message}`
  1952. }
  1953. }
  1954. const defaultZoom = clamp(gameConfig.defaultZoom || 17, mapMeta.minZoom, mapMeta.maxZoom)
  1955. const boundsCorners = mapMeta.bounds ? getBoundsCorners(mapMeta.bounds, mapMeta.projection) : null
  1956. const centerWorldTile = boundsCorners
  1957. ? lonLatToWorldTile(boundsCorners.center, defaultZoom)
  1958. : { x: 0, y: 0 }
  1959. return {
  1960. configTitle: gameConfig.title || '未命名配置',
  1961. configAppId: gameConfig.appId || '',
  1962. configSchemaVersion: gameConfig.schemaVersion || '1',
  1963. configVersion: gameConfig.version || '',
  1964. playfieldKind: gameConfig.playfieldKind || '',
  1965. tileSource: resolveUrl(mapRootUrl, mapMeta.tilePathTemplate),
  1966. minZoom: mapMeta.minZoom,
  1967. maxZoom: mapMeta.maxZoom,
  1968. defaultZoom,
  1969. initialCenterTileX: Math.round(centerWorldTile.x),
  1970. initialCenterTileY: Math.round(centerWorldTile.y),
  1971. projection: mapMeta.projection,
  1972. projectionModeText: getProjectionModeText(mapMeta.projection),
  1973. magneticDeclinationDeg: gameConfig.declinationDeg,
  1974. magneticDeclinationText: formatDeclinationText(gameConfig.declinationDeg),
  1975. tileFormat: mapMeta.tileFormat,
  1976. tileSize: mapMeta.tileSize,
  1977. bounds: mapMeta.bounds,
  1978. tileBoundsByZoom: buildTileBoundsByZoom(mapMeta.bounds, mapMeta.projection, mapMeta.minZoom, mapMeta.maxZoom),
  1979. mapMetaUrl,
  1980. mapRootUrl,
  1981. courseUrl,
  1982. course,
  1983. courseStatusText,
  1984. cpRadiusMeters: gameConfig.cpRadiusMeters,
  1985. gameMode: gameConfig.gameMode,
  1986. sessionCloseAfterMs: gameConfig.sessionCloseAfterMs,
  1987. sessionCloseWarningMs: gameConfig.sessionCloseWarningMs,
  1988. minCompletedControlsBeforeFinish: gameConfig.minCompletedControlsBeforeFinish,
  1989. punchPolicy: gameConfig.punchPolicy,
  1990. punchRadiusMeters: gameConfig.punchRadiusMeters,
  1991. requiresFocusSelection: gameConfig.requiresFocusSelection,
  1992. skipEnabled: gameConfig.skipEnabled,
  1993. skipRadiusMeters: gameConfig.skipRadiusMeters,
  1994. skipRequiresConfirm: gameConfig.skipRequiresConfirm,
  1995. autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
  1996. controlScoreOverrides: gameConfig.controlScoreOverrides,
  1997. controlContentOverrides: gameConfig.controlContentOverrides,
  1998. defaultControlContentOverride: gameConfig.defaultControlContentOverride,
  1999. defaultControlPointStyleOverride: gameConfig.defaultControlPointStyleOverride,
  2000. controlPointStyleOverrides: gameConfig.controlPointStyleOverrides,
  2001. defaultLegStyleOverride: gameConfig.defaultLegStyleOverride,
  2002. legStyleOverrides: gameConfig.legStyleOverrides,
  2003. defaultControlScore: gameConfig.defaultControlScore,
  2004. courseStyleConfig: gameConfig.courseStyleConfig,
  2005. trackStyleConfig: gameConfig.trackStyleConfig,
  2006. gpsMarkerStyleConfig: gameConfig.gpsMarkerStyleConfig,
  2007. telemetryConfig: gameConfig.telemetryConfig,
  2008. audioConfig: gameConfig.audioConfig,
  2009. hapticsConfig: gameConfig.hapticsConfig,
  2010. uiEffectsConfig: gameConfig.uiEffectsConfig,
  2011. systemSettingsConfig: gameConfig.systemSettingsConfig,
  2012. }
  2013. }