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

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