feat: 收敛玩法运行时配置并加入故障恢复

This commit is contained in:
2026-04-01 13:04:26 +08:00
parent 1635a11780
commit 3ef841ecc7
73 changed files with 8820 additions and 2122 deletions

View File

@@ -79,15 +79,23 @@ function getTargetText(control: GameControl): string {
}
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
if (distanceMeters <= definition.punchRadiusMeters) {
const audioConfig = definition.audioConfig || DEFAULT_GAME_AUDIO_CONFIG
const readyDistanceMeters = Math.max(definition.punchRadiusMeters, audioConfig.readyDistanceMeters)
const approachDistanceMeters = Math.max(readyDistanceMeters, audioConfig.approachDistanceMeters)
const distantDistanceMeters = Math.max(approachDistanceMeters, audioConfig.distantDistanceMeters)
if (distanceMeters <= readyDistanceMeters) {
return 'ready'
}
const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
if (distanceMeters <= approachDistanceMeters) {
return 'approaching'
}
if (distanceMeters <= distantDistanceMeters) {
return 'distant'
}
return 'searching'
}
@@ -129,6 +137,29 @@ function buildPunchHintText(definition: GameDefinition, state: GameSessionState,
: `${targetText}内,可点击打点`
}
function buildTargetSummaryText(state: GameSessionState, currentTarget: GameControl | null): string {
if (state.status === 'finished') {
return '本局已完成'
}
if (!currentTarget) {
return '等待路线初始化'
}
if (currentTarget.kind === 'start') {
return `${currentTarget.label} / 先打开始点`
}
if (currentTarget.kind === 'finish') {
return `${currentTarget.label} / 前往终点`
}
const sequenceText = typeof currentTarget.sequence === 'number'
? `${currentTarget.sequence}`
: '当前目标点'
return `${sequenceText} / ${currentTarget.label}`
}
function buildSkipFeedbackText(currentTarget: GameControl): string {
if (currentTarget.kind === 'start') {
return '开始点不可跳过'
@@ -193,6 +224,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
const hudPresentation: HudPresentationState = {
actionTagText: '目标',
distanceTagText: '点距',
targetSummaryText: buildTargetSummaryText(state, currentTarget),
hudTargetControlId: currentTarget ? currentTarget.id : null,
progressText: '0/0',
punchButtonText,
@@ -200,6 +232,12 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
punchButtonEnabled,
punchHintText: buildPunchHintText(definition, state, currentTarget),
}
const targetingPresentation = {
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
guidanceControlId: currentTarget ? currentTarget.id : null,
hudControlId: currentTarget ? currentTarget.id : null,
highlightedControlId: running && currentTarget ? currentTarget.id : null,
}
if (!scoringControls.length) {
return {
@@ -223,6 +261,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
skippedControlSequences: [],
},
hud: hudPresentation,
targeting: targetingPresentation,
}
}
@@ -255,6 +294,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
...hudPresentation,
progressText: `${completedControls.length}/${scoringControls.length}`,
},
targeting: targetingPresentation,
}
}
@@ -283,6 +323,9 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
const allowAutoPopup = punchPolicy === 'enter'
? false
: (control.displayContent ? control.displayContent.autoPopup : true)
const autoOpenQuiz = control.kind === 'control'
&& !!control.displayContent
&& control.displayContent.ctas.some((item) => item.type === 'quiz')
if (control.kind === 'start') {
return {
type: 'control_completed',
@@ -295,6 +338,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
autoOpenQuiz: false,
}
}
@@ -310,6 +354,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 2,
autoOpenQuiz: false,
}
}
@@ -328,6 +373,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
autoOpenQuiz,
}
}
@@ -340,6 +386,7 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl)
const nextState: GameSessionState = {
...state,
endReason: finished ? 'completed' : state.endReason,
startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt,
completedControlIds,
skippedControlIds: currentTarget.id === state.currentTargetControlId
@@ -410,6 +457,7 @@ export class ClassicSequentialRule implements RulePlugin {
initialize(definition: GameDefinition): GameSessionState {
return {
status: 'idle',
endReason: null,
startedAt: null,
endedAt: null,
completedControlIds: [],
@@ -434,6 +482,7 @@ export class ClassicSequentialRule implements RulePlugin {
const nextState: GameSessionState = {
...state,
status: 'running',
endReason: null,
startedAt: null,
endedAt: null,
inRangeControlId: null,
@@ -454,6 +503,7 @@ export class ClassicSequentialRule implements RulePlugin {
const nextState: GameSessionState = {
...state,
status: 'finished',
endReason: 'completed',
endedAt: event.at,
guidanceState: 'searching',
modeState: {
@@ -468,6 +518,25 @@ export class ClassicSequentialRule implements RulePlugin {
}
}
if (event.type === 'session_timed_out') {
const nextState: GameSessionState = {
...state,
status: 'failed',
endReason: 'timed_out',
endedAt: event.at,
guidanceState: 'searching',
modeState: {
mode: 'classic-sequential',
phase: 'done',
},
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [{ type: 'session_timed_out' }],
}
}
if (state.status !== 'running' || !state.currentTargetControlId) {
return {
nextState: state,

View File

@@ -12,6 +12,7 @@ import { type RulePlugin } from './rulePlugin'
type ScoreOModeState = {
phase: 'start' | 'controls' | 'finish' | 'done'
focusedControlId: string | null
guidanceControlId: string | null
}
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
@@ -46,6 +47,7 @@ function getModeState(state: GameSessionState): ScoreOModeState {
return {
phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start',
focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null,
guidanceControlId: rawModeState && typeof rawModeState.guidanceControlId === 'string' ? rawModeState.guidanceControlId : null,
}
}
@@ -56,12 +58,27 @@ function withModeState(state: GameSessionState, modeState: ScoreOModeState): Gam
}
}
function hasCompletedEnoughControlsForFinish(definition: GameDefinition, state: GameSessionState): boolean {
const completedScoreControls = getScoreControls(definition)
.filter((control) => state.completedControlIds.includes(control.id))
.length
return completedScoreControls >= definition.minCompletedControlsBeforeFinish
}
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
return completedStart && !completedFinish && hasCompletedEnoughControlsForFinish(definition, state)
}
function isFinishPunchAvailable(
definition: GameDefinition,
state: GameSessionState,
_modeState: ScoreOModeState,
): boolean {
return canFocusFinish(definition, state)
}
function getNearestRemainingControl(
@@ -91,6 +108,38 @@ function getNearestRemainingControl(
return nearestControl
}
function getNearestGuidanceTarget(
definition: GameDefinition,
state: GameSessionState,
modeState: ScoreOModeState,
referencePoint: LonLatPoint,
): GameControl | null {
const candidates = getRemainingScoreControls(definition, state).slice()
if (isFinishPunchAvailable(definition, state, modeState)) {
const finishControl = getFinishControl(definition)
if (finishControl && !state.completedControlIds.includes(finishControl.id)) {
candidates.push(finishControl)
}
}
if (!candidates.length) {
return null
}
let nearestControl = candidates[0]
let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point)
for (let index = 1; index < candidates.length; index += 1) {
const control = candidates[index]
const distance = getApproxDistanceMeters(referencePoint, control.point)
if (distance < nearestDistance) {
nearestControl = control
nearestDistance = distance
}
}
return nearestControl
}
function getFocusedTarget(
definition: GameDefinition,
state: GameSessionState,
@@ -118,6 +167,7 @@ function getFocusedTarget(
function resolveInteractiveTarget(
definition: GameDefinition,
state: GameSessionState,
modeState: ScoreOModeState,
primaryTarget: GameControl | null,
focusedTarget: GameControl | null,
@@ -126,11 +176,23 @@ function resolveInteractiveTarget(
return primaryTarget
}
if (modeState.phase === 'finish') {
return primaryTarget
}
if (definition.requiresFocusSelection) {
return focusedTarget
}
return focusedTarget || primaryTarget
if (focusedTarget) {
return focusedTarget
}
if (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState)) {
return getFinishControl(definition)
}
return primaryTarget
}
function getNearestInRangeControl(
@@ -157,15 +219,23 @@ function getNearestInRangeControl(
}
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
if (distanceMeters <= definition.punchRadiusMeters) {
const audioConfig = definition.audioConfig || DEFAULT_GAME_AUDIO_CONFIG
const readyDistanceMeters = Math.max(definition.punchRadiusMeters, audioConfig.readyDistanceMeters)
const approachDistanceMeters = Math.max(readyDistanceMeters, audioConfig.approachDistanceMeters)
const distantDistanceMeters = Math.max(approachDistanceMeters, audioConfig.distantDistanceMeters)
if (distanceMeters <= readyDistanceMeters) {
return 'ready'
}
const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
if (distanceMeters <= approachDistanceMeters) {
return 'approaching'
}
if (distanceMeters <= distantDistanceMeters) {
return 'distant'
}
return 'searching'
}
@@ -210,13 +280,11 @@ function buildPunchHintText(
const modeState = getModeState(state)
if (modeState.phase === 'controls' || modeState.phase === 'finish') {
if (definition.requiresFocusSelection && !focusedTarget) {
return modeState.phase === 'finish'
? '点击地图选中终点后结束比赛'
: '点击地图选中一个目标点'
if (modeState.phase === 'controls' && definition.requiresFocusSelection && !focusedTarget) {
return '点击地图选中一个目标点'
}
const displayTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
const displayTarget = resolveInteractiveTarget(definition, state, modeState, primaryTarget, focusedTarget)
const targetLabel = getDisplayTargetLabel(displayTarget)
if (displayTarget && state.inRangeControlId === displayTarget.id) {
return definition.punchPolicy === 'enter'
@@ -241,10 +309,55 @@ function buildPunchHintText(
: `进入${targetLabel}后点击打点`
}
function buildTargetSummaryText(
definition: GameDefinition,
state: GameSessionState,
primaryTarget: GameControl | null,
focusedTarget: GameControl | null,
): string {
if (state.status === 'idle') {
return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点'
}
if (state.status === 'finished') {
return '本局已完成'
}
const modeState = getModeState(state)
if (modeState.phase === 'start') {
return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点'
}
if (modeState.phase === 'finish') {
return primaryTarget ? `${primaryTarget.label} / 可随时结束` : '可前往终点结束'
}
if (focusedTarget && focusedTarget.kind === 'control') {
return `${focusedTarget.label} / ${getControlScore(focusedTarget)} 分目标`
}
if (focusedTarget && focusedTarget.kind === 'finish') {
return `${focusedTarget.label} / 结束比赛`
}
if (definition.requiresFocusSelection) {
return '请选择目标点'
}
if (primaryTarget && primaryTarget.kind === 'control') {
return `${primaryTarget.label} / ${getControlScore(primaryTarget)} 分目标`
}
return primaryTarget ? primaryTarget.label : '自由打点'
}
function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect {
const allowAutoPopup = punchPolicy === 'enter'
? false
: (control.displayContent ? control.displayContent.autoPopup : true)
const autoOpenQuiz = control.kind === 'control'
&& !!control.displayContent
&& control.displayContent.ctas.some((item) => item.type === 'quiz')
if (control.kind === 'start') {
return {
type: 'control_completed',
@@ -257,6 +370,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
autoOpenQuiz: false,
}
}
@@ -272,6 +386,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 2,
autoOpenQuiz: false,
}
}
@@ -287,9 +402,50 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
autoOpenQuiz,
}
}
function resolvePunchableControl(
definition: GameDefinition,
state: GameSessionState,
modeState: ScoreOModeState,
focusedTarget: GameControl | null,
): GameControl | null {
if (!state.inRangeControlId) {
return null
}
const inRangeControl = definition.controls.find((control) => control.id === state.inRangeControlId) || null
if (!inRangeControl) {
return null
}
if (modeState.phase === 'start') {
return inRangeControl.kind === 'start' ? inRangeControl : null
}
if (modeState.phase === 'finish') {
return inRangeControl.kind === 'finish' ? inRangeControl : null
}
if (modeState.phase === 'controls') {
if (inRangeControl.kind === 'finish' && isFinishPunchAvailable(definition, state, modeState)) {
return inRangeControl
}
if (definition.requiresFocusSelection) {
return focusedTarget && inRangeControl.id === focusedTarget.id ? inRangeControl : null
}
if (inRangeControl.kind === 'control') {
return inRangeControl
}
}
return null
}
function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
const modeState = getModeState(state)
const running = state.status === 'running'
@@ -315,14 +471,35 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
.filter((control) => typeof control.sequence === 'number')
.map((control) => control.sequence as number)
const revealFullCourse = completedStart
const interactiveTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
const punchableControl = resolvePunchableControl(definition, state, modeState, focusedTarget)
const guidanceControl = modeState.guidanceControlId
? definition.controls.find((control) => control.id === modeState.guidanceControlId) || null
: null
const punchButtonEnabled = running
&& definition.punchPolicy === 'enter-confirm'
&& !!interactiveTarget
&& state.inRangeControlId === interactiveTarget.id
&& !!punchableControl
const hudTargetControlId = modeState.phase === 'finish'
? (primaryTarget ? primaryTarget.id : null)
: focusedTarget
? focusedTarget.id
: modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState)
? (getFinishControl(definition) ? getFinishControl(definition)!.id : null)
: definition.requiresFocusSelection
? null
: primaryTarget
? primaryTarget.id
: null
const highlightedControlId = focusedTarget
? focusedTarget.id
: punchableControl
? punchableControl.id
: guidanceControl
? guidanceControl.id
: null
const showMultiTargetLabels = completedStart && modeState.phase !== 'start'
const mapPresentation: MapPresentationState = {
controlVisualMode: modeState.phase === 'controls' ? 'multi-target' : 'single-target',
controlVisualMode: showMultiTargetLabels ? 'multi-target' : 'single-target',
showCourseLegs: false,
guidanceLegAnimationEnabled: false,
focusableControlIds: canSelectFinish
@@ -336,7 +513,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
activeControlSequences,
activeStart: running && modeState.phase === 'start',
completedStart,
activeFinish: running && modeState.phase === 'finish',
activeFinish: running && (modeState.phase === 'finish' || (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState))),
focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish',
completedFinish,
revealFullCourse,
@@ -351,41 +528,48 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
const hudPresentation: HudPresentationState = {
actionTagText: modeState.phase === 'start'
? '目标'
: modeState.phase === 'finish'
? '终点'
: focusedTarget && focusedTarget.kind === 'finish'
? '终点'
: modeState.phase === 'finish'
? '终点'
: focusedTarget
? '目标'
: '自由',
distanceTagText: modeState.phase === 'start'
? '点距'
: modeState.phase === 'finish'
? '终点距'
: focusedTarget && focusedTarget.kind === 'finish'
? '终点距'
: focusedTarget
? '选中点距'
: modeState.phase === 'finish'
? '终点距'
: '最近点距',
hudTargetControlId: focusedTarget
? focusedTarget.id
: primaryTarget
? primaryTarget.id
: null,
: '目标距',
targetSummaryText: buildTargetSummaryText(definition, state, primaryTarget, focusedTarget),
hudTargetControlId,
progressText: `已收集 ${completedControls.length}/${scoreControls.length}`,
punchableControlId: punchButtonEnabled && interactiveTarget ? interactiveTarget.id : null,
punchableControlId: punchableControl ? punchableControl.id : null,
punchButtonEnabled,
punchButtonText: modeState.phase === 'start'
? '开始打卡'
: (punchableControl && punchableControl.kind === 'finish')
? '结束打卡'
: modeState.phase === 'finish'
? '结束打卡'
: focusedTarget && focusedTarget.kind === 'finish'
? '结束打卡'
: modeState.phase === 'finish'
? '结束打卡'
: '打点',
: '打点',
punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget),
}
return {
map: mapPresentation,
hud: hudPresentation,
targeting: {
punchableControlId: punchableControl ? punchableControl.id : null,
guidanceControlId: guidanceControl ? guidanceControl.id : null,
hudControlId: hudTargetControlId,
highlightedControlId,
},
}
}
@@ -402,6 +586,7 @@ function applyCompletion(
const previousModeState = getModeState(state)
const nextStateDraft: GameSessionState = {
...state,
endReason: control.kind === 'finish' ? 'completed' : state.endReason,
startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt,
endedAt: control.kind === 'finish' ? at : state.endedAt,
completedControlIds,
@@ -424,15 +609,16 @@ function applyCompletion(
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 nextModeState: ScoreOModeState = {
phase,
focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId,
guidanceControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
}
const nextState = withModeState({
...nextStateDraft,
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
@@ -459,6 +645,7 @@ export class ScoreORule implements RulePlugin {
const startControl = getStartControl(definition)
return {
status: 'idle',
endReason: null,
startedAt: null,
endedAt: null,
completedControlIds: [],
@@ -470,6 +657,7 @@ export class ScoreORule implements RulePlugin {
modeState: {
phase: 'start',
focusedControlId: null,
guidanceControlId: startControl ? startControl.id : null,
},
}
}
@@ -484,6 +672,7 @@ export class ScoreORule implements RulePlugin {
const nextState = withModeState({
...state,
status: 'running',
endReason: null,
startedAt: null,
endedAt: null,
currentTargetControlId: startControl ? startControl.id : null,
@@ -492,6 +681,7 @@ export class ScoreORule implements RulePlugin {
}, {
phase: 'start',
focusedControlId: null,
guidanceControlId: startControl ? startControl.id : null,
})
return {
nextState,
@@ -504,11 +694,13 @@ export class ScoreORule implements RulePlugin {
const nextState = withModeState({
...state,
status: 'finished',
endReason: 'completed',
endedAt: event.at,
guidanceState: 'searching',
}, {
phase: 'done',
focusedControlId: null,
guidanceControlId: null,
})
return {
nextState,
@@ -517,6 +709,25 @@ export class ScoreORule implements RulePlugin {
}
}
if (event.type === 'session_timed_out') {
const nextState = withModeState({
...state,
status: 'failed',
endReason: 'timed_out',
endedAt: event.at,
guidanceState: 'searching',
}, {
phase: 'done',
focusedControlId: null,
guidanceControlId: null,
})
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [{ type: 'session_timed_out' }],
}
}
if (state.status !== 'running') {
return {
nextState: state,
@@ -533,25 +744,30 @@ export class ScoreORule implements RulePlugin {
if (event.type === 'gps_updated') {
const referencePoint = { lon: event.lon, lat: event.lat }
const remainingControls = getRemainingScoreControls(definition, state)
const focusedTarget = getFocusedTarget(definition, state, remainingControls)
const nextStateBase = withModeState(state, modeState)
const focusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls)
let nextPrimaryTarget = targetControl
let guidanceTarget = targetControl
let punchTarget: GameControl | null = null
if (modeState.phase === 'controls') {
nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint)
guidanceTarget = focusedTarget || nextPrimaryTarget
guidanceTarget = getNearestGuidanceTarget(definition, state, modeState, referencePoint)
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
punchTarget = focusedTarget
} else if (!definition.requiresFocusSelection) {
} else if (isFinishPunchAvailable(definition, state, modeState)) {
const finishControl = getFinishControl(definition)
if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) {
punchTarget = finishControl
}
}
if (!punchTarget && !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) {
guidanceTarget = nextPrimaryTarget
if (nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) {
punchTarget = nextPrimaryTarget
}
} else if (targetControl) {
@@ -565,15 +781,19 @@ export class ScoreORule implements RulePlugin {
? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint))
: 'searching'
const nextState: GameSessionState = {
...state,
...nextStateBase,
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
inRangeControlId: punchTarget ? punchTarget.id : null,
guidanceState,
}
const nextStateWithMode = withModeState(nextState, {
...modeState,
guidanceControlId: guidanceTarget ? guidanceTarget.id : null,
})
const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null)
if (definition.punchPolicy === 'enter' && punchTarget) {
const completionResult = applyCompletion(definition, nextState, punchTarget, event.at, referencePoint)
const completionResult = applyCompletion(definition, nextStateWithMode, punchTarget, event.at, referencePoint)
return {
...completionResult,
effects: [...guidanceEffects, ...completionResult.effects],
@@ -581,8 +801,8 @@ export class ScoreORule implements RulePlugin {
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
nextState: nextStateWithMode,
presentation: buildPresentation(definition, nextStateWithMode),
effects: guidanceEffects,
}
}
@@ -612,6 +832,7 @@ export class ScoreORule implements RulePlugin {
}, {
...modeState,
focusedControlId: nextFocusedControlId,
guidanceControlId: modeState.guidanceControlId,
})
return {
nextState,
@@ -622,11 +843,19 @@ export class ScoreORule implements RulePlugin {
if (event.type === 'punch_requested') {
const focusedTarget = getFocusedTarget(definition, state)
if (definition.requiresFocusSelection && (modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
let stateForPunch = state
const finishControl = getFinishControl(definition)
const finishInRange = !!(
finishControl
&& event.lon !== null
&& event.lat !== null
&& getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters
)
if (definition.requiresFocusSelection && modeState.phase === 'controls' && !focusedTarget && !finishInRange) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: modeState.phase === 'finish' ? '请先选中终点' : '请先选中目标点', tone: 'warning' }],
effects: [{ type: 'punch_feedback', text: '请先选中目标点', tone: 'warning' }],
}
}
@@ -635,13 +864,43 @@ export class ScoreORule implements RulePlugin {
controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
}
if (!controlToPunch || (definition.requiresFocusSelection && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
if (!controlToPunch && event.lon !== null && event.lat !== null) {
const referencePoint = { lon: event.lon, lat: event.lat }
const nextStateBase = withModeState(state, modeState)
stateForPunch = nextStateBase
const remainingControls = getRemainingScoreControls(definition, state)
const resolvedFocusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls)
if (resolvedFocusedTarget && getApproxDistanceMeters(resolvedFocusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
controlToPunch = resolvedFocusedTarget
} else if (isFinishPunchAvailable(definition, state, modeState)) {
const finishControl = getFinishControl(definition)
if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) {
controlToPunch = finishControl
}
}
if (!controlToPunch && !definition.requiresFocusSelection && modeState.phase === 'controls') {
controlToPunch = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
}
}
if (!controlToPunch || (definition.requiresFocusSelection && modeState.phase === 'controls' && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
const isFinishLockedAttempt = !!(
finishControl
&& event.lon !== null
&& event.lat !== null
&& getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters
&& !hasCompletedEnoughControlsForFinish(definition, state)
)
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{
type: 'punch_feedback',
text: focusedTarget
text: isFinishLockedAttempt
? `至少完成 ${definition.minCompletedControlsBeforeFinish} 个积分点后才能结束`
: focusedTarget
? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围`
: modeState.phase === 'start'
? '未进入开始点打卡范围'
@@ -651,7 +910,7 @@ export class ScoreORule implements RulePlugin {
}
}
return applyCompletion(definition, state, controlToPunch, event.at, this.getReferencePoint(definition, state, controlToPunch))
return applyCompletion(definition, stateForPunch, controlToPunch, event.at, this.getReferencePoint(definition, stateForPunch, controlToPunch))
}
return {