classicSequentialRule.ts 13 KB

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