Add configurable game flow, finish punching, and audio cues
This commit is contained in:
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