Add configurable game flow, finish punching, and audio cues
This commit is contained in:
100
miniprogram/game/audio/soundDirector.ts
Normal file
100
miniprogram/game/audio/soundDirector.ts
Normal 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
|
||||
}
|
||||
}
|
||||
76
miniprogram/game/content/courseToGameDefinition.ts
Normal file
76
miniprogram/game/content/courseToGameDefinition.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
31
miniprogram/game/core/gameDefinition.ts
Normal file
31
miniprogram/game/core/gameDefinition.ts
Normal 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
|
||||
}
|
||||
5
miniprogram/game/core/gameEvent.ts
Normal file
5
miniprogram/game/core/gameEvent.ts
Normal 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 }
|
||||
14
miniprogram/game/core/gameResult.ts
Normal file
14
miniprogram/game/core/gameResult.ts
Normal 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[]
|
||||
}
|
||||
89
miniprogram/game/core/gameRuntime.ts
Normal file
89
miniprogram/game/core/gameRuntime.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
11
miniprogram/game/core/gameSessionState.ts
Normal file
11
miniprogram/game/core/gameSessionState.ts
Normal 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
|
||||
}
|
||||
39
miniprogram/game/presentation/presentationState.ts
Normal file
39
miniprogram/game/presentation/presentationState.ts
Normal 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: '等待进入检查点范围',
|
||||
}
|
||||
|
||||
|
||||
330
miniprogram/game/rules/classicSequentialRule.ts
Normal file
330
miniprogram/game/rules/classicSequentialRule.ts
Normal 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: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
miniprogram/game/rules/rulePlugin.ts
Normal file
11
miniprogram/game/rules/rulePlugin.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user