feat: 收敛玩法运行时配置并加入故障恢复
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user