| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564 |
- 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'] {
- if (distanceMeters <= definition.punchRadiusMeters) {
- return 'ready'
- }
- const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
- if (distanceMeters <= approachDistanceMeters) {
- return 'approaching'
- }
- 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 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: '点距',
- hudTargetControlId: currentTarget ? currentTarget.id : null,
- progressText: '0/0',
- punchButtonText,
- punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
- punchButtonEnabled,
- punchHintText: buildPunchHintText(definition, state, currentTarget),
- }
- 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,
- }
- }
- 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}`,
- },
- }
- }
- 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): GameEffect {
- if (control.kind === 'start') {
- return {
- type: 'control_completed',
- controlId: control.id,
- controlKind: 'start',
- sequence: null,
- label: control.label,
- displayTitle: '比赛开始',
- displayBody: '已完成开始点打卡,前往 1 号点。',
- }
- }
- if (control.kind === 'finish') {
- return {
- type: 'control_completed',
- controlId: control.id,
- controlKind: 'finish',
- sequence: null,
- label: control.label,
- displayTitle: '比赛结束',
- displayBody: '已完成终点打卡,本局结束。',
- }
- }
- 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,
- }
- }
- 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,
- 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)]
- 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',
- 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',
- 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',
- endedAt: event.at,
- guidanceState: 'searching',
- modeState: {
- mode: 'classic-sequential',
- phase: 'done',
- },
- }
- return {
- nextState,
- presentation: buildPresentation(definition, nextState),
- effects: [{ type: 'session_finished' }],
- }
- }
- 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: [],
- }
- }
- }
|