import { type LonLatPoint } from '../../utils/projection' import { DEFAULT_GAME_AUDIO_CONFIG } from '../audio/audioConfig' 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 HudPresentationState } from '../presentation/hudPresentationState' import { type MapPresentationState } from '../presentation/mapPresentationState' import { type RulePlugin } from './rulePlugin' type ClassicSequentialModeState = { mode: 'classic-sequential' phase: 'start' | 'course' | 'finish' | 'done' } 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 getSkippedControlSequences(definition: GameDefinition, state: GameSessionState): number[] { return getScoringControls(definition) .filter((control) => state.skippedControlIds.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 getNextTarget(definition: GameDefinition, currentTarget: GameControl): GameControl | null { const targets = getSequentialTargets(definition) const currentIndex = targets.findIndex((control) => control.id === currentTarget.id) return currentIndex >= 0 && currentIndex < targets.length - 1 ? targets[currentIndex + 1] : 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 getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] { if (distanceMeters <= definition.punchRadiusMeters) { return 'ready' } const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters if (distanceMeters <= approachDistanceMeters) { return 'approaching' } return 'searching' } function getGuidanceEffects( previousState: GameSessionState['guidanceState'], nextState: GameSessionState['guidanceState'], controlId: string | null, ): GameEffect[] { if (previousState === nextState) { return [] } return [{ type: 'guidance_state_changed', guidanceState: nextState, controlId }] } 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 buildSkipFeedbackText(currentTarget: GameControl): string { if (currentTarget.kind === 'start') { return '开始点不可跳过' } if (currentTarget.kind === 'finish') { return '终点不可跳过' } return `已跳过检查点 ${typeof currentTarget.sequence === 'number' ? currentTarget.sequence : currentTarget.label}` } function resolveGuidanceForTarget( definition: GameDefinition, previousState: GameSessionState, target: GameControl | null, location: LonLatPoint | null, ): { guidanceState: GameSessionState['guidanceState']; inRangeControlId: string | null; effects: GameEffect[] } { if (!target || !location) { const guidanceState: GameSessionState['guidanceState'] = 'searching' return { guidanceState, inRangeControlId: null, effects: getGuidanceEffects(previousState.guidanceState, guidanceState, target ? target.id : null), } } const distanceMeters = getApproxDistanceMeters(target.point, location) const guidanceState = getGuidanceState(definition, distanceMeters) const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? target.id : null return { guidanceState, inRangeControlId, effects: getGuidanceEffects(previousState.guidanceState, guidanceState, target.id), } } 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 const hudPresentation: HudPresentationState = { actionTagText: '目标', distanceTagText: '点距', hudTargetControlId: currentTarget ? currentTarget.id : null, progressText: '0/0', punchButtonText, punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null, punchButtonEnabled, punchHintText: buildPunchHintText(definition, state, currentTarget), } if (!scoringControls.length) { return { map: { ...EMPTY_GAME_PRESENTATION_STATE.map, controlVisualMode: 'single-target', showCourseLegs: true, guidanceLegAnimationEnabled: true, focusableControlIds: [], focusedControlId: null, focusedControlSequences: [], activeStart, completedStart, activeFinish, focusedFinish: false, completedFinish, revealFullCourse, activeLegIndices, completedLegIndices, skippedControlIds: [], skippedControlSequences: [], }, hud: hudPresentation, } } const mapPresentation: MapPresentationState = { controlVisualMode: 'single-target', showCourseLegs: true, guidanceLegAnimationEnabled: true, focusableControlIds: [], focusedControlId: null, focusedControlSequences: [], activeControlIds: running && currentTarget ? [currentTarget.id] : [], activeControlSequences: running && currentTarget && currentTarget.kind === 'control' && typeof currentTarget.sequence === 'number' ? [currentTarget.sequence] : [], activeStart, completedStart, activeFinish, focusedFinish: false, completedFinish, revealFullCourse, activeLegIndices, completedLegIndices, completedControlIds: completedControls.map((control) => control.id), completedControlSequences: getCompletedControlSequences(definition, state), skippedControlIds: state.skippedControlIds, skippedControlSequences: getSkippedControlSequences(definition, state), } return { map: mapPresentation, hud: { ...hudPresentation, progressText: `${completedControls.length}/${scoringControls.length}`, }, } } function resolveClassicPhase(nextTarget: GameControl | null, currentTarget: GameControl, finished: boolean): ClassicSequentialModeState['phase'] { if (finished || currentTarget.kind === 'finish') { return 'done' } if (currentTarget.kind === 'start') { return nextTarget && nextTarget.kind === 'finish' ? 'finish' : 'course' } if (nextTarget && nextTarget.kind === 'finish') { return 'finish' } return 'course' } 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: control.displayContent ? control.displayContent.title : '比赛开始', displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,前往 1 号点。', displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true, displayOnce: control.displayContent ? control.displayContent.once : false, displayPriority: control.displayContent ? control.displayContent.priority : 1, } } if (control.kind === 'finish') { return { type: 'control_completed', controlId: control.id, controlKind: 'finish', sequence: null, label: control.label, displayTitle: control.displayContent ? control.displayContent.title : '比赛结束', displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。', displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true, displayOnce: control.displayContent ? control.displayContent.once : false, displayPriority: control.displayContent ? control.displayContent.priority : 2, } } 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, displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true, displayOnce: control.displayContent ? control.displayContent.once : false, displayPriority: control.displayContent ? control.displayContent.priority : 1, } } function applyCompletion(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl, at: number): GameResult { const completedControlIds = state.completedControlIds.includes(currentTarget.id) ? state.completedControlIds : [...state.completedControlIds, currentTarget.id] const nextTarget = getNextTarget(definition, currentTarget) const completedFinish = currentTarget.kind === 'finish' const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl) const nextState: GameSessionState = { ...state, startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt, completedControlIds, skippedControlIds: currentTarget.id === state.currentTargetControlId ? state.skippedControlIds.filter((controlId) => controlId !== currentTarget.id) : state.skippedControlIds, currentTargetControlId: nextTarget ? nextTarget.id : null, inRangeControlId: null, score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length, status: finished ? 'finished' : state.status, endedAt: finished ? at : state.endedAt, guidanceState: nextTarget ? 'searching' : 'searching', modeState: { mode: 'classic-sequential', phase: resolveClassicPhase(nextTarget, currentTarget, finished), }, } const effects: GameEffect[] = [buildCompletedEffect(currentTarget)] if (finished) { effects.push({ type: 'session_finished' }) } return { nextState, presentation: buildPresentation(definition, nextState), effects, } } function applySkip( definition: GameDefinition, state: GameSessionState, currentTarget: GameControl, location: LonLatPoint | null, ): GameResult { const nextTarget = getNextTarget(definition, currentTarget) const nextPhase = nextTarget && nextTarget.kind === 'finish' ? 'finish' : 'course' const nextGuidance = resolveGuidanceForTarget(definition, state, nextTarget, location) const nextState: GameSessionState = { ...state, skippedControlIds: state.skippedControlIds.includes(currentTarget.id) ? state.skippedControlIds : [...state.skippedControlIds, currentTarget.id], currentTargetControlId: nextTarget ? nextTarget.id : null, inRangeControlId: nextGuidance.inRangeControlId, guidanceState: nextGuidance.guidanceState, modeState: { mode: 'classic-sequential', phase: nextTarget ? nextPhase : 'done', }, } return { nextState, presentation: buildPresentation(definition, nextState), effects: [ ...nextGuidance.effects, { type: 'punch_feedback', text: buildSkipFeedbackText(currentTarget), tone: 'neutral' }, ], } } export class ClassicSequentialRule implements RulePlugin { get mode(): 'classic-sequential' { return 'classic-sequential' } initialize(definition: GameDefinition): GameSessionState { return { status: 'idle', startedAt: null, endedAt: null, completedControlIds: [], skippedControlIds: [], currentTargetControlId: getInitialTargetId(definition), inRangeControlId: null, score: 0, guidanceState: 'searching', modeState: { mode: 'classic-sequential', phase: 'start', }, } } 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: null, endedAt: null, inRangeControlId: null, guidanceState: 'searching', modeState: { mode: 'classic-sequential', phase: 'start', }, } return { nextState, presentation: buildPresentation(definition, nextState), effects: [{ type: 'session_started' }], } } if (event.type === 'session_ended') { const nextState: GameSessionState = { ...state, status: 'finished', endedAt: event.at, guidanceState: 'searching', modeState: { mode: 'classic-sequential', phase: 'done', }, } 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 guidanceState = getGuidanceState(definition, distanceMeters) const nextState: GameSessionState = { ...state, inRangeControlId, guidanceState, } const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, currentTarget.id) if (definition.punchPolicy === 'enter' && inRangeControlId === currentTarget.id) { const completionResult = applyCompletion(definition, nextState, currentTarget, event.at) return { ...completionResult, effects: [...guidanceEffects, ...completionResult.effects], } } return { nextState, presentation: buildPresentation(definition, nextState), effects: guidanceEffects, } } 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) } if (event.type === 'skip_requested') { if (!definition.skipEnabled) { return { nextState: state, presentation: buildPresentation(definition, state), effects: [{ type: 'punch_feedback', text: '当前配置未开启跳点', tone: 'warning' }], } } if (currentTarget.kind !== 'control') { return { nextState: state, presentation: buildPresentation(definition, state), effects: [{ type: 'punch_feedback', text: currentTarget.kind === 'start' ? '开始点不可跳过' : '终点不可跳过', tone: 'warning' }], } } if (event.lon === null || event.lat === null) { return { nextState: state, presentation: buildPresentation(definition, state), effects: [{ type: 'punch_feedback', text: '当前无定位,无法跳点', tone: 'warning' }], } } const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat }) if (distanceMeters > definition.skipRadiusMeters) { return { nextState: state, presentation: buildPresentation(definition, state), effects: [{ type: 'punch_feedback', text: `未进入跳点范围 (${Math.round(definition.skipRadiusMeters)}m)`, tone: 'warning' }], } } return applySkip( definition, state, currentTarget, { lon: event.lon, lat: event.lat }, ) } return { nextState: state, presentation: buildPresentation(definition, state), effects: [], } } }