scoreORule.ts 21 KB

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