courseToGameDefinition.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import {
  2. type GameContentExperienceConfig,
  3. type GameContentExperienceConfigOverride,
  4. type GameDefinition,
  5. type GameControl,
  6. type GameControlDisplayContent,
  7. type GameControlDisplayContentOverride,
  8. type PunchPolicyType,
  9. } from '../core/gameDefinition'
  10. import {
  11. resolveContentCardCtaConfig,
  12. } from '../experience/contentCard'
  13. import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
  14. import {
  15. getDefaultSkipRadiusMeters,
  16. getGameModeDefaults,
  17. resolveDefaultControlScore,
  18. } from '../core/gameModeDefaults'
  19. function sortBySequence<T extends { sequence: number | null }>(items: T[]): T[] {
  20. return [...items].sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
  21. }
  22. function buildDisplayBody(label: string, sequence: number | null): string {
  23. if (typeof sequence === 'number') {
  24. return `检查点 ${sequence} · ${label || String(sequence)}`
  25. }
  26. return label
  27. }
  28. function applyExperienceOverride(
  29. baseExperience: GameContentExperienceConfig | null,
  30. override: GameContentExperienceConfigOverride | undefined,
  31. ): GameContentExperienceConfig | null {
  32. if (!override) {
  33. return baseExperience
  34. }
  35. if (override.type === 'native') {
  36. return {
  37. type: 'native',
  38. url: null,
  39. bridge: 'content-v1',
  40. fallback: 'native',
  41. presentation: 'sheet',
  42. }
  43. }
  44. if (override.type === 'h5' && override.url) {
  45. return {
  46. type: 'h5',
  47. url: override.url,
  48. bridge: override.bridge || (baseExperience ? baseExperience.bridge : 'content-v1'),
  49. fallback: override.fallback || 'native',
  50. presentation: override.presentation || (baseExperience ? baseExperience.presentation : 'sheet'),
  51. }
  52. }
  53. return baseExperience
  54. }
  55. function applyDisplayContentOverride(
  56. baseContent: GameControlDisplayContent,
  57. override: GameControlDisplayContentOverride | undefined,
  58. ): GameControlDisplayContent {
  59. if (!override) {
  60. return baseContent
  61. }
  62. return {
  63. template: override.template || baseContent.template,
  64. title: override.title || baseContent.title,
  65. body: override.body || baseContent.body,
  66. autoPopup: override.autoPopup !== undefined ? override.autoPopup : baseContent.autoPopup,
  67. once: override.once !== undefined ? override.once : baseContent.once,
  68. priority: override.priority !== undefined ? override.priority : baseContent.priority,
  69. clickTitle: override.clickTitle !== undefined ? override.clickTitle : baseContent.clickTitle,
  70. clickBody: override.clickBody !== undefined ? override.clickBody : baseContent.clickBody,
  71. ctas: override.ctas && override.ctas.length
  72. ? override.ctas
  73. .map((item) => resolveContentCardCtaConfig(item))
  74. .filter((item): item is NonNullable<typeof item> => !!item)
  75. : baseContent.ctas,
  76. contentExperience: applyExperienceOverride(baseContent.contentExperience, override.contentExperience),
  77. clickExperience: applyExperienceOverride(baseContent.clickExperience, override.clickExperience),
  78. }
  79. }
  80. export function buildGameDefinitionFromCourse(
  81. course: OrienteeringCourseData,
  82. controlRadiusMeters: number,
  83. mode: GameDefinition['mode'] = 'classic-sequential',
  84. sessionCloseAfterMs?: number,
  85. sessionCloseWarningMs?: number,
  86. minCompletedControlsBeforeFinish?: number,
  87. autoFinishOnLastControl?: boolean,
  88. punchPolicy: PunchPolicyType = 'enter-confirm',
  89. punchRadiusMeters = 5,
  90. requiresFocusSelection?: boolean,
  91. skipEnabled?: boolean,
  92. skipRadiusMeters?: number,
  93. skipRequiresConfirm?: boolean,
  94. controlScoreOverrides: Record<string, number> = {},
  95. defaultControlContentOverride: GameControlDisplayContentOverride | null = null,
  96. controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {},
  97. defaultControlScore: number | null = null,
  98. ): GameDefinition {
  99. const controls: GameControl[] = []
  100. const modeDefaults = getGameModeDefaults(mode)
  101. const resolvedSessionCloseAfterMs = sessionCloseAfterMs !== undefined
  102. ? sessionCloseAfterMs
  103. : modeDefaults.sessionCloseAfterMs
  104. const resolvedSessionCloseWarningMs = sessionCloseWarningMs !== undefined
  105. ? sessionCloseWarningMs
  106. : modeDefaults.sessionCloseWarningMs
  107. const resolvedMinCompletedControlsBeforeFinish = minCompletedControlsBeforeFinish !== undefined
  108. ? minCompletedControlsBeforeFinish
  109. : modeDefaults.minCompletedControlsBeforeFinish
  110. const resolvedRequiresFocusSelection = requiresFocusSelection !== undefined
  111. ? requiresFocusSelection
  112. : modeDefaults.requiresFocusSelection
  113. const resolvedSkipEnabled = skipEnabled !== undefined
  114. ? skipEnabled
  115. : modeDefaults.skipEnabled
  116. const resolvedSkipRadiusMeters = skipRadiusMeters !== undefined
  117. ? skipRadiusMeters
  118. : getDefaultSkipRadiusMeters(mode, punchRadiusMeters)
  119. const resolvedSkipRequiresConfirm = skipRequiresConfirm !== undefined
  120. ? skipRequiresConfirm
  121. : modeDefaults.skipRequiresConfirm
  122. const resolvedAutoFinishOnLastControl = autoFinishOnLastControl !== undefined
  123. ? autoFinishOnLastControl
  124. : modeDefaults.autoFinishOnLastControl
  125. const resolvedDefaultControlScore = resolveDefaultControlScore(mode, defaultControlScore)
  126. for (let startIndex = 0; startIndex < course.layers.starts.length; startIndex += 1) {
  127. const start = course.layers.starts[startIndex]
  128. const startId = `start-${startIndex + 1}`
  129. controls.push({
  130. id: startId,
  131. code: start.label || 'S',
  132. label: start.label || 'Start',
  133. kind: 'start',
  134. point: start.point,
  135. sequence: null,
  136. score: null,
  137. displayContent: applyDisplayContentOverride({
  138. template: 'focus',
  139. title: '比赛开始',
  140. body: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
  141. autoPopup: false,
  142. once: false,
  143. priority: 1,
  144. clickTitle: null,
  145. clickBody: null,
  146. ctas: [],
  147. contentExperience: null,
  148. clickExperience: null,
  149. }, controlContentOverrides[startId]),
  150. })
  151. }
  152. for (const control of sortBySequence(course.layers.controls)) {
  153. const label = control.label || String(control.sequence)
  154. const controlId = `control-${control.sequence}`
  155. const score = controlId in controlScoreOverrides
  156. ? controlScoreOverrides[controlId]
  157. : resolvedDefaultControlScore
  158. controls.push({
  159. id: controlId,
  160. code: label,
  161. label,
  162. kind: 'control',
  163. point: control.point,
  164. sequence: control.sequence,
  165. score,
  166. displayContent: applyDisplayContentOverride(
  167. applyDisplayContentOverride({
  168. template: 'story',
  169. title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
  170. body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence),
  171. autoPopup: false,
  172. once: false,
  173. priority: 1,
  174. clickTitle: null,
  175. clickBody: null,
  176. ctas: [],
  177. contentExperience: null,
  178. clickExperience: null,
  179. }, defaultControlContentOverride || undefined),
  180. controlContentOverrides[controlId],
  181. ),
  182. })
  183. }
  184. for (let finishIndex = 0; finishIndex < course.layers.finishes.length; finishIndex += 1) {
  185. const finish = course.layers.finishes[finishIndex]
  186. const finishId = `finish-${finishIndex + 1}`
  187. const legacyFinishId = `finish-${controls.length + 1}`
  188. controls.push({
  189. id: finishId,
  190. code: finish.label || 'F',
  191. label: finish.label || 'Finish',
  192. kind: 'finish',
  193. point: finish.point,
  194. sequence: null,
  195. score: null,
  196. displayContent: applyDisplayContentOverride({
  197. template: 'focus',
  198. title: '完成路线',
  199. body: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
  200. autoPopup: false,
  201. once: false,
  202. priority: 2,
  203. clickTitle: null,
  204. clickBody: null,
  205. ctas: [],
  206. contentExperience: null,
  207. clickExperience: null,
  208. }, controlContentOverrides[finishId] || controlContentOverrides[legacyFinishId]),
  209. })
  210. }
  211. return {
  212. id: `course-${course.title || 'default'}`,
  213. mode,
  214. title: course.title || (mode === 'score-o' ? 'Score-O' : 'Classic Sequential'),
  215. controlRadiusMeters,
  216. sessionCloseAfterMs: resolvedSessionCloseAfterMs,
  217. sessionCloseWarningMs: resolvedSessionCloseWarningMs,
  218. minCompletedControlsBeforeFinish: resolvedMinCompletedControlsBeforeFinish,
  219. punchRadiusMeters,
  220. punchPolicy,
  221. requiresFocusSelection: resolvedRequiresFocusSelection,
  222. skipEnabled: resolvedSkipEnabled,
  223. skipRadiusMeters: resolvedSkipRadiusMeters,
  224. skipRequiresConfirm: resolvedSkipRequiresConfirm,
  225. controls,
  226. autoFinishOnLastControl: resolvedAutoFinishOnLastControl,
  227. }
  228. }