courseToGameDefinition.ts 6.5 KB

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