classicSequentialRule.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import { type LonLatPoint } from '../../utils/projection'
  2. import { type GameControl, type GameDefinition } from '../core/gameDefinition'
  3. import { type GameEvent } from '../core/gameEvent'
  4. import { type GameEffect, type GameResult } from '../core/gameResult'
  5. import { type GameSessionState } from '../core/gameSessionState'
  6. import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
  7. import { type RulePlugin } from './rulePlugin'
  8. function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
  9. const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
  10. const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
  11. const dy = (b.lat - a.lat) * 110540
  12. return Math.sqrt(dx * dx + dy * dy)
  13. }
  14. function getScoringControls(definition: GameDefinition): GameControl[] {
  15. return definition.controls.filter((control) => control.kind === 'control')
  16. }
  17. function getSequentialTargets(definition: GameDefinition): GameControl[] {
  18. return definition.controls
  19. }
  20. function getCompletedControlSequences(definition: GameDefinition, state: GameSessionState): number[] {
  21. return getScoringControls(definition)
  22. .filter((control) => state.completedControlIds.includes(control.id) && typeof control.sequence === 'number')
  23. .map((control) => control.sequence as number)
  24. }
  25. function getCurrentTarget(definition: GameDefinition, state: GameSessionState): GameControl | null {
  26. return getSequentialTargets(definition).find((control) => control.id === state.currentTargetControlId) || null
  27. }
  28. function getCompletedLegIndices(definition: GameDefinition, state: GameSessionState): number[] {
  29. const targets = getSequentialTargets(definition)
  30. const completedLegIndices: number[] = []
  31. for (let index = 1; index < targets.length; index += 1) {
  32. if (state.completedControlIds.includes(targets[index].id)) {
  33. completedLegIndices.push(index - 1)
  34. }
  35. }
  36. return completedLegIndices
  37. }
  38. function getTargetText(control: GameControl): string {
  39. if (control.kind === 'start') {
  40. return '开始点'
  41. }
  42. if (control.kind === 'finish') {
  43. return '终点'
  44. }
  45. return '目标圈'
  46. }
  47. function buildPunchHintText(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl | null): string {
  48. if (state.status === 'idle') {
  49. return '点击开始后先打开始点'
  50. }
  51. if (state.status === 'finished') {
  52. return '本局已完成'
  53. }
  54. if (!currentTarget) {
  55. return '本局已完成'
  56. }
  57. const targetText = getTargetText(currentTarget)
  58. if (state.inRangeControlId !== currentTarget.id) {
  59. return definition.punchPolicy === 'enter'
  60. ? `进入${targetText}自动打点`
  61. : `进入${targetText}后点击打点`
  62. }
  63. return definition.punchPolicy === 'enter'
  64. ? `${targetText}内,自动打点中`
  65. : `${targetText}内,可点击打点`
  66. }
  67. function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
  68. const scoringControls = getScoringControls(definition)
  69. const sequentialTargets = getSequentialTargets(definition)
  70. const currentTarget = getCurrentTarget(definition, state)
  71. const currentTargetIndex = currentTarget ? sequentialTargets.findIndex((control) => control.id === currentTarget.id) : -1
  72. const completedControls = scoringControls.filter((control) => state.completedControlIds.includes(control.id))
  73. const running = state.status === 'running'
  74. const activeLegIndices = running && currentTargetIndex > 0
  75. ? [currentTargetIndex - 1]
  76. : []
  77. const completedLegIndices = getCompletedLegIndices(definition, state)
  78. const punchButtonEnabled = running && !!currentTarget && state.inRangeControlId === currentTarget.id && definition.punchPolicy === 'enter-confirm'
  79. const activeStart = running && !!currentTarget && currentTarget.kind === 'start'
  80. const completedStart = definition.controls.some((control) => control.kind === 'start' && state.completedControlIds.includes(control.id))
  81. const activeFinish = running && !!currentTarget && currentTarget.kind === 'finish'
  82. const completedFinish = definition.controls.some((control) => control.kind === 'finish' && state.completedControlIds.includes(control.id))
  83. const punchButtonText = currentTarget
  84. ? currentTarget.kind === 'start'
  85. ? '开始打卡'
  86. : currentTarget.kind === 'finish'
  87. ? '结束打卡'
  88. : '打点'
  89. : '打点'
  90. const revealFullCourse = completedStart
  91. if (!scoringControls.length) {
  92. return {
  93. ...EMPTY_GAME_PRESENTATION_STATE,
  94. activeStart,
  95. completedStart,
  96. activeFinish,
  97. completedFinish,
  98. revealFullCourse,
  99. activeLegIndices,
  100. completedLegIndices,
  101. progressText: '0/0',
  102. punchButtonText,
  103. punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
  104. punchButtonEnabled,
  105. punchHintText: buildPunchHintText(definition, state, currentTarget),
  106. }
  107. }
  108. return {
  109. activeControlIds: running && currentTarget ? [currentTarget.id] : [],
  110. activeControlSequences: running && currentTarget && currentTarget.kind === 'control' && typeof currentTarget.sequence === 'number' ? [currentTarget.sequence] : [],
  111. activeStart,
  112. completedStart,
  113. activeFinish,
  114. completedFinish,
  115. revealFullCourse,
  116. activeLegIndices,
  117. completedLegIndices,
  118. completedControlIds: completedControls.map((control) => control.id),
  119. completedControlSequences: getCompletedControlSequences(definition, state),
  120. progressText: `${completedControls.length}/${scoringControls.length}`,
  121. punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
  122. punchButtonEnabled,
  123. punchButtonText,
  124. punchHintText: buildPunchHintText(definition, state, currentTarget),
  125. }
  126. }
  127. function getInitialTargetId(definition: GameDefinition): string | null {
  128. const firstTarget = getSequentialTargets(definition)[0]
  129. return firstTarget ? firstTarget.id : null
  130. }
  131. function buildCompletedEffect(control: GameControl): GameEffect {
  132. if (control.kind === 'start') {
  133. return {
  134. type: 'control_completed',
  135. controlId: control.id,
  136. controlKind: 'start',
  137. sequence: null,
  138. label: control.label,
  139. displayTitle: '比赛开始',
  140. displayBody: '已完成开始点打卡,前往 1 号点。',
  141. }
  142. }
  143. if (control.kind === 'finish') {
  144. return {
  145. type: 'control_completed',
  146. controlId: control.id,
  147. controlKind: 'finish',
  148. sequence: null,
  149. label: control.label,
  150. displayTitle: '比赛结束',
  151. displayBody: '已完成终点打卡,本局结束。',
  152. }
  153. }
  154. const sequenceText = typeof control.sequence === 'number' ? String(control.sequence) : control.label
  155. const displayTitle = control.displayContent ? control.displayContent.title : `完成 ${sequenceText}`
  156. const displayBody = control.displayContent ? control.displayContent.body : control.label
  157. return {
  158. type: 'control_completed',
  159. controlId: control.id,
  160. controlKind: 'control',
  161. sequence: control.sequence,
  162. label: control.label,
  163. displayTitle,
  164. displayBody,
  165. }
  166. }
  167. function applyCompletion(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl, at: number): GameResult {
  168. const targets = getSequentialTargets(definition)
  169. const currentIndex = targets.findIndex((control) => control.id === currentTarget.id)
  170. const completedControlIds = state.completedControlIds.includes(currentTarget.id)
  171. ? state.completedControlIds
  172. : [...state.completedControlIds, currentTarget.id]
  173. const nextTarget = currentIndex >= 0 && currentIndex < targets.length - 1
  174. ? targets[currentIndex + 1]
  175. : null
  176. const nextState: GameSessionState = {
  177. ...state,
  178. completedControlIds,
  179. currentTargetControlId: nextTarget ? nextTarget.id : null,
  180. inRangeControlId: null,
  181. score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
  182. status: nextTarget || !definition.autoFinishOnLastControl ? state.status : 'finished',
  183. endedAt: nextTarget || !definition.autoFinishOnLastControl ? state.endedAt : at,
  184. }
  185. const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
  186. if (!nextTarget && definition.autoFinishOnLastControl) {
  187. effects.push({ type: 'session_finished' })
  188. }
  189. return {
  190. nextState,
  191. presentation: buildPresentation(definition, nextState),
  192. effects,
  193. }
  194. }
  195. export class ClassicSequentialRule implements RulePlugin {
  196. get mode(): 'classic-sequential' {
  197. return 'classic-sequential'
  198. }
  199. initialize(definition: GameDefinition): GameSessionState {
  200. return {
  201. status: 'idle',
  202. startedAt: null,
  203. endedAt: null,
  204. completedControlIds: [],
  205. currentTargetControlId: getInitialTargetId(definition),
  206. inRangeControlId: null,
  207. score: 0,
  208. }
  209. }
  210. buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
  211. return buildPresentation(definition, state)
  212. }
  213. reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult {
  214. if (event.type === 'session_started') {
  215. const nextState: GameSessionState = {
  216. ...state,
  217. status: 'running',
  218. startedAt: event.at,
  219. endedAt: null,
  220. inRangeControlId: null,
  221. }
  222. return {
  223. nextState,
  224. presentation: buildPresentation(definition, nextState),
  225. effects: [{ type: 'session_started' }],
  226. }
  227. }
  228. if (event.type === 'session_ended') {
  229. const nextState: GameSessionState = {
  230. ...state,
  231. status: 'finished',
  232. endedAt: event.at,
  233. }
  234. return {
  235. nextState,
  236. presentation: buildPresentation(definition, nextState),
  237. effects: [{ type: 'session_finished' }],
  238. }
  239. }
  240. if (state.status !== 'running' || !state.currentTargetControlId) {
  241. return {
  242. nextState: state,
  243. presentation: buildPresentation(definition, state),
  244. effects: [],
  245. }
  246. }
  247. const currentTarget = getCurrentTarget(definition, state)
  248. if (!currentTarget) {
  249. return {
  250. nextState: state,
  251. presentation: buildPresentation(definition, state),
  252. effects: [],
  253. }
  254. }
  255. if (event.type === 'gps_updated') {
  256. const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
  257. const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? currentTarget.id : null
  258. const nextState: GameSessionState = {
  259. ...state,
  260. inRangeControlId,
  261. }
  262. if (definition.punchPolicy === 'enter' && inRangeControlId === currentTarget.id) {
  263. return applyCompletion(definition, nextState, currentTarget, event.at)
  264. }
  265. return {
  266. nextState,
  267. presentation: buildPresentation(definition, nextState),
  268. effects: [],
  269. }
  270. }
  271. if (event.type === 'punch_requested') {
  272. if (state.inRangeControlId !== currentTarget.id) {
  273. return {
  274. nextState: state,
  275. presentation: buildPresentation(definition, state),
  276. effects: [{ type: 'punch_feedback', text: currentTarget.kind === 'start' ? '未进入开始点打卡范围' : currentTarget.kind === 'finish' ? '未进入终点打卡范围' : '未进入目标打点范围', tone: 'warning' }],
  277. }
  278. }
  279. return applyCompletion(definition, state, currentTarget, event.at)
  280. }
  281. return {
  282. nextState: state,
  283. presentation: buildPresentation(definition, state),
  284. effects: [],
  285. }
  286. }
  287. }