Add score-o mode and split game map HUD presentation
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
21
miniprogram/game/presentation/hudPresentationState.ts
Normal file
21
miniprogram/game/presentation/hudPresentationState.ts
Normal 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: '等待进入检查点范围',
|
||||
}
|
||||
41
miniprogram/game/presentation/mapPresentationState.ts
Normal file
41
miniprogram/game/presentation/mapPresentationState.ts
Normal 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: [],
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
609
miniprogram/game/rules/scoreORule.ts
Normal file
609
miniprogram/game/rules/scoreORule.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user