courseToGameDefinition.ts 6.8 KB

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