Files
cmr-mini/miniprogram/game/rules/classicSequentialRule.ts

646 lines
22 KiB
TypeScript

import { type LonLatPoint } from '../../utils/projection'
import { DEFAULT_GAME_AUDIO_CONFIG } from '../audio/audioConfig'
import { type GameControl, type GameDefinition } from '../core/gameDefinition'
import { type GameEvent } from '../core/gameEvent'
import { type GameEffect, type GameResult } from '../core/gameResult'
import { type GameSessionState } from '../core/gameSessionState'
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
import { type HudPresentationState } from '../presentation/hudPresentationState'
import { type MapPresentationState } from '../presentation/mapPresentationState'
import { type RulePlugin } from './rulePlugin'
type ClassicSequentialModeState = {
mode: 'classic-sequential'
phase: 'start' | 'course' | 'finish' | 'done'
}
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
const dy = (b.lat - a.lat) * 110540
return Math.sqrt(dx * dx + dy * dy)
}
function getScoringControls(definition: GameDefinition): GameControl[] {
return definition.controls.filter((control) => control.kind === 'control')
}
function getSequentialTargets(definition: GameDefinition): GameControl[] {
return definition.controls
}
function getCompletedControlSequences(definition: GameDefinition, state: GameSessionState): number[] {
return getScoringControls(definition)
.filter((control) => state.completedControlIds.includes(control.id) && typeof control.sequence === 'number')
.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[] = []
for (let index = 1; index < targets.length; index += 1) {
if (state.completedControlIds.includes(targets[index].id)) {
completedLegIndices.push(index - 1)
}
}
return completedLegIndices
}
function getTargetText(control: GameControl): string {
if (control.kind === 'start') {
return '开始点'
}
if (control.kind === 'finish') {
return '终点'
}
return '目标圈'
}
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
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'
}
if (distanceMeters <= approachDistanceMeters) {
return 'approaching'
}
if (distanceMeters <= distantDistanceMeters) {
return 'distant'
}
return 'searching'
}
function getGuidanceEffects(
previousState: GameSessionState['guidanceState'],
nextState: GameSessionState['guidanceState'],
controlId: string | null,
): GameEffect[] {
if (previousState === nextState) {
return []
}
return [{ type: 'guidance_state_changed', guidanceState: nextState, controlId }]
}
function buildPunchHintText(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl | null): string {
if (state.status === 'idle') {
return '先打开始点即可正式开始比赛'
}
if (state.status === 'finished') {
return '本局已完成'
}
if (!currentTarget) {
return '本局已完成'
}
const targetText = getTargetText(currentTarget)
if (state.inRangeControlId !== currentTarget.id) {
return definition.punchPolicy === 'enter'
? `进入${targetText}自动打点`
: `进入${targetText}后点击打点`
}
return definition.punchPolicy === 'enter'
? `${targetText}内,自动打点中`
: `${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 '开始点不可跳过'
}
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)
const currentTarget = getCurrentTarget(definition, state)
const currentTargetIndex = currentTarget ? sequentialTargets.findIndex((control) => control.id === currentTarget.id) : -1
const completedControls = scoringControls.filter((control) => state.completedControlIds.includes(control.id))
const running = state.status === 'running'
const activeLegIndices = running && currentTargetIndex > 0
? [currentTargetIndex - 1]
: []
const completedLegIndices = getCompletedLegIndices(definition, state)
const punchButtonEnabled = running && !!currentTarget && state.inRangeControlId === currentTarget.id && definition.punchPolicy === 'enter-confirm'
const activeStart = running && !!currentTarget && currentTarget.kind === 'start'
const completedStart = definition.controls.some((control) => control.kind === 'start' && state.completedControlIds.includes(control.id))
const activeFinish = running && !!currentTarget && currentTarget.kind === 'finish'
const completedFinish = definition.controls.some((control) => control.kind === 'finish' && state.completedControlIds.includes(control.id))
const punchButtonText = currentTarget
? currentTarget.kind === 'start'
? '开始打卡'
: currentTarget.kind === 'finish'
? '结束打卡'
: '打点'
: '打点'
const revealFullCourse = completedStart
const hudPresentation: HudPresentationState = {
actionTagText: '目标',
distanceTagText: '点距',
targetSummaryText: buildTargetSummaryText(state, currentTarget),
hudTargetControlId: currentTarget ? currentTarget.id : null,
progressText: '0/0',
punchButtonText,
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
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 {
map: {
...EMPTY_GAME_PRESENTATION_STATE.map,
controlVisualMode: 'single-target',
showCourseLegs: true,
guidanceLegAnimationEnabled: true,
focusableControlIds: [],
focusedControlId: null,
focusedControlSequences: [],
activeStart,
completedStart,
activeFinish,
focusedFinish: false,
completedFinish,
revealFullCourse,
activeLegIndices,
completedLegIndices,
skippedControlIds: [],
skippedControlSequences: [],
},
hud: hudPresentation,
targeting: targetingPresentation,
}
}
const mapPresentation: MapPresentationState = {
controlVisualMode: 'single-target',
showCourseLegs: true,
guidanceLegAnimationEnabled: true,
focusableControlIds: [],
focusedControlId: null,
focusedControlSequences: [],
activeControlIds: running && currentTarget ? [currentTarget.id] : [],
activeControlSequences: running && currentTarget && currentTarget.kind === 'control' && typeof currentTarget.sequence === 'number' ? [currentTarget.sequence] : [],
activeStart,
completedStart,
activeFinish,
focusedFinish: false,
completedFinish,
revealFullCourse,
activeLegIndices,
completedLegIndices,
completedControlIds: completedControls.map((control) => control.id),
completedControlSequences: getCompletedControlSequences(definition, state),
skippedControlIds: state.skippedControlIds,
skippedControlSequences: getSkippedControlSequences(definition, state),
}
return {
map: mapPresentation,
hud: {
...hudPresentation,
progressText: `${completedControls.length}/${scoringControls.length}`,
},
targeting: targetingPresentation,
}
}
function resolveClassicPhase(nextTarget: GameControl | null, currentTarget: GameControl, finished: boolean): ClassicSequentialModeState['phase'] {
if (finished || currentTarget.kind === 'finish') {
return 'done'
}
if (currentTarget.kind === 'start') {
return nextTarget && nextTarget.kind === 'finish' ? 'finish' : 'course'
}
if (nextTarget && nextTarget.kind === 'finish') {
return 'finish'
}
return 'course'
}
function getInitialTargetId(definition: GameDefinition): string | null {
const firstTarget = getSequentialTargets(definition)[0]
return firstTarget ? firstTarget.id : null
}
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',
controlId: control.id,
controlKind: 'start',
sequence: null,
label: control.label,
displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,前往 1 号点。',
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
autoOpenQuiz: false,
}
}
if (control.kind === 'finish') {
return {
type: 'control_completed',
controlId: control.id,
controlKind: 'finish',
sequence: null,
label: control.label,
displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 2,
autoOpenQuiz: false,
}
}
const sequenceText = typeof control.sequence === 'number' ? String(control.sequence) : control.label
const displayTitle = control.displayContent ? control.displayContent.title : `完成 ${sequenceText}`
const displayBody = control.displayContent ? control.displayContent.body : control.label
return {
type: 'control_completed',
controlId: control.id,
controlKind: 'control',
sequence: control.sequence,
label: control.label,
displayTitle,
displayBody,
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
autoOpenQuiz,
}
}
function applyCompletion(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl, at: number): GameResult {
const completedControlIds = state.completedControlIds.includes(currentTarget.id)
? state.completedControlIds
: [...state.completedControlIds, currentTarget.id]
const nextTarget = getNextTarget(definition, currentTarget)
const completedFinish = currentTarget.kind === 'finish'
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
? 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,
status: finished ? 'finished' : state.status,
endedAt: finished ? at : state.endedAt,
guidanceState: nextTarget ? 'searching' : 'searching',
modeState: {
mode: 'classic-sequential',
phase: resolveClassicPhase(nextTarget, currentTarget, finished),
},
}
const effects: GameEffect[] = [buildCompletedEffect(currentTarget, definition.punchPolicy)]
if (finished) {
effects.push({ type: 'session_finished' })
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects,
}
}
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'
}
initialize(definition: GameDefinition): GameSessionState {
return {
status: 'idle',
endReason: null,
startedAt: null,
endedAt: null,
completedControlIds: [],
skippedControlIds: [],
currentTargetControlId: getInitialTargetId(definition),
inRangeControlId: null,
score: 0,
guidanceState: 'searching',
modeState: {
mode: 'classic-sequential',
phase: 'start',
},
}
}
buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
return buildPresentation(definition, state)
}
reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult {
if (event.type === 'session_started') {
const nextState: GameSessionState = {
...state,
status: 'running',
endReason: null,
startedAt: null,
endedAt: null,
inRangeControlId: null,
guidanceState: 'searching',
modeState: {
mode: 'classic-sequential',
phase: 'start',
},
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [{ type: 'session_started' }],
}
}
if (event.type === 'session_ended') {
const nextState: GameSessionState = {
...state,
status: 'finished',
endReason: 'completed',
endedAt: event.at,
guidanceState: 'searching',
modeState: {
mode: 'classic-sequential',
phase: 'done',
},
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [{ type: 'session_finished' }],
}
}
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,
presentation: buildPresentation(definition, state),
effects: [],
}
}
const currentTarget = getCurrentTarget(definition, state)
if (!currentTarget) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [],
}
}
if (event.type === 'gps_updated') {
const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? currentTarget.id : null
const guidanceState = getGuidanceState(definition, distanceMeters)
const nextState: GameSessionState = {
...state,
inRangeControlId,
guidanceState,
}
const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, currentTarget.id)
if (definition.punchPolicy === 'enter' && inRangeControlId === currentTarget.id) {
const completionResult = applyCompletion(definition, nextState, currentTarget, event.at)
return {
...completionResult,
effects: [...guidanceEffects, ...completionResult.effects],
}
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: guidanceEffects,
}
}
if (event.type === 'punch_requested') {
if (state.inRangeControlId !== currentTarget.id) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: currentTarget.kind === 'start' ? '未进入开始点打卡范围' : currentTarget.kind === 'finish' ? '未进入终点打卡范围' : '未进入目标打点范围', tone: 'warning' }],
}
}
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),
effects: [],
}
}
}