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 guidanceControlId: 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 getControlScore(control: GameControl): number { return control.kind === 'control' && typeof control.score === 'number' ? control.score : 0 } 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, guidanceControlId: rawModeState && typeof rawModeState.guidanceControlId === 'string' ? rawModeState.guidanceControlId : null, } } function withModeState(state: GameSessionState, modeState: ScoreOModeState): GameSessionState { return { ...state, modeState, } } function hasCompletedEnoughControlsForFinish(definition: GameDefinition, state: GameSessionState): boolean { const completedScoreControls = getScoreControls(definition) .filter((control) => state.completedControlIds.includes(control.id)) .length return completedScoreControls >= definition.minCompletedControlsBeforeFinish } 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 && hasCompletedEnoughControlsForFinish(definition, state) } function isFinishPunchAvailable( definition: GameDefinition, state: GameSessionState, _modeState: ScoreOModeState, ): boolean { return canFocusFinish(definition, state) } 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 getNearestGuidanceTarget( definition: GameDefinition, state: GameSessionState, modeState: ScoreOModeState, referencePoint: LonLatPoint, ): GameControl | null { const candidates = getRemainingScoreControls(definition, state).slice() if (isFinishPunchAvailable(definition, state, modeState)) { const finishControl = getFinishControl(definition) if (finishControl && !state.completedControlIds.includes(finishControl.id)) { candidates.push(finishControl) } } if (!candidates.length) { return null } let nearestControl = candidates[0] let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point) for (let index = 1; index < candidates.length; index += 1) { const control = candidates[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 resolveInteractiveTarget( definition: GameDefinition, state: GameSessionState, modeState: ScoreOModeState, primaryTarget: GameControl | null, focusedTarget: GameControl | null, ): GameControl | null { if (modeState.phase === 'start') { return primaryTarget } if (modeState.phase === 'finish') { return primaryTarget } if (definition.requiresFocusSelection) { return focusedTarget } if (focusedTarget) { return focusedTarget } if (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState)) { return getFinishControl(definition) } return primaryTarget } function getNearestInRangeControl( controls: GameControl[], referencePoint: LonLatPoint, radiusMeters: number, ): GameControl | null { let nearest: GameControl | null = null let nearestDistance = Number.POSITIVE_INFINITY for (const control of controls) { const distance = getApproxDistanceMeters(control.point, referencePoint) if (distance > radiusMeters) { continue } if (!nearest || distance < nearestDistance) { nearest = control nearestDistance = distance } } return nearest } function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] { const audioConfig = definition.audioConfig || DEFAULT_GAME_AUDIO_CONFIG const readyDistanceMeters = Math.max(definition.punchRadiusMeters, audioConfig.readyDistanceMeters) const approachDistanceMeters = Math.max(readyDistanceMeters, audioConfig.approachDistanceMeters) const distantDistanceMeters = Math.max(approachDistanceMeters, audioConfig.distantDistanceMeters) if (distanceMeters <= readyDistanceMeters) { return 'ready' } if (distanceMeters <= approachDistanceMeters) { return 'approaching' } if (distanceMeters <= distantDistanceMeters) { return 'distant' } 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 (modeState.phase === 'controls' && definition.requiresFocusSelection && !focusedTarget) { return '点击地图选中一个目标点' } const displayTarget = resolveInteractiveTarget(definition, state, modeState, primaryTarget, focusedTarget) const targetLabel = getDisplayTargetLabel(displayTarget) if (displayTarget && state.inRangeControlId === displayTarget.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 buildTargetSummaryText( definition: GameDefinition, state: GameSessionState, primaryTarget: GameControl | null, focusedTarget: GameControl | null, ): string { if (state.status === 'idle') { return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点' } if (state.status === 'finished') { return '本局已完成' } const modeState = getModeState(state) if (modeState.phase === 'start') { return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点' } if (modeState.phase === 'finish') { return primaryTarget ? `${primaryTarget.label} / 可随时结束` : '可前往终点结束' } if (focusedTarget && focusedTarget.kind === 'control') { return `${focusedTarget.label} / ${getControlScore(focusedTarget)} 分目标` } if (focusedTarget && focusedTarget.kind === 'finish') { return `${focusedTarget.label} / 结束比赛` } if (definition.requiresFocusSelection) { return '请选择目标点' } if (primaryTarget && primaryTarget.kind === 'control') { return `${primaryTarget.label} / ${getControlScore(primaryTarget)} 分目标` } return primaryTarget ? primaryTarget.label : '自由打点' } function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect { const allowAutoPopup = punchPolicy === 'enter' ? false : (control.displayContent ? control.displayContent.autoPopup : true) const autoOpenQuiz = control.kind === 'control' && !!control.displayContent && control.displayContent.ctas.some((item) => item.type === 'quiz') 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 : '已完成开始点打卡,开始自由打点。', displayAutoPopup: allowAutoPopup, displayOnce: control.displayContent ? control.displayContent.once : false, displayPriority: control.displayContent ? control.displayContent.priority : 1, autoOpenQuiz: false, } } 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: allowAutoPopup, displayOnce: control.displayContent ? control.displayContent.once : false, displayPriority: control.displayContent ? control.displayContent.priority : 2, autoOpenQuiz: false, } } 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, displayAutoPopup: allowAutoPopup, displayOnce: control.displayContent ? control.displayContent.once : false, displayPriority: control.displayContent ? control.displayContent.priority : 1, autoOpenQuiz, } } function resolvePunchableControl( definition: GameDefinition, state: GameSessionState, modeState: ScoreOModeState, focusedTarget: GameControl | null, ): GameControl | null { if (!state.inRangeControlId) { return null } const inRangeControl = definition.controls.find((control) => control.id === state.inRangeControlId) || null if (!inRangeControl) { return null } if (modeState.phase === 'start') { return inRangeControl.kind === 'start' ? inRangeControl : null } if (modeState.phase === 'finish') { return inRangeControl.kind === 'finish' ? inRangeControl : null } if (modeState.phase === 'controls') { if (inRangeControl.kind === 'finish' && isFinishPunchAvailable(definition, state, modeState)) { return inRangeControl } if (definition.requiresFocusSelection) { return focusedTarget && inRangeControl.id === focusedTarget.id ? inRangeControl : null } if (inRangeControl.kind === 'control') { return inRangeControl } } return null } 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 punchableControl = resolvePunchableControl(definition, state, modeState, focusedTarget) const guidanceControl = modeState.guidanceControlId ? definition.controls.find((control) => control.id === modeState.guidanceControlId) || null : null const punchButtonEnabled = running && definition.punchPolicy === 'enter-confirm' && !!punchableControl const hudTargetControlId = modeState.phase === 'finish' ? (primaryTarget ? primaryTarget.id : null) : focusedTarget ? focusedTarget.id : modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState) ? (getFinishControl(definition) ? getFinishControl(definition)!.id : null) : definition.requiresFocusSelection ? null : primaryTarget ? primaryTarget.id : null const highlightedControlId = focusedTarget ? focusedTarget.id : punchableControl ? punchableControl.id : guidanceControl ? guidanceControl.id : null const showMultiTargetLabels = completedStart && modeState.phase !== 'start' const mapPresentation: MapPresentationState = { controlVisualMode: showMultiTargetLabels ? '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' || (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState))), focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish', completedFinish, revealFullCourse, activeLegIndices: [], completedLegIndices: [], completedControlIds: completedControls.map((control) => control.id), completedControlSequences, skippedControlIds: [], skippedControlSequences: [], } const hudPresentation: HudPresentationState = { actionTagText: modeState.phase === 'start' ? '目标' : modeState.phase === 'finish' ? '终点' : focusedTarget && focusedTarget.kind === 'finish' ? '终点' : focusedTarget ? '目标' : '自由', distanceTagText: modeState.phase === 'start' ? '点距' : modeState.phase === 'finish' ? '终点距' : focusedTarget && focusedTarget.kind === 'finish' ? '终点距' : focusedTarget ? '选中点距' : '目标距', targetSummaryText: buildTargetSummaryText(definition, state, primaryTarget, focusedTarget), hudTargetControlId, progressText: `已收集 ${completedControls.length}/${scoreControls.length}`, punchableControlId: punchableControl ? punchableControl.id : null, punchButtonEnabled, punchButtonText: modeState.phase === 'start' ? '开始打卡' : (punchableControl && punchableControl.kind === 'finish') ? '结束打卡' : modeState.phase === 'finish' ? '结束打卡' : focusedTarget && focusedTarget.kind === 'finish' ? '结束打卡' : '打点', punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget), } return { map: mapPresentation, hud: hudPresentation, targeting: { punchableControlId: punchableControl ? punchableControl.id : null, guidanceControlId: guidanceControl ? guidanceControl.id : null, hudControlId: hudTargetControlId, highlightedControlId, }, } } 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, endReason: control.kind === 'finish' ? 'completed' : state.endReason, 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)) .reduce((sum, item) => sum + getControlScore(item), 0), 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 nextPrimaryTarget = phase === 'controls' ? getNearestRemainingControl(definition, nextStateDraft, referencePoint) : phase === 'finish' ? getFinishControl(definition) : null const nextModeState: ScoreOModeState = { phase, focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId, guidanceControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null, } const nextState = withModeState({ ...nextStateDraft, currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null, }, nextModeState) const effects: GameEffect[] = [buildCompletedEffect(control, definition.punchPolicy)] 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', endReason: null, startedAt: null, endedAt: null, completedControlIds: [], skippedControlIds: [], currentTargetControlId: startControl ? startControl.id : null, inRangeControlId: null, score: 0, guidanceState: 'searching', modeState: { phase: 'start', focusedControlId: null, guidanceControlId: startControl ? startControl.id : 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', endReason: null, startedAt: null, endedAt: null, currentTargetControlId: startControl ? startControl.id : null, inRangeControlId: null, guidanceState: 'searching', }, { phase: 'start', focusedControlId: null, guidanceControlId: startControl ? startControl.id : null, }) return { nextState, presentation: buildPresentation(definition, nextState), effects: [{ type: 'session_started' }], } } if (event.type === 'session_ended') { const nextState = withModeState({ ...state, status: 'finished', endReason: 'completed', endedAt: event.at, guidanceState: 'searching', }, { phase: 'done', focusedControlId: null, guidanceControlId: null, }) return { nextState, presentation: buildPresentation(definition, nextState), effects: [{ type: 'session_finished' }], } } if (event.type === 'session_timed_out') { const nextState = withModeState({ ...state, status: 'failed', endReason: 'timed_out', endedAt: event.at, guidanceState: 'searching', }, { phase: 'done', focusedControlId: null, guidanceControlId: null, }) return { nextState, presentation: buildPresentation(definition, nextState), effects: [{ type: 'session_timed_out' }], } } 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 nextStateBase = withModeState(state, modeState) const focusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls) let nextPrimaryTarget = targetControl let guidanceTarget = targetControl let punchTarget: GameControl | null = null if (modeState.phase === 'controls') { nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint) guidanceTarget = getNearestGuidanceTarget(definition, state, modeState, referencePoint) if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) { punchTarget = focusedTarget } else if (isFinishPunchAvailable(definition, state, modeState)) { const finishControl = getFinishControl(definition) if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) { punchTarget = finishControl } } if (!punchTarget && !definition.requiresFocusSelection) { punchTarget = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters) } } else if (modeState.phase === 'finish') { nextPrimaryTarget = getFinishControl(definition) guidanceTarget = nextPrimaryTarget if (nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) { punchTarget = nextPrimaryTarget } } 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 = { ...nextStateBase, currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null, inRangeControlId: punchTarget ? punchTarget.id : null, guidanceState, } const nextStateWithMode = withModeState(nextState, { ...modeState, guidanceControlId: guidanceTarget ? guidanceTarget.id : null, }) const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null) if (definition.punchPolicy === 'enter' && punchTarget) { const completionResult = applyCompletion(definition, nextStateWithMode, punchTarget, event.at, referencePoint) return { ...completionResult, effects: [...guidanceEffects, ...completionResult.effects], } } return { nextState: nextStateWithMode, presentation: buildPresentation(definition, nextStateWithMode), 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, guidanceControlId: modeState.guidanceControlId, }) return { nextState, presentation: buildPresentation(definition, nextState), effects: [], } } if (event.type === 'punch_requested') { const focusedTarget = getFocusedTarget(definition, state) let stateForPunch = state const finishControl = getFinishControl(definition) const finishInRange = !!( finishControl && event.lon !== null && event.lat !== null && getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters ) if (definition.requiresFocusSelection && modeState.phase === 'controls' && !focusedTarget && !finishInRange) { return { nextState: state, presentation: buildPresentation(definition, state), effects: [{ type: 'punch_feedback', text: '请先选中目标点', tone: 'warning' }], } } let controlToPunch: GameControl | null = null if (state.inRangeControlId) { controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null } if (!controlToPunch && event.lon !== null && event.lat !== null) { const referencePoint = { lon: event.lon, lat: event.lat } const nextStateBase = withModeState(state, modeState) stateForPunch = nextStateBase const remainingControls = getRemainingScoreControls(definition, state) const resolvedFocusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls) if (resolvedFocusedTarget && getApproxDistanceMeters(resolvedFocusedTarget.point, referencePoint) <= definition.punchRadiusMeters) { controlToPunch = resolvedFocusedTarget } else if (isFinishPunchAvailable(definition, state, modeState)) { const finishControl = getFinishControl(definition) if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) { controlToPunch = finishControl } } if (!controlToPunch && !definition.requiresFocusSelection && modeState.phase === 'controls') { controlToPunch = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters) } } if (!controlToPunch || (definition.requiresFocusSelection && modeState.phase === 'controls' && focusedTarget && controlToPunch.id !== focusedTarget.id)) { const isFinishLockedAttempt = !!( finishControl && event.lon !== null && event.lat !== null && getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters && !hasCompletedEnoughControlsForFinish(definition, state) ) return { nextState: state, presentation: buildPresentation(definition, state), effects: [{ type: 'punch_feedback', text: isFinishLockedAttempt ? `至少完成 ${definition.minCompletedControlsBeforeFinish} 个积分点后才能结束` : focusedTarget ? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围` : modeState.phase === 'start' ? '未进入开始点打卡范围' : '未进入目标打点范围', tone: 'warning', }], } } return applyCompletion(definition, stateForPunch, controlToPunch, event.at, this.getReferencePoint(definition, stateForPunch, 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 } }