classicSequentialRule.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  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 { EMPTY_GAME_PRESENTATION_STATE, 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 ClassicSequentialModeState = {
  12. mode: 'classic-sequential'
  13. phase: 'start' | 'course' | 'finish' | 'done'
  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 getScoringControls(definition: GameDefinition): GameControl[] {
  22. return definition.controls.filter((control) => control.kind === 'control')
  23. }
  24. function getSequentialTargets(definition: GameDefinition): GameControl[] {
  25. return definition.controls
  26. }
  27. function getCompletedControlSequences(definition: GameDefinition, state: GameSessionState): number[] {
  28. return getScoringControls(definition)
  29. .filter((control) => state.completedControlIds.includes(control.id) && typeof control.sequence === 'number')
  30. .map((control) => control.sequence as number)
  31. }
  32. function getCurrentTarget(definition: GameDefinition, state: GameSessionState): GameControl | null {
  33. return getSequentialTargets(definition).find((control) => control.id === state.currentTargetControlId) || null
  34. }
  35. function getCompletedLegIndices(definition: GameDefinition, state: GameSessionState): number[] {
  36. const targets = getSequentialTargets(definition)
  37. const completedLegIndices: number[] = []
  38. for (let index = 1; index < targets.length; index += 1) {
  39. if (state.completedControlIds.includes(targets[index].id)) {
  40. completedLegIndices.push(index - 1)
  41. }
  42. }
  43. return completedLegIndices
  44. }
  45. function getTargetText(control: GameControl): string {
  46. if (control.kind === 'start') {
  47. return '开始点'
  48. }
  49. if (control.kind === 'finish') {
  50. return '终点'
  51. }
  52. return '目标圈'
  53. }
  54. function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
  55. if (distanceMeters <= definition.punchRadiusMeters) {
  56. return 'ready'
  57. }
  58. const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
  59. if (distanceMeters <= approachDistanceMeters) {
  60. return 'approaching'
  61. }
  62. return 'searching'
  63. }
  64. function getGuidanceEffects(
  65. previousState: GameSessionState['guidanceState'],
  66. nextState: GameSessionState['guidanceState'],
  67. controlId: string | null,
  68. ): GameEffect[] {
  69. if (previousState === nextState) {
  70. return []
  71. }
  72. return [{ type: 'guidance_state_changed', guidanceState: nextState, controlId }]
  73. }
  74. function buildPunchHintText(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl | null): string {
  75. if (state.status === 'idle') {
  76. return '点击开始后先打开始点'
  77. }
  78. if (state.status === 'finished') {
  79. return '本局已完成'
  80. }
  81. if (!currentTarget) {
  82. return '本局已完成'
  83. }
  84. const targetText = getTargetText(currentTarget)
  85. if (state.inRangeControlId !== currentTarget.id) {
  86. return definition.punchPolicy === 'enter'
  87. ? `进入${targetText}自动打点`
  88. : `进入${targetText}后点击打点`
  89. }
  90. return definition.punchPolicy === 'enter'
  91. ? `${targetText}内,自动打点中`
  92. : `${targetText}内,可点击打点`
  93. }
  94. function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
  95. const scoringControls = getScoringControls(definition)
  96. const sequentialTargets = getSequentialTargets(definition)
  97. const currentTarget = getCurrentTarget(definition, state)
  98. const currentTargetIndex = currentTarget ? sequentialTargets.findIndex((control) => control.id === currentTarget.id) : -1
  99. const completedControls = scoringControls.filter((control) => state.completedControlIds.includes(control.id))
  100. const running = state.status === 'running'
  101. const activeLegIndices = running && currentTargetIndex > 0
  102. ? [currentTargetIndex - 1]
  103. : []
  104. const completedLegIndices = getCompletedLegIndices(definition, state)
  105. const punchButtonEnabled = running && !!currentTarget && state.inRangeControlId === currentTarget.id && definition.punchPolicy === 'enter-confirm'
  106. const activeStart = running && !!currentTarget && currentTarget.kind === 'start'
  107. const completedStart = definition.controls.some((control) => control.kind === 'start' && state.completedControlIds.includes(control.id))
  108. const activeFinish = running && !!currentTarget && currentTarget.kind === 'finish'
  109. const completedFinish = definition.controls.some((control) => control.kind === 'finish' && state.completedControlIds.includes(control.id))
  110. const punchButtonText = currentTarget
  111. ? currentTarget.kind === 'start'
  112. ? '开始打卡'
  113. : currentTarget.kind === 'finish'
  114. ? '结束打卡'
  115. : '打点'
  116. : '打点'
  117. const revealFullCourse = completedStart
  118. const hudPresentation: HudPresentationState = {
  119. actionTagText: '目标',
  120. distanceTagText: '点距',
  121. hudTargetControlId: currentTarget ? currentTarget.id : null,
  122. progressText: '0/0',
  123. punchButtonText,
  124. punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
  125. punchButtonEnabled,
  126. punchHintText: buildPunchHintText(definition, state, currentTarget),
  127. }
  128. if (!scoringControls.length) {
  129. return {
  130. map: {
  131. ...EMPTY_GAME_PRESENTATION_STATE.map,
  132. controlVisualMode: 'single-target',
  133. showCourseLegs: true,
  134. guidanceLegAnimationEnabled: true,
  135. focusableControlIds: [],
  136. focusedControlId: null,
  137. focusedControlSequences: [],
  138. activeStart,
  139. completedStart,
  140. activeFinish,
  141. focusedFinish: false,
  142. completedFinish,
  143. revealFullCourse,
  144. activeLegIndices,
  145. completedLegIndices,
  146. },
  147. hud: hudPresentation,
  148. }
  149. }
  150. const mapPresentation: MapPresentationState = {
  151. controlVisualMode: 'single-target',
  152. showCourseLegs: true,
  153. guidanceLegAnimationEnabled: true,
  154. focusableControlIds: [],
  155. focusedControlId: null,
  156. focusedControlSequences: [],
  157. activeControlIds: running && currentTarget ? [currentTarget.id] : [],
  158. activeControlSequences: running && currentTarget && currentTarget.kind === 'control' && typeof currentTarget.sequence === 'number' ? [currentTarget.sequence] : [],
  159. activeStart,
  160. completedStart,
  161. activeFinish,
  162. focusedFinish: false,
  163. completedFinish,
  164. revealFullCourse,
  165. activeLegIndices,
  166. completedLegIndices,
  167. completedControlIds: completedControls.map((control) => control.id),
  168. completedControlSequences: getCompletedControlSequences(definition, state),
  169. }
  170. return {
  171. map: mapPresentation,
  172. hud: {
  173. ...hudPresentation,
  174. progressText: `${completedControls.length}/${scoringControls.length}`,
  175. },
  176. }
  177. }
  178. function resolveClassicPhase(nextTarget: GameControl | null, currentTarget: GameControl, finished: boolean): ClassicSequentialModeState['phase'] {
  179. if (finished || currentTarget.kind === 'finish') {
  180. return 'done'
  181. }
  182. if (currentTarget.kind === 'start') {
  183. return nextTarget && nextTarget.kind === 'finish' ? 'finish' : 'course'
  184. }
  185. if (nextTarget && nextTarget.kind === 'finish') {
  186. return 'finish'
  187. }
  188. return 'course'
  189. }
  190. function getInitialTargetId(definition: GameDefinition): string | null {
  191. const firstTarget = getSequentialTargets(definition)[0]
  192. return firstTarget ? firstTarget.id : null
  193. }
  194. function buildCompletedEffect(control: GameControl): GameEffect {
  195. if (control.kind === 'start') {
  196. return {
  197. type: 'control_completed',
  198. controlId: control.id,
  199. controlKind: 'start',
  200. sequence: null,
  201. label: control.label,
  202. displayTitle: '比赛开始',
  203. displayBody: '已完成开始点打卡,前往 1 号点。',
  204. }
  205. }
  206. if (control.kind === 'finish') {
  207. return {
  208. type: 'control_completed',
  209. controlId: control.id,
  210. controlKind: 'finish',
  211. sequence: null,
  212. label: control.label,
  213. displayTitle: '比赛结束',
  214. displayBody: '已完成终点打卡,本局结束。',
  215. }
  216. }
  217. const sequenceText = typeof control.sequence === 'number' ? String(control.sequence) : control.label
  218. const displayTitle = control.displayContent ? control.displayContent.title : `完成 ${sequenceText}`
  219. const displayBody = control.displayContent ? control.displayContent.body : control.label
  220. return {
  221. type: 'control_completed',
  222. controlId: control.id,
  223. controlKind: 'control',
  224. sequence: control.sequence,
  225. label: control.label,
  226. displayTitle,
  227. displayBody,
  228. }
  229. }
  230. function applyCompletion(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl, at: number): GameResult {
  231. const targets = getSequentialTargets(definition)
  232. const currentIndex = targets.findIndex((control) => control.id === currentTarget.id)
  233. const completedControlIds = state.completedControlIds.includes(currentTarget.id)
  234. ? state.completedControlIds
  235. : [...state.completedControlIds, currentTarget.id]
  236. const nextTarget = currentIndex >= 0 && currentIndex < targets.length - 1
  237. ? targets[currentIndex + 1]
  238. : null
  239. const completedFinish = currentTarget.kind === 'finish'
  240. const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl)
  241. const nextState: GameSessionState = {
  242. ...state,
  243. startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt,
  244. completedControlIds,
  245. currentTargetControlId: nextTarget ? nextTarget.id : null,
  246. inRangeControlId: null,
  247. score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
  248. status: finished ? 'finished' : state.status,
  249. endedAt: finished ? at : state.endedAt,
  250. guidanceState: nextTarget ? 'searching' : 'searching',
  251. modeState: {
  252. mode: 'classic-sequential',
  253. phase: resolveClassicPhase(nextTarget, currentTarget, finished),
  254. },
  255. }
  256. const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
  257. if (finished) {
  258. effects.push({ type: 'session_finished' })
  259. }
  260. return {
  261. nextState,
  262. presentation: buildPresentation(definition, nextState),
  263. effects,
  264. }
  265. }
  266. export class ClassicSequentialRule implements RulePlugin {
  267. get mode(): 'classic-sequential' {
  268. return 'classic-sequential'
  269. }
  270. initialize(definition: GameDefinition): GameSessionState {
  271. return {
  272. status: 'idle',
  273. startedAt: null,
  274. endedAt: null,
  275. completedControlIds: [],
  276. currentTargetControlId: getInitialTargetId(definition),
  277. inRangeControlId: null,
  278. score: 0,
  279. guidanceState: 'searching',
  280. modeState: {
  281. mode: 'classic-sequential',
  282. phase: 'start',
  283. },
  284. }
  285. }
  286. buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
  287. return buildPresentation(definition, state)
  288. }
  289. reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult {
  290. if (event.type === 'session_started') {
  291. const nextState: GameSessionState = {
  292. ...state,
  293. status: 'running',
  294. startedAt: null,
  295. endedAt: null,
  296. inRangeControlId: null,
  297. guidanceState: 'searching',
  298. modeState: {
  299. mode: 'classic-sequential',
  300. phase: 'start',
  301. },
  302. }
  303. return {
  304. nextState,
  305. presentation: buildPresentation(definition, nextState),
  306. effects: [{ type: 'session_started' }],
  307. }
  308. }
  309. if (event.type === 'session_ended') {
  310. const nextState: GameSessionState = {
  311. ...state,
  312. status: 'finished',
  313. endedAt: event.at,
  314. guidanceState: 'searching',
  315. modeState: {
  316. mode: 'classic-sequential',
  317. phase: 'done',
  318. },
  319. }
  320. return {
  321. nextState,
  322. presentation: buildPresentation(definition, nextState),
  323. effects: [{ type: 'session_finished' }],
  324. }
  325. }
  326. if (state.status !== 'running' || !state.currentTargetControlId) {
  327. return {
  328. nextState: state,
  329. presentation: buildPresentation(definition, state),
  330. effects: [],
  331. }
  332. }
  333. const currentTarget = getCurrentTarget(definition, state)
  334. if (!currentTarget) {
  335. return {
  336. nextState: state,
  337. presentation: buildPresentation(definition, state),
  338. effects: [],
  339. }
  340. }
  341. if (event.type === 'gps_updated') {
  342. const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
  343. const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? currentTarget.id : null
  344. const guidanceState = getGuidanceState(definition, distanceMeters)
  345. const nextState: GameSessionState = {
  346. ...state,
  347. inRangeControlId,
  348. guidanceState,
  349. }
  350. const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, currentTarget.id)
  351. if (definition.punchPolicy === 'enter' && inRangeControlId === currentTarget.id) {
  352. const completionResult = applyCompletion(definition, nextState, currentTarget, event.at)
  353. return {
  354. ...completionResult,
  355. effects: [...guidanceEffects, ...completionResult.effects],
  356. }
  357. }
  358. return {
  359. nextState,
  360. presentation: buildPresentation(definition, nextState),
  361. effects: guidanceEffects,
  362. }
  363. }
  364. if (event.type === 'punch_requested') {
  365. if (state.inRangeControlId !== currentTarget.id) {
  366. return {
  367. nextState: state,
  368. presentation: buildPresentation(definition, state),
  369. effects: [{ type: 'punch_feedback', text: currentTarget.kind === 'start' ? '未进入开始点打卡范围' : currentTarget.kind === 'finish' ? '未进入终点打卡范围' : '未进入目标打点范围', tone: 'warning' }],
  370. }
  371. }
  372. return applyCompletion(definition, state, currentTarget, event.at)
  373. }
  374. return {
  375. nextState: state,
  376. presentation: buildPresentation(definition, state),
  377. effects: [],
  378. }
  379. }
  380. }