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 { type GamePresentationState } from '../presentation/presentationState' import { type HudPresentationState } from '../presentation/hudPresentationState' import { type MapPresentationState } from '../presentation/mapPresentationState' import { type RulePlugin } from './rulePlugin' type ScoreOModeState = { phase: 'start' | 'controls' | 'finish' | 'done' focusedControlId: string | null } 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 getStartControl(definition: GameDefinition): GameControl | null { return definition.controls.find((control) => control.kind === 'start') || null } function getFinishControl(definition: GameDefinition): GameControl | null { return definition.controls.find((control) => control.kind === 'finish') || null } function getScoreControls(definition: GameDefinition): GameControl[] { return definition.controls.filter((control) => control.kind === 'control') } function getRemainingScoreControls(definition: GameDefinition, state: GameSessionState): GameControl[] { return getScoreControls(definition).filter((control) => !state.completedControlIds.includes(control.id)) } function getModeState(state: GameSessionState): ScoreOModeState { const rawModeState = state.modeState as Partial | null return { phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start', focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null, } } function withModeState(state: GameSessionState, modeState: ScoreOModeState): GameSessionState { return { ...state, modeState, } } function canFocusFinish(definition: GameDefinition, state: GameSessionState): boolean { const startControl = getStartControl(definition) const finishControl = getFinishControl(definition) const completedStart = !!startControl && state.completedControlIds.includes(startControl.id) const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id) return completedStart && !completedFinish } function getNearestRemainingControl( definition: GameDefinition, state: GameSessionState, referencePoint?: LonLatPoint | null, ): GameControl | null { const remainingControls = getRemainingScoreControls(definition, state) if (!remainingControls.length) { return getFinishControl(definition) } if (!referencePoint) { return remainingControls[0] } let nearestControl = remainingControls[0] let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point) for (let index = 1; index < remainingControls.length; index += 1) { const control = remainingControls[index] const distance = getApproxDistanceMeters(referencePoint, control.point) if (distance < nearestDistance) { nearestControl = control nearestDistance = distance } } return nearestControl } function getFocusedTarget( definition: GameDefinition, state: GameSessionState, remainingControls?: GameControl[], ): GameControl | null { const modeState = getModeState(state) if (!modeState.focusedControlId) { return null } const controls = remainingControls || getRemainingScoreControls(definition, state) for (const control of controls) { if (control.id === modeState.focusedControlId) { return control } } const finishControl = getFinishControl(definition) if (finishControl && canFocusFinish(definition, state) && finishControl.id === modeState.focusedControlId) { return finishControl } return null } 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 getDisplayTargetLabel(control: GameControl | null): string { if (!control) { return '目标点' } if (control.kind === 'start') { return '开始点' } if (control.kind === 'finish') { return '终点' } return '目标点' } function buildPunchHintText( definition: GameDefinition, state: GameSessionState, primaryTarget: GameControl | null, focusedTarget: GameControl | null, ): string { if (state.status === 'idle') { return '点击开始后先打开始点' } if (state.status === 'finished') { return '本局已完成' } const modeState = getModeState(state) if (modeState.phase === 'controls' || modeState.phase === 'finish') { if (!focusedTarget) { return modeState.phase === 'finish' ? '点击地图选中终点后结束比赛' : '点击地图选中一个目标点' } const targetLabel = getDisplayTargetLabel(focusedTarget) if (state.inRangeControlId === focusedTarget.id) { return definition.punchPolicy === 'enter' ? `${targetLabel}内,自动打点中` : `${targetLabel}内,可点击打点` } return definition.punchPolicy === 'enter' ? `进入${targetLabel}自动打点` : `进入${targetLabel}后点击打点` } const targetLabel = getDisplayTargetLabel(primaryTarget) if (state.inRangeControlId && primaryTarget && state.inRangeControlId === primaryTarget.id) { return definition.punchPolicy === 'enter' ? `${targetLabel}内,自动打点中` : `${targetLabel}内,可点击打点` } return definition.punchPolicy === 'enter' ? `进入${targetLabel}自动打点` : `进入${targetLabel}后点击打点` } 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: '已完成开始点打卡,开始自由打点。', } } 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 return { type: 'control_completed', controlId: control.id, controlKind: 'control', sequence: control.sequence, label: control.label, displayTitle: control.displayContent ? control.displayContent.title : `收集 ${sequenceText}`, displayBody: control.displayContent ? control.displayContent.body : control.label, } } function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState { const modeState = getModeState(state) const running = state.status === 'running' const startControl = getStartControl(definition) const finishControl = getFinishControl(definition) const completedStart = !!startControl && state.completedControlIds.includes(startControl.id) const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id) const remainingControls = getRemainingScoreControls(definition, state) const scoreControls = getScoreControls(definition) const primaryTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null const focusedTarget = getFocusedTarget(definition, state, remainingControls) const canSelectFinish = running && completedStart && !completedFinish && !!finishControl const activeControlIds = running && modeState.phase === 'controls' ? remainingControls.map((control) => control.id) : [] const activeControlSequences = running && modeState.phase === 'controls' ? remainingControls .filter((control) => typeof control.sequence === 'number') .map((control) => control.sequence as number) : [] const completedControls = scoreControls.filter((control) => state.completedControlIds.includes(control.id)) const completedControlSequences = completedControls .filter((control) => typeof control.sequence === 'number') .map((control) => control.sequence as number) const revealFullCourse = completedStart const interactiveTarget = modeState.phase === 'start' ? primaryTarget : focusedTarget const punchButtonEnabled = running && definition.punchPolicy === 'enter-confirm' && !!interactiveTarget && state.inRangeControlId === interactiveTarget.id const mapPresentation: MapPresentationState = { controlVisualMode: modeState.phase === 'controls' ? 'multi-target' : 'single-target', showCourseLegs: false, guidanceLegAnimationEnabled: false, focusableControlIds: canSelectFinish ? [...activeControlIds, finishControl!.id] : activeControlIds.slice(), focusedControlId: focusedTarget ? focusedTarget.id : null, focusedControlSequences: focusedTarget && focusedTarget.kind === 'control' && typeof focusedTarget.sequence === 'number' ? [focusedTarget.sequence] : [], activeControlIds, activeControlSequences, activeStart: running && modeState.phase === 'start', completedStart, activeFinish: running && modeState.phase === 'finish', focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish', completedFinish, revealFullCourse, activeLegIndices: [], completedLegIndices: [], completedControlIds: completedControls.map((control) => control.id), completedControlSequences, } const hudPresentation: HudPresentationState = { actionTagText: modeState.phase === 'start' ? '目标' : focusedTarget && focusedTarget.kind === 'finish' ? '终点' : modeState.phase === 'finish' ? '终点' : '自由', distanceTagText: modeState.phase === 'start' ? '点距' : focusedTarget && focusedTarget.kind === 'finish' ? '终点距' : focusedTarget ? '选中点距' : modeState.phase === 'finish' ? '终点距' : '最近点距', hudTargetControlId: focusedTarget ? focusedTarget.id : primaryTarget ? primaryTarget.id : null, progressText: `已收集 ${completedControls.length}/${scoreControls.length}`, punchableControlId: punchButtonEnabled && interactiveTarget ? interactiveTarget.id : null, punchButtonEnabled, punchButtonText: modeState.phase === 'start' ? '开始打卡' : focusedTarget && focusedTarget.kind === 'finish' ? '结束打卡' : modeState.phase === 'finish' ? '结束打卡' : '打点', punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget), } return { map: mapPresentation, hud: hudPresentation, } } function applyCompletion( definition: GameDefinition, state: GameSessionState, control: GameControl, at: number, referencePoint: LonLatPoint | null, ): GameResult { const completedControlIds = state.completedControlIds.includes(control.id) ? state.completedControlIds : [...state.completedControlIds, control.id] const previousModeState = getModeState(state) const nextStateDraft: GameSessionState = { ...state, startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt, endedAt: control.kind === 'finish' ? at : state.endedAt, completedControlIds, currentTargetControlId: null, inRangeControlId: null, score: getScoreControls(definition).filter((item) => completedControlIds.includes(item.id)).length, status: control.kind === 'finish' ? 'finished' : state.status, guidanceState: 'searching', } const remainingControls = getRemainingScoreControls(definition, nextStateDraft) let phase: ScoreOModeState['phase'] if (control.kind === 'finish') { phase = 'done' } else if (control.kind === 'start') { phase = remainingControls.length ? 'controls' : 'finish' } else { phase = remainingControls.length ? 'controls' : 'finish' } const nextModeState: ScoreOModeState = { phase, focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId, } const nextPrimaryTarget = phase === 'controls' ? getNearestRemainingControl(definition, nextStateDraft, referencePoint) : phase === 'finish' ? getFinishControl(definition) : null const nextState = withModeState({ ...nextStateDraft, currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null, }, nextModeState) const effects: GameEffect[] = [buildCompletedEffect(control)] if (control.kind === 'finish') { effects.push({ type: 'session_finished' }) } return { nextState, presentation: buildPresentation(definition, nextState), effects, } } export class ScoreORule implements RulePlugin { get mode(): 'score-o' { return 'score-o' } initialize(definition: GameDefinition): GameSessionState { const startControl = getStartControl(definition) return { status: 'idle', startedAt: null, endedAt: null, completedControlIds: [], currentTargetControlId: startControl ? startControl.id : null, inRangeControlId: null, score: 0, guidanceState: 'searching', modeState: { phase: 'start', focusedControlId: null, }, } } buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState { return buildPresentation(definition, state) } reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult { if (event.type === 'session_started') { const startControl = getStartControl(definition) const nextState = withModeState({ ...state, status: 'running', startedAt: null, endedAt: null, currentTargetControlId: startControl ? startControl.id : null, inRangeControlId: null, guidanceState: 'searching', }, { phase: 'start', focusedControlId: null, }) return { nextState, presentation: buildPresentation(definition, nextState), effects: [{ type: 'session_started' }], } } if (event.type === 'session_ended') { const nextState = withModeState({ ...state, status: 'finished', endedAt: event.at, guidanceState: 'searching', }, { phase: 'done', focusedControlId: null, }) return { nextState, presentation: buildPresentation(definition, nextState), effects: [{ type: 'session_finished' }], } } if (state.status !== 'running') { return { nextState: state, presentation: buildPresentation(definition, state), effects: [], } } const modeState = getModeState(state) const targetControl = state.currentTargetControlId ? definition.controls.find((control) => control.id === state.currentTargetControlId) || null : null if (event.type === 'gps_updated') { const referencePoint = { lon: event.lon, lat: event.lat } const remainingControls = getRemainingScoreControls(definition, state) const focusedTarget = getFocusedTarget(definition, state, remainingControls) let nextPrimaryTarget = targetControl let guidanceTarget = targetControl let punchTarget: GameControl | null = null if (modeState.phase === 'controls') { nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint) guidanceTarget = focusedTarget || nextPrimaryTarget if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) { punchTarget = focusedTarget } } else if (modeState.phase === 'finish') { nextPrimaryTarget = getFinishControl(definition) guidanceTarget = focusedTarget || nextPrimaryTarget if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) { punchTarget = focusedTarget } } else if (targetControl) { guidanceTarget = targetControl if (getApproxDistanceMeters(targetControl.point, referencePoint) <= definition.punchRadiusMeters) { punchTarget = targetControl } } const guidanceState = guidanceTarget ? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint)) : 'searching' const nextState: GameSessionState = { ...state, currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null, inRangeControlId: punchTarget ? punchTarget.id : null, guidanceState, } const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null) if (definition.punchPolicy === 'enter' && punchTarget) { const completionResult = applyCompletion(definition, nextState, punchTarget, event.at, referencePoint) return { ...completionResult, effects: [...guidanceEffects, ...completionResult.effects], } } return { nextState, presentation: buildPresentation(definition, nextState), effects: guidanceEffects, } } if (event.type === 'control_focused') { if (modeState.phase !== 'controls' && modeState.phase !== 'finish') { return { nextState: state, presentation: buildPresentation(definition, state), effects: [], } } const focusableControlIds = getRemainingScoreControls(definition, state).map((control) => control.id) const finishControl = getFinishControl(definition) if (finishControl && canFocusFinish(definition, state)) { focusableControlIds.push(finishControl.id) } const nextFocusedControlId = event.controlId && focusableControlIds.includes(event.controlId) ? modeState.focusedControlId === event.controlId ? null : event.controlId : null const nextState = withModeState({ ...state, }, { ...modeState, focusedControlId: nextFocusedControlId, }) return { nextState, presentation: buildPresentation(definition, nextState), effects: [], } } if (event.type === 'punch_requested') { const focusedTarget = getFocusedTarget(definition, state) if ((modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) { return { nextState: state, presentation: buildPresentation(definition, state), effects: [{ type: 'punch_feedback', text: modeState.phase === 'finish' ? '请先选中终点' : '请先选中目标点', tone: 'warning' }], } } let controlToPunch: GameControl | null = null if (state.inRangeControlId) { controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null } if (!controlToPunch || (focusedTarget && controlToPunch.id !== focusedTarget.id)) { return { nextState: state, presentation: buildPresentation(definition, state), effects: [{ type: 'punch_feedback', text: focusedTarget ? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围` : modeState.phase === 'start' ? '未进入开始点打卡范围' : '未进入目标打点范围', tone: 'warning', }], } } return applyCompletion(definition, state, controlToPunch, event.at, this.getReferencePoint(definition, state, controlToPunch)) } return { nextState: state, presentation: buildPresentation(definition, state), effects: [], } } private getReferencePoint(definition: GameDefinition, state: GameSessionState, completedControl: GameControl): LonLatPoint | null { if (completedControl.kind === 'control') { const remaining = getRemainingScoreControls(definition, { ...state, completedControlIds: [...state.completedControlIds, completedControl.id], }) return remaining.length ? completedControl.point : (getFinishControl(definition) ? getFinishControl(definition)!.point : completedControl.point) } return completedControl.point } }