scoreORule.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. import { type LonLatPoint } from '../../utils/projection'
  2. import { DEFAULT_GAME_AUDIO_CONFIG } from '../audio/audioConfig'
  3. import { type GameControl, type GameDefinition } from '../core/gameDefinition'
  4. import { type GameEvent } from '../core/gameEvent'
  5. import { type GameEffect, type GameResult } from '../core/gameResult'
  6. import { type GameSessionState } from '../core/gameSessionState'
  7. import { type GamePresentationState } from '../presentation/presentationState'
  8. import { type HudPresentationState } from '../presentation/hudPresentationState'
  9. import { type MapPresentationState } from '../presentation/mapPresentationState'
  10. import { type RulePlugin } from './rulePlugin'
  11. type ScoreOModeState = {
  12. phase: 'start' | 'controls' | 'finish' | 'done'
  13. focusedControlId: string | null
  14. }
  15. function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
  16. const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
  17. const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
  18. const dy = (b.lat - a.lat) * 110540
  19. return Math.sqrt(dx * dx + dy * dy)
  20. }
  21. function getStartControl(definition: GameDefinition): GameControl | null {
  22. return definition.controls.find((control) => control.kind === 'start') || null
  23. }
  24. function getFinishControl(definition: GameDefinition): GameControl | null {
  25. return definition.controls.find((control) => control.kind === 'finish') || null
  26. }
  27. function getScoreControls(definition: GameDefinition): GameControl[] {
  28. return definition.controls.filter((control) => control.kind === 'control')
  29. }
  30. function getControlScore(control: GameControl): number {
  31. return control.kind === 'control' && typeof control.score === 'number' ? control.score : 0
  32. }
  33. function getRemainingScoreControls(definition: GameDefinition, state: GameSessionState): GameControl[] {
  34. return getScoreControls(definition).filter((control) => !state.completedControlIds.includes(control.id))
  35. }
  36. function getModeState(state: GameSessionState): ScoreOModeState {
  37. const rawModeState = state.modeState as Partial<ScoreOModeState> | null
  38. return {
  39. phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start',
  40. focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null,
  41. }
  42. }
  43. function withModeState(state: GameSessionState, modeState: ScoreOModeState): GameSessionState {
  44. return {
  45. ...state,
  46. modeState,
  47. }
  48. }
  49. function canFocusFinish(definition: GameDefinition, state: GameSessionState): boolean {
  50. const startControl = getStartControl(definition)
  51. const finishControl = getFinishControl(definition)
  52. const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
  53. const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
  54. return completedStart && !completedFinish
  55. }
  56. function getNearestRemainingControl(
  57. definition: GameDefinition,
  58. state: GameSessionState,
  59. referencePoint?: LonLatPoint | null,
  60. ): GameControl | null {
  61. const remainingControls = getRemainingScoreControls(definition, state)
  62. if (!remainingControls.length) {
  63. return getFinishControl(definition)
  64. }
  65. if (!referencePoint) {
  66. return remainingControls[0]
  67. }
  68. let nearestControl = remainingControls[0]
  69. let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point)
  70. for (let index = 1; index < remainingControls.length; index += 1) {
  71. const control = remainingControls[index]
  72. const distance = getApproxDistanceMeters(referencePoint, control.point)
  73. if (distance < nearestDistance) {
  74. nearestControl = control
  75. nearestDistance = distance
  76. }
  77. }
  78. return nearestControl
  79. }
  80. function getFocusedTarget(
  81. definition: GameDefinition,
  82. state: GameSessionState,
  83. remainingControls?: GameControl[],
  84. ): GameControl | null {
  85. const modeState = getModeState(state)
  86. if (!modeState.focusedControlId) {
  87. return null
  88. }
  89. const controls = remainingControls || getRemainingScoreControls(definition, state)
  90. for (const control of controls) {
  91. if (control.id === modeState.focusedControlId) {
  92. return control
  93. }
  94. }
  95. const finishControl = getFinishControl(definition)
  96. if (finishControl && canFocusFinish(definition, state) && finishControl.id === modeState.focusedControlId) {
  97. return finishControl
  98. }
  99. return null
  100. }
  101. function resolveInteractiveTarget(
  102. definition: GameDefinition,
  103. modeState: ScoreOModeState,
  104. primaryTarget: GameControl | null,
  105. focusedTarget: GameControl | null,
  106. ): GameControl | null {
  107. if (modeState.phase === 'start') {
  108. return primaryTarget
  109. }
  110. if (definition.requiresFocusSelection) {
  111. return focusedTarget
  112. }
  113. return focusedTarget || primaryTarget
  114. }
  115. function getNearestInRangeControl(
  116. controls: GameControl[],
  117. referencePoint: LonLatPoint,
  118. radiusMeters: number,
  119. ): GameControl | null {
  120. let nearest: GameControl | null = null
  121. let nearestDistance = Number.POSITIVE_INFINITY
  122. for (const control of controls) {
  123. const distance = getApproxDistanceMeters(control.point, referencePoint)
  124. if (distance > radiusMeters) {
  125. continue
  126. }
  127. if (!nearest || distance < nearestDistance) {
  128. nearest = control
  129. nearestDistance = distance
  130. }
  131. }
  132. return nearest
  133. }
  134. function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
  135. if (distanceMeters <= definition.punchRadiusMeters) {
  136. return 'ready'
  137. }
  138. const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
  139. if (distanceMeters <= approachDistanceMeters) {
  140. return 'approaching'
  141. }
  142. return 'searching'
  143. }
  144. function getGuidanceEffects(
  145. previousState: GameSessionState['guidanceState'],
  146. nextState: GameSessionState['guidanceState'],
  147. controlId: string | null,
  148. ): GameEffect[] {
  149. if (previousState === nextState) {
  150. return []
  151. }
  152. return [{ type: 'guidance_state_changed', guidanceState: nextState, controlId }]
  153. }
  154. function getDisplayTargetLabel(control: GameControl | null): string {
  155. if (!control) {
  156. return '目标点'
  157. }
  158. if (control.kind === 'start') {
  159. return '开始点'
  160. }
  161. if (control.kind === 'finish') {
  162. return '终点'
  163. }
  164. return '目标点'
  165. }
  166. function buildPunchHintText(
  167. definition: GameDefinition,
  168. state: GameSessionState,
  169. primaryTarget: GameControl | null,
  170. focusedTarget: GameControl | null,
  171. ): string {
  172. if (state.status === 'idle') {
  173. return '点击开始后先打开始点'
  174. }
  175. if (state.status === 'finished') {
  176. return '本局已完成'
  177. }
  178. const modeState = getModeState(state)
  179. if (modeState.phase === 'controls' || modeState.phase === 'finish') {
  180. if (definition.requiresFocusSelection && !focusedTarget) {
  181. return modeState.phase === 'finish'
  182. ? '点击地图选中终点后结束比赛'
  183. : '点击地图选中一个目标点'
  184. }
  185. const displayTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
  186. const targetLabel = getDisplayTargetLabel(displayTarget)
  187. if (displayTarget && state.inRangeControlId === displayTarget.id) {
  188. return definition.punchPolicy === 'enter'
  189. ? `${targetLabel}内,自动打点中`
  190. : `${targetLabel}内,可点击打点`
  191. }
  192. return definition.punchPolicy === 'enter'
  193. ? `进入${targetLabel}自动打点`
  194. : `进入${targetLabel}后点击打点`
  195. }
  196. const targetLabel = getDisplayTargetLabel(primaryTarget)
  197. if (state.inRangeControlId && primaryTarget && state.inRangeControlId === primaryTarget.id) {
  198. return definition.punchPolicy === 'enter'
  199. ? `${targetLabel}内,自动打点中`
  200. : `${targetLabel}内,可点击打点`
  201. }
  202. return definition.punchPolicy === 'enter'
  203. ? `进入${targetLabel}自动打点`
  204. : `进入${targetLabel}后点击打点`
  205. }
  206. function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect {
  207. const allowAutoPopup = punchPolicy === 'enter'
  208. ? false
  209. : (control.displayContent ? control.displayContent.autoPopup : true)
  210. if (control.kind === 'start') {
  211. return {
  212. type: 'control_completed',
  213. controlId: control.id,
  214. controlKind: 'start',
  215. sequence: null,
  216. label: control.label,
  217. displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
  218. displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,开始自由打点。',
  219. displayAutoPopup: allowAutoPopup,
  220. displayOnce: control.displayContent ? control.displayContent.once : false,
  221. displayPriority: control.displayContent ? control.displayContent.priority : 1,
  222. }
  223. }
  224. if (control.kind === 'finish') {
  225. return {
  226. type: 'control_completed',
  227. controlId: control.id,
  228. controlKind: 'finish',
  229. sequence: null,
  230. label: control.label,
  231. displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
  232. displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
  233. displayAutoPopup: allowAutoPopup,
  234. displayOnce: control.displayContent ? control.displayContent.once : false,
  235. displayPriority: control.displayContent ? control.displayContent.priority : 2,
  236. }
  237. }
  238. const sequenceText = typeof control.sequence === 'number' ? String(control.sequence) : control.label
  239. return {
  240. type: 'control_completed',
  241. controlId: control.id,
  242. controlKind: 'control',
  243. sequence: control.sequence,
  244. label: control.label,
  245. displayTitle: control.displayContent ? control.displayContent.title : `收集 ${sequenceText}`,
  246. displayBody: control.displayContent ? control.displayContent.body : control.label,
  247. displayAutoPopup: allowAutoPopup,
  248. displayOnce: control.displayContent ? control.displayContent.once : false,
  249. displayPriority: control.displayContent ? control.displayContent.priority : 1,
  250. }
  251. }
  252. function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
  253. const modeState = getModeState(state)
  254. const running = state.status === 'running'
  255. const startControl = getStartControl(definition)
  256. const finishControl = getFinishControl(definition)
  257. const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
  258. const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
  259. const remainingControls = getRemainingScoreControls(definition, state)
  260. const scoreControls = getScoreControls(definition)
  261. const primaryTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null
  262. const focusedTarget = getFocusedTarget(definition, state, remainingControls)
  263. const canSelectFinish = running && completedStart && !completedFinish && !!finishControl
  264. const activeControlIds = running && modeState.phase === 'controls'
  265. ? remainingControls.map((control) => control.id)
  266. : []
  267. const activeControlSequences = running && modeState.phase === 'controls'
  268. ? remainingControls
  269. .filter((control) => typeof control.sequence === 'number')
  270. .map((control) => control.sequence as number)
  271. : []
  272. const completedControls = scoreControls.filter((control) => state.completedControlIds.includes(control.id))
  273. const completedControlSequences = completedControls
  274. .filter((control) => typeof control.sequence === 'number')
  275. .map((control) => control.sequence as number)
  276. const revealFullCourse = completedStart
  277. const interactiveTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
  278. const punchButtonEnabled = running
  279. && definition.punchPolicy === 'enter-confirm'
  280. && !!interactiveTarget
  281. && state.inRangeControlId === interactiveTarget.id
  282. const mapPresentation: MapPresentationState = {
  283. controlVisualMode: modeState.phase === 'controls' ? 'multi-target' : 'single-target',
  284. showCourseLegs: false,
  285. guidanceLegAnimationEnabled: false,
  286. focusableControlIds: canSelectFinish
  287. ? [...activeControlIds, finishControl!.id]
  288. : activeControlIds.slice(),
  289. focusedControlId: focusedTarget ? focusedTarget.id : null,
  290. focusedControlSequences: focusedTarget && focusedTarget.kind === 'control' && typeof focusedTarget.sequence === 'number'
  291. ? [focusedTarget.sequence]
  292. : [],
  293. activeControlIds,
  294. activeControlSequences,
  295. activeStart: running && modeState.phase === 'start',
  296. completedStart,
  297. activeFinish: running && modeState.phase === 'finish',
  298. focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish',
  299. completedFinish,
  300. revealFullCourse,
  301. activeLegIndices: [],
  302. completedLegIndices: [],
  303. completedControlIds: completedControls.map((control) => control.id),
  304. completedControlSequences,
  305. skippedControlIds: [],
  306. skippedControlSequences: [],
  307. }
  308. const hudPresentation: HudPresentationState = {
  309. actionTagText: modeState.phase === 'start'
  310. ? '目标'
  311. : focusedTarget && focusedTarget.kind === 'finish'
  312. ? '终点'
  313. : modeState.phase === 'finish'
  314. ? '终点'
  315. : '自由',
  316. distanceTagText: modeState.phase === 'start'
  317. ? '点距'
  318. : focusedTarget && focusedTarget.kind === 'finish'
  319. ? '终点距'
  320. : focusedTarget
  321. ? '选中点距'
  322. : modeState.phase === 'finish'
  323. ? '终点距'
  324. : '最近点距',
  325. hudTargetControlId: focusedTarget
  326. ? focusedTarget.id
  327. : primaryTarget
  328. ? primaryTarget.id
  329. : null,
  330. progressText: `已收集 ${completedControls.length}/${scoreControls.length}`,
  331. punchableControlId: punchButtonEnabled && interactiveTarget ? interactiveTarget.id : null,
  332. punchButtonEnabled,
  333. punchButtonText: modeState.phase === 'start'
  334. ? '开始打卡'
  335. : focusedTarget && focusedTarget.kind === 'finish'
  336. ? '结束打卡'
  337. : modeState.phase === 'finish'
  338. ? '结束打卡'
  339. : '打点',
  340. punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget),
  341. }
  342. return {
  343. map: mapPresentation,
  344. hud: hudPresentation,
  345. }
  346. }
  347. function applyCompletion(
  348. definition: GameDefinition,
  349. state: GameSessionState,
  350. control: GameControl,
  351. at: number,
  352. referencePoint: LonLatPoint | null,
  353. ): GameResult {
  354. const completedControlIds = state.completedControlIds.includes(control.id)
  355. ? state.completedControlIds
  356. : [...state.completedControlIds, control.id]
  357. const previousModeState = getModeState(state)
  358. const nextStateDraft: GameSessionState = {
  359. ...state,
  360. startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt,
  361. endedAt: control.kind === 'finish' ? at : state.endedAt,
  362. completedControlIds,
  363. currentTargetControlId: null,
  364. inRangeControlId: null,
  365. score: getScoreControls(definition)
  366. .filter((item) => completedControlIds.includes(item.id))
  367. .reduce((sum, item) => sum + getControlScore(item), 0),
  368. status: control.kind === 'finish' ? 'finished' : state.status,
  369. guidanceState: 'searching',
  370. }
  371. const remainingControls = getRemainingScoreControls(definition, nextStateDraft)
  372. let phase: ScoreOModeState['phase']
  373. if (control.kind === 'finish') {
  374. phase = 'done'
  375. } else if (control.kind === 'start') {
  376. phase = remainingControls.length ? 'controls' : 'finish'
  377. } else {
  378. phase = remainingControls.length ? 'controls' : 'finish'
  379. }
  380. const nextModeState: ScoreOModeState = {
  381. phase,
  382. focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId,
  383. }
  384. const nextPrimaryTarget = phase === 'controls'
  385. ? getNearestRemainingControl(definition, nextStateDraft, referencePoint)
  386. : phase === 'finish'
  387. ? getFinishControl(definition)
  388. : null
  389. const nextState = withModeState({
  390. ...nextStateDraft,
  391. currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
  392. }, nextModeState)
  393. const effects: GameEffect[] = [buildCompletedEffect(control, definition.punchPolicy)]
  394. if (control.kind === 'finish') {
  395. effects.push({ type: 'session_finished' })
  396. }
  397. return {
  398. nextState,
  399. presentation: buildPresentation(definition, nextState),
  400. effects,
  401. }
  402. }
  403. export class ScoreORule implements RulePlugin {
  404. get mode(): 'score-o' {
  405. return 'score-o'
  406. }
  407. initialize(definition: GameDefinition): GameSessionState {
  408. const startControl = getStartControl(definition)
  409. return {
  410. status: 'idle',
  411. startedAt: null,
  412. endedAt: null,
  413. completedControlIds: [],
  414. skippedControlIds: [],
  415. currentTargetControlId: startControl ? startControl.id : null,
  416. inRangeControlId: null,
  417. score: 0,
  418. guidanceState: 'searching',
  419. modeState: {
  420. phase: 'start',
  421. focusedControlId: null,
  422. },
  423. }
  424. }
  425. buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
  426. return buildPresentation(definition, state)
  427. }
  428. reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult {
  429. if (event.type === 'session_started') {
  430. const startControl = getStartControl(definition)
  431. const nextState = withModeState({
  432. ...state,
  433. status: 'running',
  434. startedAt: null,
  435. endedAt: null,
  436. currentTargetControlId: startControl ? startControl.id : null,
  437. inRangeControlId: null,
  438. guidanceState: 'searching',
  439. }, {
  440. phase: 'start',
  441. focusedControlId: null,
  442. })
  443. return {
  444. nextState,
  445. presentation: buildPresentation(definition, nextState),
  446. effects: [{ type: 'session_started' }],
  447. }
  448. }
  449. if (event.type === 'session_ended') {
  450. const nextState = withModeState({
  451. ...state,
  452. status: 'finished',
  453. endedAt: event.at,
  454. guidanceState: 'searching',
  455. }, {
  456. phase: 'done',
  457. focusedControlId: null,
  458. })
  459. return {
  460. nextState,
  461. presentation: buildPresentation(definition, nextState),
  462. effects: [{ type: 'session_finished' }],
  463. }
  464. }
  465. if (state.status !== 'running') {
  466. return {
  467. nextState: state,
  468. presentation: buildPresentation(definition, state),
  469. effects: [],
  470. }
  471. }
  472. const modeState = getModeState(state)
  473. const targetControl = state.currentTargetControlId
  474. ? definition.controls.find((control) => control.id === state.currentTargetControlId) || null
  475. : null
  476. if (event.type === 'gps_updated') {
  477. const referencePoint = { lon: event.lon, lat: event.lat }
  478. const remainingControls = getRemainingScoreControls(definition, state)
  479. const focusedTarget = getFocusedTarget(definition, state, remainingControls)
  480. let nextPrimaryTarget = targetControl
  481. let guidanceTarget = targetControl
  482. let punchTarget: GameControl | null = null
  483. if (modeState.phase === 'controls') {
  484. nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint)
  485. guidanceTarget = focusedTarget || nextPrimaryTarget
  486. if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
  487. punchTarget = focusedTarget
  488. } else if (!definition.requiresFocusSelection) {
  489. punchTarget = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
  490. }
  491. } else if (modeState.phase === 'finish') {
  492. nextPrimaryTarget = getFinishControl(definition)
  493. guidanceTarget = focusedTarget || nextPrimaryTarget
  494. if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
  495. punchTarget = focusedTarget
  496. } else if (!definition.requiresFocusSelection && nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) {
  497. punchTarget = nextPrimaryTarget
  498. }
  499. } else if (targetControl) {
  500. guidanceTarget = targetControl
  501. if (getApproxDistanceMeters(targetControl.point, referencePoint) <= definition.punchRadiusMeters) {
  502. punchTarget = targetControl
  503. }
  504. }
  505. const guidanceState = guidanceTarget
  506. ? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint))
  507. : 'searching'
  508. const nextState: GameSessionState = {
  509. ...state,
  510. currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
  511. inRangeControlId: punchTarget ? punchTarget.id : null,
  512. guidanceState,
  513. }
  514. const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null)
  515. if (definition.punchPolicy === 'enter' && punchTarget) {
  516. const completionResult = applyCompletion(definition, nextState, punchTarget, event.at, referencePoint)
  517. return {
  518. ...completionResult,
  519. effects: [...guidanceEffects, ...completionResult.effects],
  520. }
  521. }
  522. return {
  523. nextState,
  524. presentation: buildPresentation(definition, nextState),
  525. effects: guidanceEffects,
  526. }
  527. }
  528. if (event.type === 'control_focused') {
  529. if (modeState.phase !== 'controls' && modeState.phase !== 'finish') {
  530. return {
  531. nextState: state,
  532. presentation: buildPresentation(definition, state),
  533. effects: [],
  534. }
  535. }
  536. const focusableControlIds = getRemainingScoreControls(definition, state).map((control) => control.id)
  537. const finishControl = getFinishControl(definition)
  538. if (finishControl && canFocusFinish(definition, state)) {
  539. focusableControlIds.push(finishControl.id)
  540. }
  541. const nextFocusedControlId = event.controlId && focusableControlIds.includes(event.controlId)
  542. ? modeState.focusedControlId === event.controlId
  543. ? null
  544. : event.controlId
  545. : null
  546. const nextState = withModeState({
  547. ...state,
  548. }, {
  549. ...modeState,
  550. focusedControlId: nextFocusedControlId,
  551. })
  552. return {
  553. nextState,
  554. presentation: buildPresentation(definition, nextState),
  555. effects: [],
  556. }
  557. }
  558. if (event.type === 'punch_requested') {
  559. const focusedTarget = getFocusedTarget(definition, state)
  560. if (definition.requiresFocusSelection && (modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
  561. return {
  562. nextState: state,
  563. presentation: buildPresentation(definition, state),
  564. effects: [{ type: 'punch_feedback', text: modeState.phase === 'finish' ? '请先选中终点' : '请先选中目标点', tone: 'warning' }],
  565. }
  566. }
  567. let controlToPunch: GameControl | null = null
  568. if (state.inRangeControlId) {
  569. controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
  570. }
  571. if (!controlToPunch || (definition.requiresFocusSelection && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
  572. return {
  573. nextState: state,
  574. presentation: buildPresentation(definition, state),
  575. effects: [{
  576. type: 'punch_feedback',
  577. text: focusedTarget
  578. ? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围`
  579. : modeState.phase === 'start'
  580. ? '未进入开始点打卡范围'
  581. : '未进入目标打点范围',
  582. tone: 'warning',
  583. }],
  584. }
  585. }
  586. return applyCompletion(definition, state, controlToPunch, event.at, this.getReferencePoint(definition, state, controlToPunch))
  587. }
  588. return {
  589. nextState: state,
  590. presentation: buildPresentation(definition, state),
  591. effects: [],
  592. }
  593. }
  594. private getReferencePoint(definition: GameDefinition, state: GameSessionState, completedControl: GameControl): LonLatPoint | null {
  595. if (completedControl.kind === 'control') {
  596. const remaining = getRemainingScoreControls(definition, {
  597. ...state,
  598. completedControlIds: [...state.completedControlIds, completedControl.id],
  599. })
  600. return remaining.length ? completedControl.point : (getFinishControl(definition) ? getFinishControl(definition)!.point : completedControl.point)
  601. }
  602. return completedControl.point
  603. }
  604. }