Add configurable game flow, finish punching, and audio cues

This commit is contained in:
2026-03-23 19:35:17 +08:00
parent 3b4b3ee3ec
commit 48159be900
23 changed files with 1620 additions and 68 deletions

View File

@@ -0,0 +1,31 @@
import { type LonLatPoint } from '../../utils/projection'
export type GameMode = 'classic-sequential'
export type GameControlKind = 'start' | 'control' | 'finish'
export type PunchPolicyType = 'enter' | 'enter-confirm'
export interface GameControlDisplayContent {
title: string
body: string
}
export interface GameControl {
id: string
code: string
label: string
kind: GameControlKind
point: LonLatPoint
sequence: number | null
displayContent: GameControlDisplayContent | null
}
export interface GameDefinition {
id: string
mode: GameMode
title: string
controlRadiusMeters: number
punchRadiusMeters: number
punchPolicy: PunchPolicyType
controls: GameControl[]
autoFinishOnLastControl: boolean
}

View File

@@ -0,0 +1,5 @@
export type GameEvent =
| { type: 'session_started'; at: number }
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
| { type: 'punch_requested'; at: number }
| { type: 'session_ended'; at: number }

View File

@@ -0,0 +1,14 @@
import { type GameSessionState } from './gameSessionState'
import { type GamePresentationState } from '../presentation/presentationState'
export type GameEffect =
| { type: 'session_started' }
| { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' }
| { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string }
| { type: 'session_finished' }
export interface GameResult {
nextState: GameSessionState
presentation: GamePresentationState
effects: GameEffect[]
}

View File

@@ -0,0 +1,89 @@
import { type GameDefinition } from './gameDefinition'
import { type GameEvent } from './gameEvent'
import { type GameResult } from './gameResult'
import { type GameSessionState } from './gameSessionState'
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
import { ClassicSequentialRule } from '../rules/classicSequentialRule'
import { type RulePlugin } from '../rules/rulePlugin'
export class GameRuntime {
definition: GameDefinition | null
plugin: RulePlugin | null
state: GameSessionState | null
presentation: GamePresentationState
lastResult: GameResult | null
constructor() {
this.definition = null
this.plugin = null
this.state = null
this.presentation = EMPTY_GAME_PRESENTATION_STATE
this.lastResult = null
}
clear(): void {
this.definition = null
this.plugin = null
this.state = null
this.presentation = EMPTY_GAME_PRESENTATION_STATE
this.lastResult = null
}
loadDefinition(definition: GameDefinition): GameResult {
this.definition = definition
this.plugin = this.resolvePlugin(definition)
this.state = this.plugin.initialize(definition)
const result: GameResult = {
nextState: this.state,
presentation: this.plugin.buildPresentation(definition, this.state),
effects: [],
}
this.presentation = result.presentation
this.lastResult = result
return result
}
startSession(startAt = Date.now()): GameResult {
return this.dispatch({ type: 'session_started', at: startAt })
}
dispatch(event: GameEvent): GameResult {
if (!this.definition || !this.plugin || !this.state) {
const emptyState: GameSessionState = {
status: 'idle',
startedAt: null,
endedAt: null,
completedControlIds: [],
currentTargetControlId: null,
inRangeControlId: null,
score: 0,
}
const result: GameResult = {
nextState: emptyState,
presentation: EMPTY_GAME_PRESENTATION_STATE,
effects: [],
}
this.lastResult = result
this.presentation = result.presentation
return result
}
const result = this.plugin.reduce(this.definition, this.state, event)
this.state = result.nextState
this.presentation = result.presentation
this.lastResult = result
return result
}
getPresentation(): GamePresentationState {
return this.presentation
}
resolvePlugin(definition: GameDefinition): RulePlugin {
if (definition.mode === 'classic-sequential') {
return new ClassicSequentialRule()
}
throw new Error(`未支持的玩法模式: ${definition.mode}`)
}
}

View File

@@ -0,0 +1,11 @@
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
export interface GameSessionState {
status: GameSessionStatus
startedAt: number | null
endedAt: number | null
completedControlIds: string[]
currentTargetControlId: string | null
inRangeControlId: string | null
score: number
}