scoreORule.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934
  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. guidanceControlId: string | null
  15. }
  16. function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
  17. const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
  18. const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
  19. const dy = (b.lat - a.lat) * 110540
  20. return Math.sqrt(dx * dx + dy * dy)
  21. }
  22. function getStartControl(definition: GameDefinition): GameControl | null {
  23. return definition.controls.find((control) => control.kind === 'start') || null
  24. }
  25. function getFinishControl(definition: GameDefinition): GameControl | null {
  26. return definition.controls.find((control) => control.kind === 'finish') || null
  27. }
  28. function getScoreControls(definition: GameDefinition): GameControl[] {
  29. return definition.controls.filter((control) => control.kind === 'control')
  30. }
  31. function getControlScore(control: GameControl): number {
  32. return control.kind === 'control' && typeof control.score === 'number' ? control.score : 0
  33. }
  34. function getRemainingScoreControls(definition: GameDefinition, state: GameSessionState): GameControl[] {
  35. return getScoreControls(definition).filter((control) => !state.completedControlIds.includes(control.id))
  36. }
  37. function getModeState(state: GameSessionState): ScoreOModeState {
  38. const rawModeState = state.modeState as Partial<ScoreOModeState> | null
  39. return {
  40. phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start',
  41. focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null,
  42. guidanceControlId: rawModeState && typeof rawModeState.guidanceControlId === 'string' ? rawModeState.guidanceControlId : null,
  43. }
  44. }
  45. function withModeState(state: GameSessionState, modeState: ScoreOModeState): GameSessionState {
  46. return {
  47. ...state,
  48. modeState,
  49. }
  50. }
  51. function hasCompletedEnoughControlsForFinish(definition: GameDefinition, state: GameSessionState): boolean {
  52. const completedScoreControls = getScoreControls(definition)
  53. .filter((control) => state.completedControlIds.includes(control.id))
  54. .length
  55. return completedScoreControls >= definition.minCompletedControlsBeforeFinish
  56. }
  57. function canFocusFinish(definition: GameDefinition, state: GameSessionState): boolean {
  58. const startControl = getStartControl(definition)
  59. const finishControl = getFinishControl(definition)
  60. const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
  61. const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
  62. return completedStart && !completedFinish && hasCompletedEnoughControlsForFinish(definition, state)
  63. }
  64. function isFinishPunchAvailable(
  65. definition: GameDefinition,
  66. state: GameSessionState,
  67. _modeState: ScoreOModeState,
  68. ): boolean {
  69. return canFocusFinish(definition, state)
  70. }
  71. function getNearestRemainingControl(
  72. definition: GameDefinition,
  73. state: GameSessionState,
  74. referencePoint?: LonLatPoint | null,
  75. ): GameControl | null {
  76. const remainingControls = getRemainingScoreControls(definition, state)
  77. if (!remainingControls.length) {
  78. return getFinishControl(definition)
  79. }
  80. if (!referencePoint) {
  81. return remainingControls[0]
  82. }
  83. let nearestControl = remainingControls[0]
  84. let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point)
  85. for (let index = 1; index < remainingControls.length; index += 1) {
  86. const control = remainingControls[index]
  87. const distance = getApproxDistanceMeters(referencePoint, control.point)
  88. if (distance < nearestDistance) {
  89. nearestControl = control
  90. nearestDistance = distance
  91. }
  92. }
  93. return nearestControl
  94. }
  95. function getNearestGuidanceTarget(
  96. definition: GameDefinition,
  97. state: GameSessionState,
  98. modeState: ScoreOModeState,
  99. referencePoint: LonLatPoint,
  100. ): GameControl | null {
  101. const candidates = getRemainingScoreControls(definition, state).slice()
  102. if (isFinishPunchAvailable(definition, state, modeState)) {
  103. const finishControl = getFinishControl(definition)
  104. if (finishControl && !state.completedControlIds.includes(finishControl.id)) {
  105. candidates.push(finishControl)
  106. }
  107. }
  108. if (!candidates.length) {
  109. return null
  110. }
  111. let nearestControl = candidates[0]
  112. let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point)
  113. for (let index = 1; index < candidates.length; index += 1) {
  114. const control = candidates[index]
  115. const distance = getApproxDistanceMeters(referencePoint, control.point)
  116. if (distance < nearestDistance) {
  117. nearestControl = control
  118. nearestDistance = distance
  119. }
  120. }
  121. return nearestControl
  122. }
  123. function getFocusedTarget(
  124. definition: GameDefinition,
  125. state: GameSessionState,
  126. remainingControls?: GameControl[],
  127. ): GameControl | null {
  128. const modeState = getModeState(state)
  129. if (!modeState.focusedControlId) {
  130. return null
  131. }
  132. const controls = remainingControls || getRemainingScoreControls(definition, state)
  133. for (const control of controls) {
  134. if (control.id === modeState.focusedControlId) {
  135. return control
  136. }
  137. }
  138. const finishControl = getFinishControl(definition)
  139. if (finishControl && canFocusFinish(definition, state) && finishControl.id === modeState.focusedControlId) {
  140. return finishControl
  141. }
  142. return null
  143. }
  144. function resolveInteractiveTarget(
  145. definition: GameDefinition,
  146. state: GameSessionState,
  147. modeState: ScoreOModeState,
  148. primaryTarget: GameControl | null,
  149. focusedTarget: GameControl | null,
  150. ): GameControl | null {
  151. if (modeState.phase === 'start') {
  152. return primaryTarget
  153. }
  154. if (modeState.phase === 'finish') {
  155. return primaryTarget
  156. }
  157. if (definition.requiresFocusSelection) {
  158. return focusedTarget
  159. }
  160. if (focusedTarget) {
  161. return focusedTarget
  162. }
  163. if (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState)) {
  164. return getFinishControl(definition)
  165. }
  166. return primaryTarget
  167. }
  168. function getNearestInRangeControl(
  169. controls: GameControl[],
  170. referencePoint: LonLatPoint,
  171. radiusMeters: number,
  172. ): GameControl | null {
  173. let nearest: GameControl | null = null
  174. let nearestDistance = Number.POSITIVE_INFINITY
  175. for (const control of controls) {
  176. const distance = getApproxDistanceMeters(control.point, referencePoint)
  177. if (distance > radiusMeters) {
  178. continue
  179. }
  180. if (!nearest || distance < nearestDistance) {
  181. nearest = control
  182. nearestDistance = distance
  183. }
  184. }
  185. return nearest
  186. }
  187. function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
  188. const audioConfig = definition.audioConfig || DEFAULT_GAME_AUDIO_CONFIG
  189. const readyDistanceMeters = Math.max(definition.punchRadiusMeters, audioConfig.readyDistanceMeters)
  190. const approachDistanceMeters = Math.max(readyDistanceMeters, audioConfig.approachDistanceMeters)
  191. const distantDistanceMeters = Math.max(approachDistanceMeters, audioConfig.distantDistanceMeters)
  192. if (distanceMeters <= readyDistanceMeters) {
  193. return 'ready'
  194. }
  195. if (distanceMeters <= approachDistanceMeters) {
  196. return 'approaching'
  197. }
  198. if (distanceMeters <= distantDistanceMeters) {
  199. return 'distant'
  200. }
  201. return 'searching'
  202. }
  203. function getGuidanceEffects(
  204. previousState: GameSessionState['guidanceState'],
  205. nextState: GameSessionState['guidanceState'],
  206. controlId: string | null,
  207. ): GameEffect[] {
  208. if (previousState === nextState) {
  209. return []
  210. }
  211. return [{ type: 'guidance_state_changed', guidanceState: nextState, controlId }]
  212. }
  213. function getDisplayTargetLabel(control: GameControl | null): string {
  214. if (!control) {
  215. return '目标点'
  216. }
  217. if (control.kind === 'start') {
  218. return '开始点'
  219. }
  220. if (control.kind === 'finish') {
  221. return '终点'
  222. }
  223. return '目标点'
  224. }
  225. function buildPunchHintText(
  226. definition: GameDefinition,
  227. state: GameSessionState,
  228. primaryTarget: GameControl | null,
  229. focusedTarget: GameControl | null,
  230. ): string {
  231. if (state.status === 'idle') {
  232. return '先打开始点即可正式开始比赛'
  233. }
  234. if (state.status === 'finished') {
  235. return '本局已完成'
  236. }
  237. const modeState = getModeState(state)
  238. if (modeState.phase === 'controls' || modeState.phase === 'finish') {
  239. if (modeState.phase === 'controls' && definition.requiresFocusSelection && !focusedTarget) {
  240. return '点击地图选中一个目标点'
  241. }
  242. const displayTarget = resolveInteractiveTarget(definition, state, modeState, primaryTarget, focusedTarget)
  243. const targetLabel = getDisplayTargetLabel(displayTarget)
  244. if (displayTarget && state.inRangeControlId === displayTarget.id) {
  245. return definition.punchPolicy === 'enter'
  246. ? `${targetLabel}内,自动打点中`
  247. : `${targetLabel}内,可点击打点`
  248. }
  249. return definition.punchPolicy === 'enter'
  250. ? `进入${targetLabel}自动打点`
  251. : `进入${targetLabel}后点击打点`
  252. }
  253. const targetLabel = getDisplayTargetLabel(primaryTarget)
  254. if (state.inRangeControlId && primaryTarget && state.inRangeControlId === primaryTarget.id) {
  255. return definition.punchPolicy === 'enter'
  256. ? `${targetLabel}内,自动打点中`
  257. : `${targetLabel}内,可点击打点`
  258. }
  259. return definition.punchPolicy === 'enter'
  260. ? `进入${targetLabel}自动打点`
  261. : `进入${targetLabel}后点击打点`
  262. }
  263. function buildTargetSummaryText(
  264. definition: GameDefinition,
  265. state: GameSessionState,
  266. primaryTarget: GameControl | null,
  267. focusedTarget: GameControl | null,
  268. ): string {
  269. if (state.status === 'idle') {
  270. return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点'
  271. }
  272. if (state.status === 'finished') {
  273. return '本局已完成'
  274. }
  275. const modeState = getModeState(state)
  276. if (modeState.phase === 'start') {
  277. return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点'
  278. }
  279. if (modeState.phase === 'finish') {
  280. return primaryTarget ? `${primaryTarget.label} / 可随时结束` : '可前往终点结束'
  281. }
  282. if (focusedTarget && focusedTarget.kind === 'control') {
  283. return `${focusedTarget.label} / ${getControlScore(focusedTarget)} 分目标`
  284. }
  285. if (focusedTarget && focusedTarget.kind === 'finish') {
  286. return `${focusedTarget.label} / 结束比赛`
  287. }
  288. if (definition.requiresFocusSelection) {
  289. return '请选择目标点'
  290. }
  291. if (primaryTarget && primaryTarget.kind === 'control') {
  292. return `${primaryTarget.label} / ${getControlScore(primaryTarget)} 分目标`
  293. }
  294. return primaryTarget ? primaryTarget.label : '自由打点'
  295. }
  296. function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect {
  297. const allowAutoPopup = punchPolicy === 'enter'
  298. ? false
  299. : (control.displayContent ? control.displayContent.autoPopup : true)
  300. const autoOpenQuiz = control.kind === 'control'
  301. && !!control.displayContent
  302. && control.displayContent.ctas.some((item) => item.type === 'quiz')
  303. if (control.kind === 'start') {
  304. return {
  305. type: 'control_completed',
  306. controlId: control.id,
  307. controlKind: 'start',
  308. sequence: null,
  309. label: control.label,
  310. displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
  311. displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,开始自由打点。',
  312. displayAutoPopup: allowAutoPopup,
  313. displayOnce: control.displayContent ? control.displayContent.once : false,
  314. displayPriority: control.displayContent ? control.displayContent.priority : 1,
  315. autoOpenQuiz: false,
  316. }
  317. }
  318. if (control.kind === 'finish') {
  319. return {
  320. type: 'control_completed',
  321. controlId: control.id,
  322. controlKind: 'finish',
  323. sequence: null,
  324. label: control.label,
  325. displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
  326. displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
  327. displayAutoPopup: allowAutoPopup,
  328. displayOnce: control.displayContent ? control.displayContent.once : false,
  329. displayPriority: control.displayContent ? control.displayContent.priority : 2,
  330. autoOpenQuiz: false,
  331. }
  332. }
  333. const sequenceText = typeof control.sequence === 'number' ? String(control.sequence) : control.label
  334. return {
  335. type: 'control_completed',
  336. controlId: control.id,
  337. controlKind: 'control',
  338. sequence: control.sequence,
  339. label: control.label,
  340. displayTitle: control.displayContent ? control.displayContent.title : `收集 ${sequenceText}`,
  341. displayBody: control.displayContent ? control.displayContent.body : control.label,
  342. displayAutoPopup: allowAutoPopup,
  343. displayOnce: control.displayContent ? control.displayContent.once : false,
  344. displayPriority: control.displayContent ? control.displayContent.priority : 1,
  345. autoOpenQuiz,
  346. }
  347. }
  348. function resolvePunchableControl(
  349. definition: GameDefinition,
  350. state: GameSessionState,
  351. modeState: ScoreOModeState,
  352. focusedTarget: GameControl | null,
  353. ): GameControl | null {
  354. if (!state.inRangeControlId) {
  355. return null
  356. }
  357. const inRangeControl = definition.controls.find((control) => control.id === state.inRangeControlId) || null
  358. if (!inRangeControl) {
  359. return null
  360. }
  361. if (modeState.phase === 'start') {
  362. return inRangeControl.kind === 'start' ? inRangeControl : null
  363. }
  364. if (modeState.phase === 'finish') {
  365. return inRangeControl.kind === 'finish' ? inRangeControl : null
  366. }
  367. if (modeState.phase === 'controls') {
  368. if (inRangeControl.kind === 'finish' && isFinishPunchAvailable(definition, state, modeState)) {
  369. return inRangeControl
  370. }
  371. if (definition.requiresFocusSelection) {
  372. return focusedTarget && inRangeControl.id === focusedTarget.id ? inRangeControl : null
  373. }
  374. if (inRangeControl.kind === 'control') {
  375. return inRangeControl
  376. }
  377. }
  378. return null
  379. }
  380. function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
  381. const modeState = getModeState(state)
  382. const running = state.status === 'running'
  383. const startControl = getStartControl(definition)
  384. const finishControl = getFinishControl(definition)
  385. const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
  386. const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
  387. const remainingControls = getRemainingScoreControls(definition, state)
  388. const scoreControls = getScoreControls(definition)
  389. const primaryTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null
  390. const focusedTarget = getFocusedTarget(definition, state, remainingControls)
  391. const canSelectFinish = running && completedStart && !completedFinish && !!finishControl
  392. const activeControlIds = running && modeState.phase === 'controls'
  393. ? remainingControls.map((control) => control.id)
  394. : []
  395. const activeControlSequences = running && modeState.phase === 'controls'
  396. ? remainingControls
  397. .filter((control) => typeof control.sequence === 'number')
  398. .map((control) => control.sequence as number)
  399. : []
  400. const completedControls = scoreControls.filter((control) => state.completedControlIds.includes(control.id))
  401. const completedControlSequences = completedControls
  402. .filter((control) => typeof control.sequence === 'number')
  403. .map((control) => control.sequence as number)
  404. const revealFullCourse = completedStart
  405. const punchableControl = resolvePunchableControl(definition, state, modeState, focusedTarget)
  406. const guidanceControl = modeState.guidanceControlId
  407. ? definition.controls.find((control) => control.id === modeState.guidanceControlId) || null
  408. : null
  409. const punchButtonEnabled = running
  410. && definition.punchPolicy === 'enter-confirm'
  411. && !!punchableControl
  412. const hudTargetControlId = modeState.phase === 'finish'
  413. ? (primaryTarget ? primaryTarget.id : null)
  414. : focusedTarget
  415. ? focusedTarget.id
  416. : modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState)
  417. ? (getFinishControl(definition) ? getFinishControl(definition)!.id : null)
  418. : definition.requiresFocusSelection
  419. ? null
  420. : primaryTarget
  421. ? primaryTarget.id
  422. : null
  423. const highlightedControlId = focusedTarget
  424. ? focusedTarget.id
  425. : punchableControl
  426. ? punchableControl.id
  427. : guidanceControl
  428. ? guidanceControl.id
  429. : null
  430. const showMultiTargetLabels = completedStart && modeState.phase !== 'start'
  431. const mapPresentation: MapPresentationState = {
  432. controlVisualMode: showMultiTargetLabels ? 'multi-target' : 'single-target',
  433. showCourseLegs: false,
  434. guidanceLegAnimationEnabled: false,
  435. focusableControlIds: canSelectFinish
  436. ? [...activeControlIds, finishControl!.id]
  437. : activeControlIds.slice(),
  438. focusedControlId: focusedTarget ? focusedTarget.id : null,
  439. focusedControlSequences: focusedTarget && focusedTarget.kind === 'control' && typeof focusedTarget.sequence === 'number'
  440. ? [focusedTarget.sequence]
  441. : [],
  442. activeControlIds,
  443. activeControlSequences,
  444. activeStart: running && modeState.phase === 'start',
  445. completedStart,
  446. activeFinish: running && (modeState.phase === 'finish' || (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState))),
  447. focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish',
  448. completedFinish,
  449. revealFullCourse,
  450. activeLegIndices: [],
  451. completedLegIndices: [],
  452. completedControlIds: completedControls.map((control) => control.id),
  453. completedControlSequences,
  454. skippedControlIds: [],
  455. skippedControlSequences: [],
  456. }
  457. const hudPresentation: HudPresentationState = {
  458. actionTagText: modeState.phase === 'start'
  459. ? '目标'
  460. : modeState.phase === 'finish'
  461. ? '终点'
  462. : focusedTarget && focusedTarget.kind === 'finish'
  463. ? '终点'
  464. : focusedTarget
  465. ? '目标'
  466. : '自由',
  467. distanceTagText: modeState.phase === 'start'
  468. ? '点距'
  469. : modeState.phase === 'finish'
  470. ? '终点距'
  471. : focusedTarget && focusedTarget.kind === 'finish'
  472. ? '终点距'
  473. : focusedTarget
  474. ? '选中点距'
  475. : '目标距',
  476. targetSummaryText: buildTargetSummaryText(definition, state, primaryTarget, focusedTarget),
  477. hudTargetControlId,
  478. progressText: `已收集 ${completedControls.length}/${scoreControls.length}`,
  479. punchableControlId: punchableControl ? punchableControl.id : null,
  480. punchButtonEnabled,
  481. punchButtonText: modeState.phase === 'start'
  482. ? '开始打卡'
  483. : (punchableControl && punchableControl.kind === 'finish')
  484. ? '结束打卡'
  485. : modeState.phase === 'finish'
  486. ? '结束打卡'
  487. : focusedTarget && focusedTarget.kind === 'finish'
  488. ? '结束打卡'
  489. : '打点',
  490. punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget),
  491. }
  492. return {
  493. map: mapPresentation,
  494. hud: hudPresentation,
  495. targeting: {
  496. punchableControlId: punchableControl ? punchableControl.id : null,
  497. guidanceControlId: guidanceControl ? guidanceControl.id : null,
  498. hudControlId: hudTargetControlId,
  499. highlightedControlId,
  500. },
  501. }
  502. }
  503. function applyCompletion(
  504. definition: GameDefinition,
  505. state: GameSessionState,
  506. control: GameControl,
  507. at: number,
  508. referencePoint: LonLatPoint | null,
  509. ): GameResult {
  510. const completedControlIds = state.completedControlIds.includes(control.id)
  511. ? state.completedControlIds
  512. : [...state.completedControlIds, control.id]
  513. const previousModeState = getModeState(state)
  514. const nextStateDraft: GameSessionState = {
  515. ...state,
  516. endReason: control.kind === 'finish' ? 'completed' : state.endReason,
  517. startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt,
  518. endedAt: control.kind === 'finish' ? at : state.endedAt,
  519. completedControlIds,
  520. currentTargetControlId: null,
  521. inRangeControlId: null,
  522. score: getScoreControls(definition)
  523. .filter((item) => completedControlIds.includes(item.id))
  524. .reduce((sum, item) => sum + getControlScore(item), 0),
  525. status: control.kind === 'finish' ? 'finished' : state.status,
  526. guidanceState: 'searching',
  527. }
  528. const remainingControls = getRemainingScoreControls(definition, nextStateDraft)
  529. let phase: ScoreOModeState['phase']
  530. if (control.kind === 'finish') {
  531. phase = 'done'
  532. } else if (control.kind === 'start') {
  533. phase = remainingControls.length ? 'controls' : 'finish'
  534. } else {
  535. phase = remainingControls.length ? 'controls' : 'finish'
  536. }
  537. const nextPrimaryTarget = phase === 'controls'
  538. ? getNearestRemainingControl(definition, nextStateDraft, referencePoint)
  539. : phase === 'finish'
  540. ? getFinishControl(definition)
  541. : null
  542. const nextModeState: ScoreOModeState = {
  543. phase,
  544. focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId,
  545. guidanceControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
  546. }
  547. const nextState = withModeState({
  548. ...nextStateDraft,
  549. currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
  550. }, nextModeState)
  551. const effects: GameEffect[] = [buildCompletedEffect(control, definition.punchPolicy)]
  552. if (control.kind === 'finish') {
  553. effects.push({ type: 'session_finished' })
  554. }
  555. return {
  556. nextState,
  557. presentation: buildPresentation(definition, nextState),
  558. effects,
  559. }
  560. }
  561. export class ScoreORule implements RulePlugin {
  562. get mode(): 'score-o' {
  563. return 'score-o'
  564. }
  565. initialize(definition: GameDefinition): GameSessionState {
  566. const startControl = getStartControl(definition)
  567. return {
  568. status: 'idle',
  569. endReason: null,
  570. startedAt: null,
  571. endedAt: null,
  572. completedControlIds: [],
  573. skippedControlIds: [],
  574. currentTargetControlId: startControl ? startControl.id : null,
  575. inRangeControlId: null,
  576. score: 0,
  577. guidanceState: 'searching',
  578. modeState: {
  579. phase: 'start',
  580. focusedControlId: null,
  581. guidanceControlId: startControl ? startControl.id : null,
  582. },
  583. }
  584. }
  585. buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
  586. return buildPresentation(definition, state)
  587. }
  588. reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult {
  589. if (event.type === 'session_started') {
  590. const startControl = getStartControl(definition)
  591. const nextState = withModeState({
  592. ...state,
  593. status: 'running',
  594. endReason: null,
  595. startedAt: null,
  596. endedAt: null,
  597. currentTargetControlId: startControl ? startControl.id : null,
  598. inRangeControlId: null,
  599. guidanceState: 'searching',
  600. }, {
  601. phase: 'start',
  602. focusedControlId: null,
  603. guidanceControlId: startControl ? startControl.id : null,
  604. })
  605. return {
  606. nextState,
  607. presentation: buildPresentation(definition, nextState),
  608. effects: [{ type: 'session_started' }],
  609. }
  610. }
  611. if (event.type === 'session_ended') {
  612. const nextState = withModeState({
  613. ...state,
  614. status: 'finished',
  615. endReason: 'completed',
  616. endedAt: event.at,
  617. guidanceState: 'searching',
  618. }, {
  619. phase: 'done',
  620. focusedControlId: null,
  621. guidanceControlId: null,
  622. })
  623. return {
  624. nextState,
  625. presentation: buildPresentation(definition, nextState),
  626. effects: [{ type: 'session_finished' }],
  627. }
  628. }
  629. if (event.type === 'session_timed_out') {
  630. const nextState = withModeState({
  631. ...state,
  632. status: 'failed',
  633. endReason: 'timed_out',
  634. endedAt: event.at,
  635. guidanceState: 'searching',
  636. }, {
  637. phase: 'done',
  638. focusedControlId: null,
  639. guidanceControlId: null,
  640. })
  641. return {
  642. nextState,
  643. presentation: buildPresentation(definition, nextState),
  644. effects: [{ type: 'session_timed_out' }],
  645. }
  646. }
  647. if (state.status !== 'running') {
  648. return {
  649. nextState: state,
  650. presentation: buildPresentation(definition, state),
  651. effects: [],
  652. }
  653. }
  654. const modeState = getModeState(state)
  655. const targetControl = state.currentTargetControlId
  656. ? definition.controls.find((control) => control.id === state.currentTargetControlId) || null
  657. : null
  658. if (event.type === 'gps_updated') {
  659. const referencePoint = { lon: event.lon, lat: event.lat }
  660. const remainingControls = getRemainingScoreControls(definition, state)
  661. const nextStateBase = withModeState(state, modeState)
  662. const focusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls)
  663. let nextPrimaryTarget = targetControl
  664. let guidanceTarget = targetControl
  665. let punchTarget: GameControl | null = null
  666. if (modeState.phase === 'controls') {
  667. nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint)
  668. guidanceTarget = getNearestGuidanceTarget(definition, state, modeState, referencePoint)
  669. if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
  670. punchTarget = focusedTarget
  671. } else if (isFinishPunchAvailable(definition, state, modeState)) {
  672. const finishControl = getFinishControl(definition)
  673. if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) {
  674. punchTarget = finishControl
  675. }
  676. }
  677. if (!punchTarget && !definition.requiresFocusSelection) {
  678. punchTarget = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
  679. }
  680. } else if (modeState.phase === 'finish') {
  681. nextPrimaryTarget = getFinishControl(definition)
  682. guidanceTarget = nextPrimaryTarget
  683. if (nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) {
  684. punchTarget = nextPrimaryTarget
  685. }
  686. } else if (targetControl) {
  687. guidanceTarget = targetControl
  688. if (getApproxDistanceMeters(targetControl.point, referencePoint) <= definition.punchRadiusMeters) {
  689. punchTarget = targetControl
  690. }
  691. }
  692. const guidanceState = guidanceTarget
  693. ? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint))
  694. : 'searching'
  695. const nextState: GameSessionState = {
  696. ...nextStateBase,
  697. currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
  698. inRangeControlId: punchTarget ? punchTarget.id : null,
  699. guidanceState,
  700. }
  701. const nextStateWithMode = withModeState(nextState, {
  702. ...modeState,
  703. guidanceControlId: guidanceTarget ? guidanceTarget.id : null,
  704. })
  705. const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null)
  706. if (definition.punchPolicy === 'enter' && punchTarget) {
  707. const completionResult = applyCompletion(definition, nextStateWithMode, punchTarget, event.at, referencePoint)
  708. return {
  709. ...completionResult,
  710. effects: [...guidanceEffects, ...completionResult.effects],
  711. }
  712. }
  713. return {
  714. nextState: nextStateWithMode,
  715. presentation: buildPresentation(definition, nextStateWithMode),
  716. effects: guidanceEffects,
  717. }
  718. }
  719. if (event.type === 'control_focused') {
  720. if (modeState.phase !== 'controls' && modeState.phase !== 'finish') {
  721. return {
  722. nextState: state,
  723. presentation: buildPresentation(definition, state),
  724. effects: [],
  725. }
  726. }
  727. const focusableControlIds = getRemainingScoreControls(definition, state).map((control) => control.id)
  728. const finishControl = getFinishControl(definition)
  729. if (finishControl && canFocusFinish(definition, state)) {
  730. focusableControlIds.push(finishControl.id)
  731. }
  732. const nextFocusedControlId = event.controlId && focusableControlIds.includes(event.controlId)
  733. ? modeState.focusedControlId === event.controlId
  734. ? null
  735. : event.controlId
  736. : null
  737. const nextState = withModeState({
  738. ...state,
  739. }, {
  740. ...modeState,
  741. focusedControlId: nextFocusedControlId,
  742. guidanceControlId: modeState.guidanceControlId,
  743. })
  744. return {
  745. nextState,
  746. presentation: buildPresentation(definition, nextState),
  747. effects: [],
  748. }
  749. }
  750. if (event.type === 'punch_requested') {
  751. const focusedTarget = getFocusedTarget(definition, state)
  752. let stateForPunch = state
  753. const finishControl = getFinishControl(definition)
  754. const finishInRange = !!(
  755. finishControl
  756. && event.lon !== null
  757. && event.lat !== null
  758. && getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters
  759. )
  760. if (definition.requiresFocusSelection && modeState.phase === 'controls' && !focusedTarget && !finishInRange) {
  761. return {
  762. nextState: state,
  763. presentation: buildPresentation(definition, state),
  764. effects: [{ type: 'punch_feedback', text: '请先选中目标点', tone: 'warning' }],
  765. }
  766. }
  767. let controlToPunch: GameControl | null = null
  768. if (state.inRangeControlId) {
  769. controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
  770. }
  771. if (!controlToPunch && event.lon !== null && event.lat !== null) {
  772. const referencePoint = { lon: event.lon, lat: event.lat }
  773. const nextStateBase = withModeState(state, modeState)
  774. stateForPunch = nextStateBase
  775. const remainingControls = getRemainingScoreControls(definition, state)
  776. const resolvedFocusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls)
  777. if (resolvedFocusedTarget && getApproxDistanceMeters(resolvedFocusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
  778. controlToPunch = resolvedFocusedTarget
  779. } else if (isFinishPunchAvailable(definition, state, modeState)) {
  780. const finishControl = getFinishControl(definition)
  781. if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) {
  782. controlToPunch = finishControl
  783. }
  784. }
  785. if (!controlToPunch && !definition.requiresFocusSelection && modeState.phase === 'controls') {
  786. controlToPunch = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
  787. }
  788. }
  789. if (!controlToPunch || (definition.requiresFocusSelection && modeState.phase === 'controls' && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
  790. const isFinishLockedAttempt = !!(
  791. finishControl
  792. && event.lon !== null
  793. && event.lat !== null
  794. && getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters
  795. && !hasCompletedEnoughControlsForFinish(definition, state)
  796. )
  797. return {
  798. nextState: state,
  799. presentation: buildPresentation(definition, state),
  800. effects: [{
  801. type: 'punch_feedback',
  802. text: isFinishLockedAttempt
  803. ? `至少完成 ${definition.minCompletedControlsBeforeFinish} 个积分点后才能结束`
  804. : focusedTarget
  805. ? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围`
  806. : modeState.phase === 'start'
  807. ? '未进入开始点打卡范围'
  808. : '未进入目标打点范围',
  809. tone: 'warning',
  810. }],
  811. }
  812. }
  813. return applyCompletion(definition, stateForPunch, controlToPunch, event.at, this.getReferencePoint(definition, stateForPunch, controlToPunch))
  814. }
  815. return {
  816. nextState: state,
  817. presentation: buildPresentation(definition, state),
  818. effects: [],
  819. }
  820. }
  821. private getReferencePoint(definition: GameDefinition, state: GameSessionState, completedControl: GameControl): LonLatPoint | null {
  822. if (completedControl.kind === 'control') {
  823. const remaining = getRemainingScoreControls(definition, {
  824. ...state,
  825. completedControlIds: [...state.completedControlIds, completedControl.id],
  826. })
  827. return remaining.length ? completedControl.point : (getFinishControl(definition) ? getFinishControl(definition)!.point : completedControl.point)
  828. }
  829. return completedControl.point
  830. }
  831. }