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

@@ -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),