| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675 |
- 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 { type GamePresentationState } from '../presentation/presentationState'
- import { type HudPresentationState } from '../presentation/hudPresentationState'
- import { type MapPresentationState } from '../presentation/mapPresentationState'
- import { type RulePlugin } from './rulePlugin'
- type ScoreOModeState = {
- phase: 'start' | 'controls' | 'finish' | 'done'
- focusedControlId: string | null
- }
- 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 getStartControl(definition: GameDefinition): GameControl | null {
- return definition.controls.find((control) => control.kind === 'start') || null
- }
- function getFinishControl(definition: GameDefinition): GameControl | null {
- return definition.controls.find((control) => control.kind === 'finish') || null
- }
- function getScoreControls(definition: GameDefinition): GameControl[] {
- return definition.controls.filter((control) => control.kind === 'control')
- }
- function getControlScore(control: GameControl): number {
- return control.kind === 'control' && typeof control.score === 'number' ? control.score : 0
- }
- function getRemainingScoreControls(definition: GameDefinition, state: GameSessionState): GameControl[] {
- return getScoreControls(definition).filter((control) => !state.completedControlIds.includes(control.id))
- }
- function getModeState(state: GameSessionState): ScoreOModeState {
- const rawModeState = state.modeState as Partial<ScoreOModeState> | null
- return {
- phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start',
- focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null,
- }
- }
- function withModeState(state: GameSessionState, modeState: ScoreOModeState): GameSessionState {
- return {
- ...state,
- modeState,
- }
- }
- 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
- }
- function getNearestRemainingControl(
- definition: GameDefinition,
- state: GameSessionState,
- referencePoint?: LonLatPoint | null,
- ): GameControl | null {
- const remainingControls = getRemainingScoreControls(definition, state)
- if (!remainingControls.length) {
- return getFinishControl(definition)
- }
- if (!referencePoint) {
- return remainingControls[0]
- }
- let nearestControl = remainingControls[0]
- let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point)
- for (let index = 1; index < remainingControls.length; index += 1) {
- const control = remainingControls[index]
- const distance = getApproxDistanceMeters(referencePoint, control.point)
- if (distance < nearestDistance) {
- nearestControl = control
- nearestDistance = distance
- }
- }
- return nearestControl
- }
- function getFocusedTarget(
- definition: GameDefinition,
- state: GameSessionState,
- remainingControls?: GameControl[],
- ): GameControl | null {
- const modeState = getModeState(state)
- if (!modeState.focusedControlId) {
- return null
- }
- const controls = remainingControls || getRemainingScoreControls(definition, state)
- for (const control of controls) {
- if (control.id === modeState.focusedControlId) {
- return control
- }
- }
- const finishControl = getFinishControl(definition)
- if (finishControl && canFocusFinish(definition, state) && finishControl.id === modeState.focusedControlId) {
- return finishControl
- }
- return null
- }
- function resolveInteractiveTarget(
- definition: GameDefinition,
- modeState: ScoreOModeState,
- primaryTarget: GameControl | null,
- focusedTarget: GameControl | null,
- ): GameControl | null {
- if (modeState.phase === 'start') {
- return primaryTarget
- }
- if (definition.requiresFocusSelection) {
- return focusedTarget
- }
- return focusedTarget || primaryTarget
- }
- function getNearestInRangeControl(
- controls: GameControl[],
- referencePoint: LonLatPoint,
- radiusMeters: number,
- ): GameControl | null {
- let nearest: GameControl | null = null
- let nearestDistance = Number.POSITIVE_INFINITY
- for (const control of controls) {
- const distance = getApproxDistanceMeters(control.point, referencePoint)
- if (distance > radiusMeters) {
- continue
- }
- if (!nearest || distance < nearestDistance) {
- nearest = control
- nearestDistance = distance
- }
- }
- return nearest
- }
- 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 getDisplayTargetLabel(control: GameControl | null): string {
- if (!control) {
- return '目标点'
- }
- if (control.kind === 'start') {
- return '开始点'
- }
- if (control.kind === 'finish') {
- return '终点'
- }
- return '目标点'
- }
- function buildPunchHintText(
- definition: GameDefinition,
- state: GameSessionState,
- primaryTarget: GameControl | null,
- focusedTarget: GameControl | null,
- ): string {
- if (state.status === 'idle') {
- return '点击开始后先打开始点'
- }
- if (state.status === 'finished') {
- return '本局已完成'
- }
- const modeState = getModeState(state)
- if (modeState.phase === 'controls' || modeState.phase === 'finish') {
- if (definition.requiresFocusSelection && !focusedTarget) {
- return modeState.phase === 'finish'
- ? '点击地图选中终点后结束比赛'
- : '点击地图选中一个目标点'
- }
- const displayTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
- const targetLabel = getDisplayTargetLabel(displayTarget)
- if (displayTarget && state.inRangeControlId === displayTarget.id) {
- return definition.punchPolicy === 'enter'
- ? `${targetLabel}内,自动打点中`
- : `${targetLabel}内,可点击打点`
- }
- return definition.punchPolicy === 'enter'
- ? `进入${targetLabel}自动打点`
- : `进入${targetLabel}后点击打点`
- }
- const targetLabel = getDisplayTargetLabel(primaryTarget)
- if (state.inRangeControlId && primaryTarget && state.inRangeControlId === primaryTarget.id) {
- return definition.punchPolicy === 'enter'
- ? `${targetLabel}内,自动打点中`
- : `${targetLabel}内,可点击打点`
- }
- return definition.punchPolicy === 'enter'
- ? `进入${targetLabel}自动打点`
- : `进入${targetLabel}后点击打点`
- }
- function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect {
- const allowAutoPopup = punchPolicy === 'enter'
- ? false
- : (control.displayContent ? control.displayContent.autoPopup : true)
- 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 : '已完成开始点打卡,开始自由打点。',
- displayAutoPopup: allowAutoPopup,
- displayOnce: control.displayContent ? control.displayContent.once : false,
- displayPriority: control.displayContent ? control.displayContent.priority : 1,
- }
- }
- 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,
- }
- }
- const sequenceText = typeof control.sequence === 'number' ? String(control.sequence) : control.label
- return {
- type: 'control_completed',
- controlId: control.id,
- controlKind: 'control',
- sequence: control.sequence,
- label: control.label,
- displayTitle: control.displayContent ? control.displayContent.title : `收集 ${sequenceText}`,
- displayBody: control.displayContent ? control.displayContent.body : control.label,
- displayAutoPopup: allowAutoPopup,
- displayOnce: control.displayContent ? control.displayContent.once : false,
- displayPriority: control.displayContent ? control.displayContent.priority : 1,
- }
- }
- function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
- const modeState = getModeState(state)
- const running = state.status === 'running'
- const startControl = getStartControl(definition)
- const finishControl = getFinishControl(definition)
- const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
- const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
- const remainingControls = getRemainingScoreControls(definition, state)
- const scoreControls = getScoreControls(definition)
- const primaryTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null
- const focusedTarget = getFocusedTarget(definition, state, remainingControls)
- const canSelectFinish = running && completedStart && !completedFinish && !!finishControl
- const activeControlIds = running && modeState.phase === 'controls'
- ? remainingControls.map((control) => control.id)
- : []
- const activeControlSequences = running && modeState.phase === 'controls'
- ? remainingControls
- .filter((control) => typeof control.sequence === 'number')
- .map((control) => control.sequence as number)
- : []
- const completedControls = scoreControls.filter((control) => state.completedControlIds.includes(control.id))
- const completedControlSequences = completedControls
- .filter((control) => typeof control.sequence === 'number')
- .map((control) => control.sequence as number)
- const revealFullCourse = completedStart
- const interactiveTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
- const punchButtonEnabled = running
- && definition.punchPolicy === 'enter-confirm'
- && !!interactiveTarget
- && state.inRangeControlId === interactiveTarget.id
- const mapPresentation: MapPresentationState = {
- controlVisualMode: modeState.phase === 'controls' ? 'multi-target' : 'single-target',
- showCourseLegs: false,
- guidanceLegAnimationEnabled: false,
- focusableControlIds: canSelectFinish
- ? [...activeControlIds, finishControl!.id]
- : activeControlIds.slice(),
- focusedControlId: focusedTarget ? focusedTarget.id : null,
- focusedControlSequences: focusedTarget && focusedTarget.kind === 'control' && typeof focusedTarget.sequence === 'number'
- ? [focusedTarget.sequence]
- : [],
- activeControlIds,
- activeControlSequences,
- activeStart: running && modeState.phase === 'start',
- completedStart,
- activeFinish: running && modeState.phase === 'finish',
- focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish',
- completedFinish,
- revealFullCourse,
- activeLegIndices: [],
- completedLegIndices: [],
- completedControlIds: completedControls.map((control) => control.id),
- completedControlSequences,
- skippedControlIds: [],
- skippedControlSequences: [],
- }
- const hudPresentation: HudPresentationState = {
- actionTagText: modeState.phase === 'start'
- ? '目标'
- : focusedTarget && focusedTarget.kind === 'finish'
- ? '终点'
- : modeState.phase === 'finish'
- ? '终点'
- : '自由',
- distanceTagText: modeState.phase === 'start'
- ? '点距'
- : focusedTarget && focusedTarget.kind === 'finish'
- ? '终点距'
- : focusedTarget
- ? '选中点距'
- : modeState.phase === 'finish'
- ? '终点距'
- : '最近点距',
- hudTargetControlId: focusedTarget
- ? focusedTarget.id
- : primaryTarget
- ? primaryTarget.id
- : null,
- progressText: `已收集 ${completedControls.length}/${scoreControls.length}`,
- punchableControlId: punchButtonEnabled && interactiveTarget ? interactiveTarget.id : null,
- punchButtonEnabled,
- punchButtonText: modeState.phase === 'start'
- ? '开始打卡'
- : focusedTarget && focusedTarget.kind === 'finish'
- ? '结束打卡'
- : modeState.phase === 'finish'
- ? '结束打卡'
- : '打点',
- punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget),
- }
- return {
- map: mapPresentation,
- hud: hudPresentation,
- }
- }
- function applyCompletion(
- definition: GameDefinition,
- state: GameSessionState,
- control: GameControl,
- at: number,
- referencePoint: LonLatPoint | null,
- ): GameResult {
- const completedControlIds = state.completedControlIds.includes(control.id)
- ? state.completedControlIds
- : [...state.completedControlIds, control.id]
- const previousModeState = getModeState(state)
- const nextStateDraft: GameSessionState = {
- ...state,
- startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt,
- endedAt: control.kind === 'finish' ? at : state.endedAt,
- completedControlIds,
- currentTargetControlId: null,
- inRangeControlId: null,
- score: getScoreControls(definition)
- .filter((item) => completedControlIds.includes(item.id))
- .reduce((sum, item) => sum + getControlScore(item), 0),
- status: control.kind === 'finish' ? 'finished' : state.status,
- guidanceState: 'searching',
- }
- const remainingControls = getRemainingScoreControls(definition, nextStateDraft)
- let phase: ScoreOModeState['phase']
- if (control.kind === 'finish') {
- phase = 'done'
- } else if (control.kind === 'start') {
- phase = remainingControls.length ? 'controls' : 'finish'
- } else {
- 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 nextState = withModeState({
- ...nextStateDraft,
- currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
- }, nextModeState)
- const effects: GameEffect[] = [buildCompletedEffect(control, definition.punchPolicy)]
- if (control.kind === 'finish') {
- effects.push({ type: 'session_finished' })
- }
- return {
- nextState,
- presentation: buildPresentation(definition, nextState),
- effects,
- }
- }
- export class ScoreORule implements RulePlugin {
- get mode(): 'score-o' {
- return 'score-o'
- }
- initialize(definition: GameDefinition): GameSessionState {
- const startControl = getStartControl(definition)
- return {
- status: 'idle',
- startedAt: null,
- endedAt: null,
- completedControlIds: [],
- skippedControlIds: [],
- currentTargetControlId: startControl ? startControl.id : null,
- inRangeControlId: null,
- score: 0,
- guidanceState: 'searching',
- modeState: {
- phase: 'start',
- focusedControlId: null,
- },
- }
- }
- buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
- return buildPresentation(definition, state)
- }
- reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult {
- if (event.type === 'session_started') {
- const startControl = getStartControl(definition)
- const nextState = withModeState({
- ...state,
- status: 'running',
- startedAt: null,
- endedAt: null,
- currentTargetControlId: startControl ? startControl.id : null,
- inRangeControlId: null,
- guidanceState: 'searching',
- }, {
- phase: 'start',
- focusedControlId: null,
- })
- return {
- nextState,
- presentation: buildPresentation(definition, nextState),
- effects: [{ type: 'session_started' }],
- }
- }
- if (event.type === 'session_ended') {
- const nextState = withModeState({
- ...state,
- status: 'finished',
- endedAt: event.at,
- guidanceState: 'searching',
- }, {
- phase: 'done',
- focusedControlId: null,
- })
- return {
- nextState,
- presentation: buildPresentation(definition, nextState),
- effects: [{ type: 'session_finished' }],
- }
- }
- if (state.status !== 'running') {
- return {
- nextState: state,
- presentation: buildPresentation(definition, state),
- effects: [],
- }
- }
- const modeState = getModeState(state)
- const targetControl = state.currentTargetControlId
- ? definition.controls.find((control) => control.id === state.currentTargetControlId) || null
- : null
- if (event.type === 'gps_updated') {
- const referencePoint = { lon: event.lon, lat: event.lat }
- const remainingControls = getRemainingScoreControls(definition, state)
- const focusedTarget = getFocusedTarget(definition, state, remainingControls)
- let nextPrimaryTarget = targetControl
- let guidanceTarget = targetControl
- let punchTarget: GameControl | null = null
- if (modeState.phase === 'controls') {
- nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint)
- guidanceTarget = focusedTarget || nextPrimaryTarget
- if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
- punchTarget = focusedTarget
- } else if (!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) {
- punchTarget = nextPrimaryTarget
- }
- } else if (targetControl) {
- guidanceTarget = targetControl
- if (getApproxDistanceMeters(targetControl.point, referencePoint) <= definition.punchRadiusMeters) {
- punchTarget = targetControl
- }
- }
- const guidanceState = guidanceTarget
- ? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint))
- : 'searching'
- const nextState: GameSessionState = {
- ...state,
- currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
- inRangeControlId: punchTarget ? punchTarget.id : null,
- guidanceState,
- }
- const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null)
- if (definition.punchPolicy === 'enter' && punchTarget) {
- const completionResult = applyCompletion(definition, nextState, punchTarget, event.at, referencePoint)
- return {
- ...completionResult,
- effects: [...guidanceEffects, ...completionResult.effects],
- }
- }
- return {
- nextState,
- presentation: buildPresentation(definition, nextState),
- effects: guidanceEffects,
- }
- }
- if (event.type === 'control_focused') {
- if (modeState.phase !== 'controls' && modeState.phase !== 'finish') {
- return {
- nextState: state,
- presentation: buildPresentation(definition, state),
- effects: [],
- }
- }
- const focusableControlIds = getRemainingScoreControls(definition, state).map((control) => control.id)
- const finishControl = getFinishControl(definition)
- if (finishControl && canFocusFinish(definition, state)) {
- focusableControlIds.push(finishControl.id)
- }
- const nextFocusedControlId = event.controlId && focusableControlIds.includes(event.controlId)
- ? modeState.focusedControlId === event.controlId
- ? null
- : event.controlId
- : null
- const nextState = withModeState({
- ...state,
- }, {
- ...modeState,
- focusedControlId: nextFocusedControlId,
- })
- return {
- nextState,
- presentation: buildPresentation(definition, nextState),
- effects: [],
- }
- }
- if (event.type === 'punch_requested') {
- const focusedTarget = getFocusedTarget(definition, state)
- if (definition.requiresFocusSelection && (modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
- return {
- nextState: state,
- presentation: buildPresentation(definition, state),
- effects: [{ type: 'punch_feedback', text: modeState.phase === 'finish' ? '请先选中终点' : '请先选中目标点', tone: 'warning' }],
- }
- }
- let controlToPunch: GameControl | null = null
- if (state.inRangeControlId) {
- controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
- }
- if (!controlToPunch || (definition.requiresFocusSelection && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
- return {
- nextState: state,
- presentation: buildPresentation(definition, state),
- effects: [{
- type: 'punch_feedback',
- text: focusedTarget
- ? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围`
- : modeState.phase === 'start'
- ? '未进入开始点打卡范围'
- : '未进入目标打点范围',
- tone: 'warning',
- }],
- }
- }
- return applyCompletion(definition, state, controlToPunch, event.at, this.getReferencePoint(definition, state, controlToPunch))
- }
- return {
- nextState: state,
- presentation: buildPresentation(definition, state),
- effects: [],
- }
- }
- private getReferencePoint(definition: GameDefinition, state: GameSessionState, completedControl: GameControl): LonLatPoint | null {
- if (completedControl.kind === 'control') {
- const remaining = getRemainingScoreControls(definition, {
- ...state,
- completedControlIds: [...state.completedControlIds, completedControl.id],
- })
- return remaining.length ? completedControl.point : (getFinishControl(definition) ? getFinishControl(definition)!.point : completedControl.point)
- }
- return completedControl.point
- }
- }
|