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,100 @@
import { type GameEffect } from '../core/gameResult'
type SoundKey = 'session-start' | 'start-complete' | 'control-complete' | 'finish-complete' | 'warning'
const SOUND_SRC: Record<SoundKey, string> = {
'session-start': '/assets/sounds/session-start.wav',
'start-complete': '/assets/sounds/start-complete.wav',
'control-complete': '/assets/sounds/control-complete.wav',
'finish-complete': '/assets/sounds/finish-complete.wav',
warning: '/assets/sounds/warning.wav',
}
export class SoundDirector {
enabled: boolean
contexts: Partial<Record<SoundKey, WechatMiniprogram.InnerAudioContext>>
constructor() {
this.enabled = true
this.contexts = {}
}
setEnabled(enabled: boolean): void {
this.enabled = enabled
}
destroy(): void {
const keys = Object.keys(this.contexts) as SoundKey[]
for (const key of keys) {
const context = this.contexts[key]
if (!context) {
continue
}
context.stop()
context.destroy()
}
this.contexts = {}
}
handleEffects(effects: GameEffect[]): void {
if (!this.enabled || !effects.length) {
return
}
const hasFinishCompletion = effects.some((effect) => effect.type === 'control_completed' && effect.controlKind === 'finish')
for (const effect of effects) {
if (effect.type === 'session_started') {
this.play('session-start')
continue
}
if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
this.play('warning')
continue
}
if (effect.type === 'control_completed') {
if (effect.controlKind === 'start') {
this.play('start-complete')
continue
}
if (effect.controlKind === 'finish') {
this.play('finish-complete')
continue
}
this.play('control-complete')
continue
}
if (effect.type === 'session_finished' && !hasFinishCompletion) {
this.play('finish-complete')
}
}
}
play(key: SoundKey): void {
const context = this.getContext(key)
context.stop()
context.seek(0)
context.play()
}
getContext(key: SoundKey): WechatMiniprogram.InnerAudioContext {
const existing = this.contexts[key]
if (existing) {
return existing
}
const context = wx.createInnerAudioContext()
context.src = SOUND_SRC[key]
context.autoplay = false
context.loop = false
context.obeyMuteSwitch = true
context.volume = 1
this.contexts[key] = context
return context
}
}

View File

@@ -0,0 +1,76 @@
import { type GameDefinition, type GameControl, type PunchPolicyType } from '../core/gameDefinition'
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
function sortBySequence<T extends { sequence: number | null }>(items: T[]): T[] {
return [...items].sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
}
function buildDisplayBody(label: string, sequence: number | null): string {
if (typeof sequence === 'number') {
return `检查点 ${sequence} · ${label || String(sequence)}`
}
return label
}
export function buildGameDefinitionFromCourse(
course: OrienteeringCourseData,
controlRadiusMeters: number,
mode: GameDefinition['mode'] = 'classic-sequential',
autoFinishOnLastControl = true,
punchPolicy: PunchPolicyType = 'enter-confirm',
punchRadiusMeters = 5,
): GameDefinition {
const controls: GameControl[] = []
for (const start of course.layers.starts) {
controls.push({
id: `start-${controls.length + 1}`,
code: start.label || 'S',
label: start.label || 'Start',
kind: 'start',
point: start.point,
sequence: null,
displayContent: null,
})
}
for (const control of sortBySequence(course.layers.controls)) {
const label = control.label || String(control.sequence)
controls.push({
id: `control-${control.sequence}`,
code: label,
label,
kind: 'control',
point: control.point,
sequence: control.sequence,
displayContent: {
title: `收集 ${label}`,
body: buildDisplayBody(label, control.sequence),
},
})
}
for (const finish of course.layers.finishes) {
controls.push({
id: `finish-${controls.length + 1}`,
code: finish.label || 'F',
label: finish.label || 'Finish',
kind: 'finish',
point: finish.point,
sequence: null,
displayContent: null,
})
}
return {
id: `course-${course.title || 'default'}`,
mode,
title: course.title || 'Classic Sequential',
controlRadiusMeters,
punchRadiusMeters,
punchPolicy,
controls,
autoFinishOnLastControl,
}
}

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
}

View File

@@ -0,0 +1,39 @@
export interface GamePresentationState {
activeControlIds: string[]
activeControlSequences: number[]
activeStart: boolean
completedStart: boolean
activeFinish: boolean
completedFinish: boolean
revealFullCourse: boolean
activeLegIndices: number[]
completedLegIndices: number[]
completedControlIds: string[]
completedControlSequences: number[]
progressText: string
punchableControlId: string | null
punchButtonEnabled: boolean
punchButtonText: string
punchHintText: string
}
export const EMPTY_GAME_PRESENTATION_STATE: GamePresentationState = {
activeControlIds: [],
activeControlSequences: [],
activeStart: false,
completedStart: false,
activeFinish: false,
completedFinish: false,
revealFullCourse: false,
activeLegIndices: [],
completedLegIndices: [],
completedControlIds: [],
completedControlSequences: [],
progressText: '0/0',
punchableControlId: null,
punchButtonEnabled: false,
punchButtonText: '打点',
punchHintText: '等待进入检查点范围',
}

View File

@@ -0,0 +1,330 @@
import { type LonLatPoint } from '../../utils/projection'
import { type GameControl, type GameDefinition } from '../core/gameDefinition'
import { type GameEvent } from '../core/gameEvent'
import { type GameEffect, type GameResult } from '../core/gameResult'
import { type GameSessionState } from '../core/gameSessionState'
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
import { type RulePlugin } from './rulePlugin'
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
const dy = (b.lat - a.lat) * 110540
return Math.sqrt(dx * dx + dy * dy)
}
function getScoringControls(definition: GameDefinition): GameControl[] {
return definition.controls.filter((control) => control.kind === 'control')
}
function getSequentialTargets(definition: GameDefinition): GameControl[] {
return definition.controls
}
function getCompletedControlSequences(definition: GameDefinition, state: GameSessionState): number[] {
return getScoringControls(definition)
.filter((control) => state.completedControlIds.includes(control.id) && typeof control.sequence === 'number')
.map((control) => control.sequence as number)
}
function getCurrentTarget(definition: GameDefinition, state: GameSessionState): GameControl | null {
return getSequentialTargets(definition).find((control) => control.id === state.currentTargetControlId) || null
}
function getCompletedLegIndices(definition: GameDefinition, state: GameSessionState): number[] {
const targets = getSequentialTargets(definition)
const completedLegIndices: number[] = []
for (let index = 1; index < targets.length; index += 1) {
if (state.completedControlIds.includes(targets[index].id)) {
completedLegIndices.push(index - 1)
}
}
return completedLegIndices
}
function getTargetText(control: GameControl): string {
if (control.kind === 'start') {
return '开始点'
}
if (control.kind === 'finish') {
return '终点'
}
return '目标圈'
}
function buildPunchHintText(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl | null): string {
if (state.status === 'idle') {
return '点击开始后先打开始点'
}
if (state.status === 'finished') {
return '本局已完成'
}
if (!currentTarget) {
return '本局已完成'
}
const targetText = getTargetText(currentTarget)
if (state.inRangeControlId !== currentTarget.id) {
return definition.punchPolicy === 'enter'
? `进入${targetText}自动打点`
: `进入${targetText}后点击打点`
}
return definition.punchPolicy === 'enter'
? `${targetText}内,自动打点中`
: `${targetText}内,可点击打点`
}
function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
const scoringControls = getScoringControls(definition)
const sequentialTargets = getSequentialTargets(definition)
const currentTarget = getCurrentTarget(definition, state)
const currentTargetIndex = currentTarget ? sequentialTargets.findIndex((control) => control.id === currentTarget.id) : -1
const completedControls = scoringControls.filter((control) => state.completedControlIds.includes(control.id))
const running = state.status === 'running'
const activeLegIndices = running && currentTargetIndex > 0
? [currentTargetIndex - 1]
: []
const completedLegIndices = getCompletedLegIndices(definition, state)
const punchButtonEnabled = running && !!currentTarget && state.inRangeControlId === currentTarget.id && definition.punchPolicy === 'enter-confirm'
const activeStart = running && !!currentTarget && currentTarget.kind === 'start'
const completedStart = definition.controls.some((control) => control.kind === 'start' && state.completedControlIds.includes(control.id))
const activeFinish = running && !!currentTarget && currentTarget.kind === 'finish'
const completedFinish = definition.controls.some((control) => control.kind === 'finish' && state.completedControlIds.includes(control.id))
const punchButtonText = currentTarget
? currentTarget.kind === 'start'
? '开始打卡'
: currentTarget.kind === 'finish'
? '结束打卡'
: '打点'
: '打点'
const revealFullCourse = completedStart
if (!scoringControls.length) {
return {
...EMPTY_GAME_PRESENTATION_STATE,
activeStart,
completedStart,
activeFinish,
completedFinish,
revealFullCourse,
activeLegIndices,
completedLegIndices,
progressText: '0/0',
punchButtonText,
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
punchButtonEnabled,
punchHintText: buildPunchHintText(definition, state, currentTarget),
}
}
return {
activeControlIds: running && currentTarget ? [currentTarget.id] : [],
activeControlSequences: running && currentTarget && currentTarget.kind === 'control' && typeof currentTarget.sequence === 'number' ? [currentTarget.sequence] : [],
activeStart,
completedStart,
activeFinish,
completedFinish,
revealFullCourse,
activeLegIndices,
completedLegIndices,
completedControlIds: completedControls.map((control) => control.id),
completedControlSequences: getCompletedControlSequences(definition, state),
progressText: `${completedControls.length}/${scoringControls.length}`,
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
punchButtonEnabled,
punchButtonText,
punchHintText: buildPunchHintText(definition, state, currentTarget),
}
}
function getInitialTargetId(definition: GameDefinition): string | null {
const firstTarget = getSequentialTargets(definition)[0]
return firstTarget ? firstTarget.id : null
}
function buildCompletedEffect(control: GameControl): GameEffect {
if (control.kind === 'start') {
return {
type: 'control_completed',
controlId: control.id,
controlKind: 'start',
sequence: null,
label: control.label,
displayTitle: '比赛开始',
displayBody: '已完成开始点打卡,前往 1 号点。',
}
}
if (control.kind === 'finish') {
return {
type: 'control_completed',
controlId: control.id,
controlKind: 'finish',
sequence: null,
label: control.label,
displayTitle: '比赛结束',
displayBody: '已完成终点打卡,本局结束。',
}
}
const sequenceText = typeof control.sequence === 'number' ? String(control.sequence) : control.label
const displayTitle = control.displayContent ? control.displayContent.title : `完成 ${sequenceText}`
const displayBody = control.displayContent ? control.displayContent.body : control.label
return {
type: 'control_completed',
controlId: control.id,
controlKind: 'control',
sequence: control.sequence,
label: control.label,
displayTitle,
displayBody,
}
}
function applyCompletion(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl, at: number): GameResult {
const targets = getSequentialTargets(definition)
const currentIndex = targets.findIndex((control) => control.id === currentTarget.id)
const completedControlIds = state.completedControlIds.includes(currentTarget.id)
? state.completedControlIds
: [...state.completedControlIds, currentTarget.id]
const nextTarget = currentIndex >= 0 && currentIndex < targets.length - 1
? targets[currentIndex + 1]
: null
const nextState: GameSessionState = {
...state,
completedControlIds,
currentTargetControlId: nextTarget ? nextTarget.id : null,
inRangeControlId: null,
score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
status: nextTarget || !definition.autoFinishOnLastControl ? state.status : 'finished',
endedAt: nextTarget || !definition.autoFinishOnLastControl ? state.endedAt : at,
}
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
if (!nextTarget && definition.autoFinishOnLastControl) {
effects.push({ type: 'session_finished' })
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects,
}
}
export class ClassicSequentialRule implements RulePlugin {
get mode(): 'classic-sequential' {
return 'classic-sequential'
}
initialize(definition: GameDefinition): GameSessionState {
return {
status: 'idle',
startedAt: null,
endedAt: null,
completedControlIds: [],
currentTargetControlId: getInitialTargetId(definition),
inRangeControlId: null,
score: 0,
}
}
buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
return buildPresentation(definition, state)
}
reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult {
if (event.type === 'session_started') {
const nextState: GameSessionState = {
...state,
status: 'running',
startedAt: event.at,
endedAt: null,
inRangeControlId: null,
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [{ type: 'session_started' }],
}
}
if (event.type === 'session_ended') {
const nextState: GameSessionState = {
...state,
status: 'finished',
endedAt: event.at,
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [{ type: 'session_finished' }],
}
}
if (state.status !== 'running' || !state.currentTargetControlId) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [],
}
}
const currentTarget = getCurrentTarget(definition, state)
if (!currentTarget) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [],
}
}
if (event.type === 'gps_updated') {
const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? currentTarget.id : null
const nextState: GameSessionState = {
...state,
inRangeControlId,
}
if (definition.punchPolicy === 'enter' && inRangeControlId === currentTarget.id) {
return applyCompletion(definition, nextState, currentTarget, event.at)
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [],
}
}
if (event.type === 'punch_requested') {
if (state.inRangeControlId !== currentTarget.id) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: currentTarget.kind === 'start' ? '未进入开始点打卡范围' : currentTarget.kind === 'finish' ? '未进入终点打卡范围' : '未进入目标打点范围', tone: 'warning' }],
}
}
return applyCompletion(definition, state, currentTarget, event.at)
}
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [],
}
}
}

View File

@@ -0,0 +1,11 @@
import { type GameDefinition } from '../core/gameDefinition'
import { type GameEvent } from '../core/gameEvent'
import { type GameResult } from '../core/gameResult'
import { type GameSessionState } from '../core/gameSessionState'
export interface RulePlugin {
readonly mode: GameDefinition['mode']
initialize(definition: GameDefinition): GameSessionState
buildPresentation(definition: GameDefinition, state: GameSessionState): GameResult['presentation']
reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult
}