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: [], } } }