|
|
@@ -0,0 +1,609 @@
|
|
|
+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<ScoreOModeState> | 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
|
|
|
+ }
|
|
|
+}
|