Add config-driven game host updates

This commit is contained in:
2026-03-25 13:58:51 +08:00
parent f0ced54805
commit d1cc6cc473
28 changed files with 3247 additions and 105 deletions

View File

@@ -20,6 +20,12 @@ export function buildGameDefinitionFromCourse(
autoFinishOnLastControl = true,
punchPolicy: PunchPolicyType = 'enter-confirm',
punchRadiusMeters = 5,
requiresFocusSelection = false,
skipEnabled = false,
skipRadiusMeters = 30,
skipRequiresConfirm = true,
controlScoreOverrides: Record<string, number> = {},
defaultControlScore: number | null = null,
): GameDefinition {
const controls: GameControl[] = []
@@ -31,22 +37,28 @@ export function buildGameDefinitionFromCourse(
kind: 'start',
point: start.point,
sequence: null,
score: null,
displayContent: null,
})
}
for (const control of sortBySequence(course.layers.controls)) {
const label = control.label || String(control.sequence)
const controlId = `control-${control.sequence}`
const score = controlId in controlScoreOverrides
? controlScoreOverrides[controlId]
: defaultControlScore
controls.push({
id: `control-${control.sequence}`,
id: controlId,
code: label,
label,
kind: 'control',
point: control.point,
sequence: control.sequence,
score,
displayContent: {
title: `收集 ${label}`,
body: buildDisplayBody(label, control.sequence),
title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}` : buildDisplayBody(label, control.sequence),
},
})
}
@@ -59,6 +71,7 @@ export function buildGameDefinitionFromCourse(
kind: 'finish',
point: finish.point,
sequence: null,
score: null,
displayContent: null,
})
}
@@ -70,6 +83,10 @@ export function buildGameDefinitionFromCourse(
controlRadiusMeters,
punchRadiusMeters,
punchPolicy,
requiresFocusSelection,
skipEnabled,
skipRadiusMeters,
skipRequiresConfirm,
controls,
autoFinishOnLastControl,
}

View File

@@ -17,6 +17,7 @@ export interface GameControl {
kind: GameControlKind
point: LonLatPoint
sequence: number | null
score: number | null
displayContent: GameControlDisplayContent | null
}
@@ -27,6 +28,10 @@ export interface GameDefinition {
controlRadiusMeters: number
punchRadiusMeters: number
punchPolicy: PunchPolicyType
requiresFocusSelection: boolean
skipEnabled: boolean
skipRadiusMeters: number
skipRequiresConfirm: boolean
controls: GameControl[]
autoFinishOnLastControl: boolean
audioConfig?: GameAudioConfig

View File

@@ -2,5 +2,6 @@ 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: 'skip_requested'; at: number; lon: number | null; lat: number | null }
| { type: 'control_focused'; at: number; controlId: string | null }
| { type: 'session_ended'; at: number }

View File

@@ -65,6 +65,7 @@ export class GameRuntime {
startedAt: null,
endedAt: null,
completedControlIds: [],
skippedControlIds: [],
currentTargetControlId: null,
inRangeControlId: null,
score: 0,

View File

@@ -7,6 +7,7 @@ export interface GameSessionState {
startedAt: number | null
endedAt: number | null
completedControlIds: string[]
skippedControlIds: string[]
currentTargetControlId: string | null
inRangeControlId: string | null
score: number

View File

@@ -39,6 +39,10 @@ export class FeedbackDirector {
this.uiEffectDirector.configure(config.uiEffectsConfig || DEFAULT_GAME_UI_EFFECTS_CONFIG)
}
reset(): void {
this.soundDirector.resetContexts()
}
destroy(): void {
this.soundDirector.destroy()
this.hapticsDirector.destroy()

View File

@@ -17,6 +17,8 @@ export interface MapPresentationState {
completedLegIndices: number[]
completedControlIds: string[]
completedControlSequences: number[]
skippedControlIds: string[]
skippedControlSequences: number[]
}
export const EMPTY_MAP_PRESENTATION_STATE: MapPresentationState = {
@@ -38,4 +40,6 @@ export const EMPTY_MAP_PRESENTATION_STATE: MapPresentationState = {
completedLegIndices: [],
completedControlIds: [],
completedControlSequences: [],
skippedControlIds: [],
skippedControlSequences: [],
}

View File

@@ -35,10 +35,24 @@ function getCompletedControlSequences(definition: GameDefinition, state: GameSes
.map((control) => control.sequence as number)
}
function getSkippedControlSequences(definition: GameDefinition, state: GameSessionState): number[] {
return getScoringControls(definition)
.filter((control) => state.skippedControlIds.includes(control.id) && typeof control.sequence === 'number')
.map((control) => control.sequence as number)
}
function getCurrentTarget(definition: GameDefinition, state: GameSessionState): GameControl | null {
return getSequentialTargets(definition).find((control) => control.id === state.currentTargetControlId) || null
}
function getNextTarget(definition: GameDefinition, currentTarget: GameControl): GameControl | null {
const targets = getSequentialTargets(definition)
const currentIndex = targets.findIndex((control) => control.id === currentTarget.id)
return currentIndex >= 0 && currentIndex < targets.length - 1
? targets[currentIndex + 1]
: null
}
function getCompletedLegIndices(definition: GameDefinition, state: GameSessionState): number[] {
const targets = getSequentialTargets(definition)
const completedLegIndices: number[] = []
@@ -115,6 +129,43 @@ function buildPunchHintText(definition: GameDefinition, state: GameSessionState,
: `${targetText}内,可点击打点`
}
function buildSkipFeedbackText(currentTarget: GameControl): string {
if (currentTarget.kind === 'start') {
return '开始点不可跳过'
}
if (currentTarget.kind === 'finish') {
return '终点不可跳过'
}
return `已跳过检查点 ${typeof currentTarget.sequence === 'number' ? currentTarget.sequence : currentTarget.label}`
}
function resolveGuidanceForTarget(
definition: GameDefinition,
previousState: GameSessionState,
target: GameControl | null,
location: LonLatPoint | null,
): { guidanceState: GameSessionState['guidanceState']; inRangeControlId: string | null; effects: GameEffect[] } {
if (!target || !location) {
const guidanceState: GameSessionState['guidanceState'] = 'searching'
return {
guidanceState,
inRangeControlId: null,
effects: getGuidanceEffects(previousState.guidanceState, guidanceState, target ? target.id : null),
}
}
const distanceMeters = getApproxDistanceMeters(target.point, location)
const guidanceState = getGuidanceState(definition, distanceMeters)
const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? target.id : null
return {
guidanceState,
inRangeControlId,
effects: getGuidanceEffects(previousState.guidanceState, guidanceState, target.id),
}
}
function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
const scoringControls = getScoringControls(definition)
const sequentialTargets = getSequentialTargets(definition)
@@ -168,6 +219,8 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
revealFullCourse,
activeLegIndices,
completedLegIndices,
skippedControlIds: [],
skippedControlSequences: [],
},
hud: hudPresentation,
}
@@ -192,6 +245,8 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
completedLegIndices,
completedControlIds: completedControls.map((control) => control.id),
completedControlSequences: getCompletedControlSequences(definition, state),
skippedControlIds: state.skippedControlIds,
skippedControlSequences: getSkippedControlSequences(definition, state),
}
return {
@@ -265,20 +320,19 @@ function buildCompletedEffect(control: GameControl): GameEffect {
}
function applyCompletion(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl, at: number): GameResult {
const targets = getSequentialTargets(definition)
const currentIndex = targets.findIndex((control) => control.id === currentTarget.id)
const completedControlIds = state.completedControlIds.includes(currentTarget.id)
? state.completedControlIds
: [...state.completedControlIds, currentTarget.id]
const nextTarget = currentIndex >= 0 && currentIndex < targets.length - 1
? targets[currentIndex + 1]
: null
const nextTarget = getNextTarget(definition, currentTarget)
const completedFinish = currentTarget.kind === 'finish'
const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl)
const nextState: GameSessionState = {
...state,
startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt,
completedControlIds,
skippedControlIds: currentTarget.id === state.currentTargetControlId
? state.skippedControlIds.filter((controlId) => controlId !== currentTarget.id)
: state.skippedControlIds,
currentTargetControlId: nextTarget ? nextTarget.id : null,
inRangeControlId: null,
score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
@@ -303,6 +357,39 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
}
}
function applySkip(
definition: GameDefinition,
state: GameSessionState,
currentTarget: GameControl,
location: LonLatPoint | null,
): GameResult {
const nextTarget = getNextTarget(definition, currentTarget)
const nextPhase = nextTarget && nextTarget.kind === 'finish' ? 'finish' : 'course'
const nextGuidance = resolveGuidanceForTarget(definition, state, nextTarget, location)
const nextState: GameSessionState = {
...state,
skippedControlIds: state.skippedControlIds.includes(currentTarget.id)
? state.skippedControlIds
: [...state.skippedControlIds, currentTarget.id],
currentTargetControlId: nextTarget ? nextTarget.id : null,
inRangeControlId: nextGuidance.inRangeControlId,
guidanceState: nextGuidance.guidanceState,
modeState: {
mode: 'classic-sequential',
phase: nextTarget ? nextPhase : 'done',
},
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [
...nextGuidance.effects,
{ type: 'punch_feedback', text: buildSkipFeedbackText(currentTarget), tone: 'neutral' },
],
}
}
export class ClassicSequentialRule implements RulePlugin {
get mode(): 'classic-sequential' {
return 'classic-sequential'
@@ -314,6 +401,7 @@ export class ClassicSequentialRule implements RulePlugin {
startedAt: null,
endedAt: null,
completedControlIds: [],
skippedControlIds: [],
currentTargetControlId: getInitialTargetId(definition),
inRangeControlId: null,
score: 0,
@@ -423,6 +511,48 @@ export class ClassicSequentialRule implements RulePlugin {
return applyCompletion(definition, state, currentTarget, event.at)
}
if (event.type === 'skip_requested') {
if (!definition.skipEnabled) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: '当前配置未开启跳点', tone: 'warning' }],
}
}
if (currentTarget.kind !== 'control') {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: currentTarget.kind === 'start' ? '开始点不可跳过' : '终点不可跳过', tone: 'warning' }],
}
}
if (event.lon === null || event.lat === null) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: '当前无定位,无法跳点', tone: 'warning' }],
}
}
const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
if (distanceMeters > definition.skipRadiusMeters) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: `未进入跳点范围 (${Math.round(definition.skipRadiusMeters)}m)`, tone: 'warning' }],
}
}
return applySkip(
definition,
state,
currentTarget,
{ lon: event.lon, lat: event.lat },
)
}
return {
nextState: state,
presentation: buildPresentation(definition, state),

View File

@@ -33,6 +33,10 @@ function getScoreControls(definition: GameDefinition): GameControl[] {
return definition.controls.filter((control) => control.kind === 'control')
}
function getControlScore(control: GameControl): number {
return control.kind === 'control' && typeof control.score === 'number' ? control.score : 0
}
function getRemainingScoreControls(definition: GameDefinition, state: GameSessionState): GameControl[] {
return getScoreControls(definition).filter((control) => !state.completedControlIds.includes(control.id))
}
@@ -112,6 +116,46 @@ function getFocusedTarget(
return null
}
function resolveInteractiveTarget(
definition: GameDefinition,
modeState: ScoreOModeState,
primaryTarget: GameControl | null,
focusedTarget: GameControl | null,
): GameControl | null {
if (modeState.phase === 'start') {
return primaryTarget
}
if (definition.requiresFocusSelection) {
return focusedTarget
}
return focusedTarget || primaryTarget
}
function getNearestInRangeControl(
controls: GameControl[],
referencePoint: LonLatPoint,
radiusMeters: number,
): GameControl | null {
let nearest: GameControl | null = null
let nearestDistance = Number.POSITIVE_INFINITY
for (const control of controls) {
const distance = getApproxDistanceMeters(control.point, referencePoint)
if (distance > radiusMeters) {
continue
}
if (!nearest || distance < nearestDistance) {
nearest = control
nearestDistance = distance
}
}
return nearest
}
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
if (distanceMeters <= definition.punchRadiusMeters) {
return 'ready'
@@ -166,14 +210,15 @@ function buildPunchHintText(
const modeState = getModeState(state)
if (modeState.phase === 'controls' || modeState.phase === 'finish') {
if (!focusedTarget) {
if (definition.requiresFocusSelection && !focusedTarget) {
return modeState.phase === 'finish'
? '点击地图选中终点后结束比赛'
: '点击地图选中一个目标点'
}
const targetLabel = getDisplayTargetLabel(focusedTarget)
if (state.inRangeControlId === focusedTarget.id) {
const displayTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
const targetLabel = getDisplayTargetLabel(displayTarget)
if (displayTarget && state.inRangeControlId === displayTarget.id) {
return definition.punchPolicy === 'enter'
? `${targetLabel}内,自动打点中`
: `${targetLabel}内,可点击打点`
@@ -258,7 +303,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
.filter((control) => typeof control.sequence === 'number')
.map((control) => control.sequence as number)
const revealFullCourse = completedStart
const interactiveTarget = modeState.phase === 'start' ? primaryTarget : focusedTarget
const interactiveTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
const punchButtonEnabled = running
&& definition.punchPolicy === 'enter-confirm'
&& !!interactiveTarget
@@ -287,6 +332,8 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
completedLegIndices: [],
completedControlIds: completedControls.map((control) => control.id),
completedControlSequences,
skippedControlIds: [],
skippedControlSequences: [],
}
const hudPresentation: HudPresentationState = {
@@ -348,7 +395,9 @@ function applyCompletion(
completedControlIds,
currentTargetControlId: null,
inRangeControlId: null,
score: getScoreControls(definition).filter((item) => completedControlIds.includes(item.id)).length,
score: getScoreControls(definition)
.filter((item) => completedControlIds.includes(item.id))
.reduce((sum, item) => sum + getControlScore(item), 0),
status: control.kind === 'finish' ? 'finished' : state.status,
guidanceState: 'searching',
}
@@ -401,6 +450,7 @@ export class ScoreORule implements RulePlugin {
startedAt: null,
endedAt: null,
completedControlIds: [],
skippedControlIds: [],
currentTargetControlId: startControl ? startControl.id : null,
inRangeControlId: null,
score: 0,
@@ -481,12 +531,16 @@ export class ScoreORule implements RulePlugin {
guidanceTarget = focusedTarget || nextPrimaryTarget
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
punchTarget = focusedTarget
} else if (!definition.requiresFocusSelection) {
punchTarget = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
}
} else if (modeState.phase === 'finish') {
nextPrimaryTarget = getFinishControl(definition)
guidanceTarget = focusedTarget || nextPrimaryTarget
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
punchTarget = focusedTarget
} else if (!definition.requiresFocusSelection && nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) {
punchTarget = nextPrimaryTarget
}
} else if (targetControl) {
guidanceTarget = targetControl
@@ -556,7 +610,7 @@ export class ScoreORule implements RulePlugin {
if (event.type === 'punch_requested') {
const focusedTarget = getFocusedTarget(definition, state)
if ((modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
if (definition.requiresFocusSelection && (modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
@@ -569,7 +623,7 @@ export class ScoreORule implements RulePlugin {
controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
}
if (!controlToPunch || (focusedTarget && controlToPunch.id !== focusedTarget.id)) {
if (!controlToPunch || (definition.requiresFocusSelection && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
return {
nextState: state,
presentation: buildPresentation(definition, state),