Add score-o mode and split game map HUD presentation

This commit is contained in:
2026-03-24 12:27:45 +08:00
parent a117a25824
commit 0295893b56
18 changed files with 1121 additions and 113 deletions

View File

@@ -66,7 +66,7 @@ export function buildGameDefinitionFromCourse(
return {
id: `course-${course.title || 'default'}`,
mode,
title: course.title || 'Classic Sequential',
title: course.title || (mode === 'score-o' ? 'Score-O' : 'Classic Sequential'),
controlRadiusMeters,
punchRadiusMeters,
punchPolicy,

View File

@@ -1,7 +1,7 @@
import { type LonLatPoint } from '../../utils/projection'
import { type GameAudioConfig } from '../audio/audioConfig'
export type GameMode = 'classic-sequential'
export type GameMode = 'classic-sequential' | 'score-o'
export type GameControlKind = 'start' | 'control' | 'finish'
export type PunchPolicyType = 'enter' | 'enter-confirm'

View File

@@ -2,4 +2,5 @@ export type GameEvent =
| { type: 'session_started'; at: number }
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
| { type: 'punch_requested'; at: number }
| { type: 'control_focused'; at: number; controlId: string | null }
| { type: 'session_ended'; at: number }

View File

@@ -3,7 +3,10 @@ import { type GameEvent } from './gameEvent'
import { type GameResult } from './gameResult'
import { type GameSessionState } from './gameSessionState'
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
import { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from '../presentation/hudPresentationState'
import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from '../presentation/mapPresentationState'
import { ClassicSequentialRule } from '../rules/classicSequentialRule'
import { ScoreORule } from '../rules/scoreORule'
import { type RulePlugin } from '../rules/rulePlugin'
export class GameRuntime {
@@ -11,6 +14,8 @@ export class GameRuntime {
plugin: RulePlugin | null
state: GameSessionState | null
presentation: GamePresentationState
mapPresentation: MapPresentationState
hudPresentation: HudPresentationState
lastResult: GameResult | null
constructor() {
@@ -18,6 +23,8 @@ export class GameRuntime {
this.plugin = null
this.state = null
this.presentation = EMPTY_GAME_PRESENTATION_STATE
this.mapPresentation = EMPTY_MAP_PRESENTATION_STATE
this.hudPresentation = EMPTY_HUD_PRESENTATION_STATE
this.lastResult = null
}
@@ -26,6 +33,8 @@ export class GameRuntime {
this.plugin = null
this.state = null
this.presentation = EMPTY_GAME_PRESENTATION_STATE
this.mapPresentation = EMPTY_MAP_PRESENTATION_STATE
this.hudPresentation = EMPTY_HUD_PRESENTATION_STATE
this.lastResult = null
}
@@ -39,6 +48,8 @@ export class GameRuntime {
effects: [],
}
this.presentation = result.presentation
this.mapPresentation = result.presentation.map
this.hudPresentation = result.presentation.hud
this.lastResult = result
return result
}
@@ -58,6 +69,7 @@ export class GameRuntime {
inRangeControlId: null,
score: 0,
guidanceState: 'searching',
modeState: null,
}
const result: GameResult = {
nextState: emptyState,
@@ -66,12 +78,16 @@ export class GameRuntime {
}
this.lastResult = result
this.presentation = result.presentation
this.mapPresentation = result.presentation.map
this.hudPresentation = result.presentation.hud
return result
}
const result = this.plugin.reduce(this.definition, this.state, event)
this.state = result.nextState
this.presentation = result.presentation
this.mapPresentation = result.presentation.map
this.hudPresentation = result.presentation.hud
this.lastResult = result
return result
}
@@ -80,11 +96,23 @@ export class GameRuntime {
return this.presentation
}
getMapPresentation(): MapPresentationState {
return this.mapPresentation
}
getHudPresentation(): HudPresentationState {
return this.hudPresentation
}
resolvePlugin(definition: GameDefinition): RulePlugin {
if (definition.mode === 'classic-sequential') {
return new ClassicSequentialRule()
}
if (definition.mode === 'score-o') {
return new ScoreORule()
}
throw new Error(`未支持的玩法模式: ${definition.mode}`)
}
}

View File

@@ -1,5 +1,6 @@
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
export type GuidanceState = 'searching' | 'approaching' | 'ready'
export type GameModeState = Record<string, unknown> | null
export interface GameSessionState {
status: GameSessionStatus
@@ -10,4 +11,5 @@ export interface GameSessionState {
inRangeControlId: string | null
score: number
guidanceState: GuidanceState
modeState: GameModeState
}

View File

@@ -0,0 +1,21 @@
export interface HudPresentationState {
actionTagText: string
distanceTagText: string
hudTargetControlId: string | null
progressText: string
punchableControlId: string | null
punchButtonEnabled: boolean
punchButtonText: string
punchHintText: string
}
export const EMPTY_HUD_PRESENTATION_STATE: HudPresentationState = {
actionTagText: '目标',
distanceTagText: '点距',
hudTargetControlId: null,
progressText: '0/0',
punchableControlId: null,
punchButtonEnabled: false,
punchButtonText: '打点',
punchHintText: '等待进入检查点范围',
}

View File

@@ -0,0 +1,41 @@
export interface MapPresentationState {
controlVisualMode: 'single-target' | 'multi-target'
showCourseLegs: boolean
guidanceLegAnimationEnabled: boolean
focusableControlIds: string[]
focusedControlId: string | null
focusedControlSequences: number[]
activeControlIds: string[]
activeControlSequences: number[]
activeStart: boolean
completedStart: boolean
activeFinish: boolean
focusedFinish: boolean
completedFinish: boolean
revealFullCourse: boolean
activeLegIndices: number[]
completedLegIndices: number[]
completedControlIds: string[]
completedControlSequences: number[]
}
export const EMPTY_MAP_PRESENTATION_STATE: MapPresentationState = {
controlVisualMode: 'single-target',
showCourseLegs: true,
guidanceLegAnimationEnabled: true,
focusableControlIds: [],
focusedControlId: null,
focusedControlSequences: [],
activeControlIds: [],
activeControlSequences: [],
activeStart: false,
completedStart: false,
activeFinish: false,
focusedFinish: false,
completedFinish: false,
revealFullCourse: false,
activeLegIndices: [],
completedLegIndices: [],
completedControlIds: [],
completedControlSequences: [],
}

View File

@@ -1,39 +1,14 @@
import { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from './hudPresentationState'
import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from './mapPresentationState'
export interface GamePresentationState {
activeControlIds: string[]
activeControlSequences: number[]
activeStart: boolean
completedStart: boolean
activeFinish: boolean
completedFinish: boolean
revealFullCourse: boolean
activeLegIndices: number[]
completedLegIndices: number[]
completedControlIds: string[]
completedControlSequences: number[]
progressText: string
punchableControlId: string | null
punchButtonEnabled: boolean
punchButtonText: string
punchHintText: string
map: MapPresentationState
hud: HudPresentationState
}
export const EMPTY_GAME_PRESENTATION_STATE: GamePresentationState = {
activeControlIds: [],
activeControlSequences: [],
activeStart: false,
completedStart: false,
activeFinish: false,
completedFinish: false,
revealFullCourse: false,
activeLegIndices: [],
completedLegIndices: [],
completedControlIds: [],
completedControlSequences: [],
progressText: '0/0',
punchableControlId: null,
punchButtonEnabled: false,
punchButtonText: '打点',
punchHintText: '等待进入检查点范围',
map: EMPTY_MAP_PRESENTATION_STATE,
hud: EMPTY_HUD_PRESENTATION_STATE,
}

View File

@@ -5,8 +5,15 @@ 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)
@@ -132,43 +139,84 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
: '打点'
: '打点'
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 {
...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),
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,
},
hud: hudPresentation,
}
}
return {
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),
progressText: `${completedControls.length}/${scoringControls.length}`,
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
punchButtonEnabled,
punchButtonText,
punchHintText: buildPunchHintText(definition, state, currentTarget),
}
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 {
@@ -237,6 +285,10 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
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)]
@@ -266,6 +318,10 @@ export class ClassicSequentialRule implements RulePlugin {
inRangeControlId: null,
score: 0,
guidanceState: 'searching',
modeState: {
mode: 'classic-sequential',
phase: 'start',
},
}
}
@@ -282,6 +338,10 @@ export class ClassicSequentialRule implements RulePlugin {
endedAt: null,
inRangeControlId: null,
guidanceState: 'searching',
modeState: {
mode: 'classic-sequential',
phase: 'start',
},
}
return {
nextState,
@@ -296,6 +356,10 @@ export class ClassicSequentialRule implements RulePlugin {
status: 'finished',
endedAt: event.at,
guidanceState: 'searching',
modeState: {
mode: 'classic-sequential',
phase: 'done',
},
}
return {
nextState,

View File

@@ -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
}
}

View File

@@ -268,14 +268,15 @@ export class TelemetryRuntime {
this.reset()
}
syncGameState(definition: GameDefinition | null, state: GameSessionState | null): void {
syncGameState(definition: GameDefinition | null, state: GameSessionState | null, hudTargetControlId?: string | null): void {
if (!definition || !state) {
this.dispatch({ type: 'reset' })
return
}
const targetControl = state.currentTargetControlId
? definition.controls.find((control) => control.id === state.currentTargetControlId) || null
const targetControlId = hudTargetControlId !== undefined ? hudTargetControlId : state.currentTargetControlId
const targetControl = targetControlId
? definition.controls.find((control) => control.id === targetControlId) || null
: null
this.dispatch({