Add config-driven game host updates
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user