6185 lines
205 KiB
TypeScript
6185 lines
205 KiB
TypeScript
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
||
import { AccelerometerController } from '../sensor/accelerometerController'
|
||
import { CompassHeadingController, type CompassTuningProfile } from '../sensor/compassHeadingController'
|
||
import { DeviceMotionController } from '../sensor/deviceMotionController'
|
||
import { MockSimulatorDebugLogger } from '../debug/mockSimulatorDebugLogger'
|
||
import { GyroscopeController } from '../sensor/gyroscopeController'
|
||
import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
|
||
import { HeartRateInputController } from '../sensor/heartRateInputController'
|
||
import { LocationController } from '../sensor/locationController'
|
||
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
|
||
import { type MapRendererStats } from '../renderer/mapRenderer'
|
||
import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
|
||
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
||
import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
|
||
import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel'
|
||
import { GameRuntime } from '../../game/core/gameRuntime'
|
||
import { type GameControl, type GameControlDisplayContentOverride } from '../../game/core/gameDefinition'
|
||
import {
|
||
buildDefaultContentCardCtaLabel,
|
||
buildDefaultContentCardQuizConfig,
|
||
type ContentCardActionViewModel,
|
||
type ContentCardCtaConfig,
|
||
type ContentCardQuizConfig,
|
||
type ContentCardTemplate,
|
||
} from '../../game/experience/contentCard'
|
||
import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
|
||
import { type GameEffect, type GameResult } from '../../game/core/gameResult'
|
||
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
|
||
import { getDefaultSkipRadiusMeters, getGameModeDefaults } from '../../game/core/gameModeDefaults'
|
||
import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
|
||
import { DEFAULT_COURSE_STYLE_CONFIG, type ControlPointStyleEntry, type CourseLegStyleEntry, type CourseStyleConfig } from '../../game/presentation/courseStyleConfig'
|
||
import {
|
||
DEFAULT_TRACK_VISUALIZATION_CONFIG,
|
||
TRACK_COLOR_PRESET_MAP,
|
||
TRACK_TAIL_LENGTH_METERS,
|
||
type TrackColorPreset,
|
||
type TrackDisplayMode,
|
||
type TrackStyleProfile,
|
||
type TrackTailLengthPreset,
|
||
type TrackVisualizationConfig,
|
||
} from '../../game/presentation/trackStyleConfig'
|
||
import {
|
||
DEFAULT_GPS_MARKER_STYLE_CONFIG,
|
||
GPS_MARKER_COLOR_PRESET_MAP,
|
||
type GpsMarkerColorPreset,
|
||
type GpsMarkerSizePreset,
|
||
type GpsMarkerStyleId,
|
||
type GpsMarkerStyleConfig,
|
||
} from '../../game/presentation/gpsMarkerStyleConfig'
|
||
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
|
||
import { buildResultSummarySnapshot, type ResultSummarySnapshot } from '../../game/result/resultSummary'
|
||
import { TelemetryRuntime } from '../../game/telemetry/telemetryRuntime'
|
||
import { getHeartRateToneSampleBpm, type HeartRateTone } from '../../game/telemetry/telemetryConfig'
|
||
import { type PlayerTelemetryProfile } from '../../game/telemetry/playerTelemetryProfile'
|
||
import {
|
||
type RuntimeMapProfile,
|
||
type RuntimeGameProfile,
|
||
type RuntimeFeedbackProfile,
|
||
type RuntimePresentationProfile,
|
||
type RuntimeSettingsProfile,
|
||
type RuntimeTelemetryProfile,
|
||
} from '../../game/core/runtimeProfileCompiler'
|
||
import {
|
||
type RecoveryRuntimeSnapshot,
|
||
} from '../../game/core/sessionRecovery'
|
||
|
||
const RENDER_MODE = 'Single WebGL Pipeline'
|
||
const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
|
||
const MAP_NORTH_OFFSET_DEG = 0
|
||
let MAGNETIC_DECLINATION_DEG = -6.91
|
||
let MAGNETIC_DECLINATION_TEXT = '6.91˚ W'
|
||
const MIN_ZOOM = 15
|
||
const MAX_ZOOM = 20
|
||
const DEFAULT_ZOOM = 17
|
||
const DESIRED_VISIBLE_COLUMNS = 3
|
||
const OVERDRAW = 1
|
||
const DEFAULT_TOP_LEFT_TILE_X = 108132
|
||
const DEFAULT_TOP_LEFT_TILE_Y = 51199
|
||
const DEFAULT_CENTER_TILE_X = DEFAULT_TOP_LEFT_TILE_X + 1
|
||
const DEFAULT_CENTER_TILE_Y = DEFAULT_TOP_LEFT_TILE_Y + 1
|
||
const TILE_SOURCE = 'https://oss-mbh5.colormaprun.com/wxMap/lcx/{z}/{x}/{y}.png'
|
||
const OSM_TILE_SOURCE = 'https://tiles.mymarsgo.xyz/{z}/{x}/{y}.png'
|
||
const MAP_OVERLAY_OPACITY = 0.72
|
||
const GPS_MAP_CALIBRATION: MapCalibration = {
|
||
offsetEastMeters: 0,
|
||
offsetNorthMeters: 0,
|
||
rotationDeg: 0,
|
||
scale: 1,
|
||
}
|
||
const MIN_PREVIEW_SCALE = 0.55
|
||
const MAX_PREVIEW_SCALE = 1.85
|
||
const INERTIA_FRAME_MS = 16
|
||
const INERTIA_DECAY = 0.92
|
||
const INERTIA_MIN_SPEED = 0.02
|
||
const PREVIEW_RESET_DURATION_MS = 140
|
||
const UI_SYNC_INTERVAL_MS = 80
|
||
const ROTATE_STEP_DEG = 15
|
||
const AUTO_ROTATE_FRAME_MS = 8
|
||
const AUTO_ROTATE_EASE = 0.34
|
||
const AUTO_ROTATE_SNAP_DEG = 0.1
|
||
const AUTO_ROTATE_DEADZONE_DEG = 4
|
||
const AUTO_ROTATE_MAX_STEP_DEG = 0.75
|
||
const AUTO_ROTATE_HEADING_SMOOTHING = 0.46
|
||
const COMPASS_NEEDLE_FRAME_MS = 16
|
||
const COMPASS_NEEDLE_SNAP_DEG = 0.08
|
||
const COMPASS_BOOTSTRAP_RETRY_DELAY_MS = 700
|
||
const COMPASS_TUNING_PRESETS: Record<CompassTuningProfile, {
|
||
needleMinSmoothing: number
|
||
needleMaxSmoothing: number
|
||
displayDeadzoneDeg: number
|
||
}> = {
|
||
smooth: {
|
||
needleMinSmoothing: 0.16,
|
||
needleMaxSmoothing: 0.4,
|
||
displayDeadzoneDeg: 0.75,
|
||
},
|
||
balanced: {
|
||
needleMinSmoothing: 0.22,
|
||
needleMaxSmoothing: 0.52,
|
||
displayDeadzoneDeg: 0.45,
|
||
},
|
||
responsive: {
|
||
needleMinSmoothing: 0.3,
|
||
needleMaxSmoothing: 0.68,
|
||
displayDeadzoneDeg: 0.2,
|
||
},
|
||
}
|
||
const SMART_HEADING_BLEND_START_SPEED_KMH = 1.2
|
||
const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0
|
||
const SMART_HEADING_MIN_DISTANCE_METERS = 12
|
||
const SMART_HEADING_MAX_ACCURACY_METERS = 25
|
||
const SMART_HEADING_MOVEMENT_MIN_SMOOTHING = 0.12
|
||
const SMART_HEADING_MOVEMENT_MAX_SMOOTHING = 0.24
|
||
const GPS_TRACK_MAX_POINTS = 200
|
||
const GPS_TRACK_MIN_STEP_METERS = 3
|
||
const MAP_TAP_MOVE_THRESHOLD_PX = 14
|
||
const MAP_TAP_DURATION_MS = 280
|
||
|
||
function clampNumber(value: number, min: number, max: number): number {
|
||
return Math.max(min, Math.min(max, value))
|
||
}
|
||
|
||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||
const normalized = hex.replace('#', '')
|
||
const full = normalized.length === 3
|
||
? normalized.split('').map((segment) => segment + segment).join('')
|
||
: normalized.padEnd(6, '0').slice(0, 6)
|
||
const parsed = Number.parseInt(full, 16)
|
||
return {
|
||
r: (parsed >> 16) & 0xff,
|
||
g: (parsed >> 8) & 0xff,
|
||
b: parsed & 0xff,
|
||
}
|
||
}
|
||
|
||
function rgbToHex(r: number, g: number, b: number): string {
|
||
const toHex = (value: number) => clampNumber(Math.round(value), 0, 255).toString(16).padStart(2, '0')
|
||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||
}
|
||
|
||
function mixHexColor(fromHex: string, toHex: string, amount: number): string {
|
||
const from = hexToRgb(fromHex)
|
||
const to = hexToRgb(toHex)
|
||
const factor = clampNumber(amount, 0, 1)
|
||
return rgbToHex(
|
||
from.r + (to.r - from.r) * factor,
|
||
from.g + (to.g - from.g) * factor,
|
||
from.b + (to.b - from.b) * factor,
|
||
)
|
||
}
|
||
|
||
type TouchPoint = WechatMiniprogram.TouchDetail
|
||
|
||
type GestureMode = 'idle' | 'pan' | 'pinch'
|
||
type RotationMode = 'manual' | 'auto'
|
||
type OrientationMode = 'manual' | 'north-up' | 'heading-up'
|
||
type AutoRotateSourceMode = 'sensor' | 'course' | 'fusion' | 'smart'
|
||
type SmartHeadingSource = 'sensor' | 'blended' | 'movement'
|
||
type NorthReferenceMode = 'magnetic' | 'true'
|
||
|
||
const DEFAULT_NORTH_REFERENCE_MODE: NorthReferenceMode = 'magnetic'
|
||
|
||
export interface MapEngineStageRect {
|
||
width: number
|
||
height: number
|
||
left: number
|
||
top: number
|
||
}
|
||
|
||
export interface MapEngineViewState {
|
||
animationLevel: AnimationLevel
|
||
buildVersion: string
|
||
renderMode: string
|
||
projectionMode: string
|
||
mapReady: boolean
|
||
mapReadyText: string
|
||
mapName: string
|
||
configStatusText: string
|
||
zoom: number
|
||
rotationDeg: number
|
||
rotationText: string
|
||
rotationMode: RotationMode
|
||
rotationModeText: string
|
||
rotationToggleText: string
|
||
orientationMode: OrientationMode
|
||
orientationModeText: string
|
||
sensorHeadingText: string
|
||
deviceHeadingText: string
|
||
devicePoseText: string
|
||
headingConfidenceText: string
|
||
accelerometerText: string
|
||
gyroscopeText: string
|
||
deviceMotionText: string
|
||
compassSourceText: string
|
||
compassTuningProfile: CompassTuningProfile
|
||
compassTuningProfileText: string
|
||
compassDeclinationText: string
|
||
northReferenceMode: NorthReferenceMode
|
||
northReferenceButtonText: string
|
||
autoRotateSourceText: string
|
||
autoRotateCalibrationText: string
|
||
northReferenceText: string
|
||
compassNeedleDeg: number
|
||
centerTileX: number
|
||
centerTileY: number
|
||
centerText: string
|
||
tileSource: string
|
||
visibleColumnCount: number
|
||
visibleTileCount: number
|
||
readyTileCount: number
|
||
memoryTileCount: number
|
||
diskTileCount: number
|
||
memoryHitCount: number
|
||
diskHitCount: number
|
||
networkFetchCount: number
|
||
cacheHitRateText: string
|
||
tileTranslateX: number
|
||
tileTranslateY: number
|
||
tileSizePx: number
|
||
previewScale: number
|
||
stageWidth: number
|
||
stageHeight: number
|
||
stageLeft: number
|
||
stageTop: number
|
||
statusText: string
|
||
gpsTracking: boolean
|
||
gpsTrackingText: string
|
||
gpsLockEnabled: boolean
|
||
gpsLockAvailable: boolean
|
||
locationSourceMode: 'real' | 'mock'
|
||
locationSourceText: string
|
||
mockBridgeConnected: boolean
|
||
mockBridgeStatusText: string
|
||
mockBridgeUrlText: string
|
||
mockChannelIdText: string
|
||
mockCoordText: string
|
||
mockSpeedText: string
|
||
gpsCoordText: string
|
||
heartRateSourceMode: 'real' | 'mock'
|
||
heartRateSourceText: string
|
||
heartRateConnected: boolean
|
||
heartRateStatusText: string
|
||
heartRateDeviceText: string
|
||
heartRateScanText: string
|
||
heartRateDiscoveredDevices: Array<{
|
||
deviceId: string
|
||
name: string
|
||
rssiText: string
|
||
preferred: boolean
|
||
connected: boolean
|
||
}>
|
||
mockHeartRateBridgeConnected: boolean
|
||
mockHeartRateBridgeStatusText: string
|
||
mockHeartRateBridgeUrlText: string
|
||
mockHeartRateText: string
|
||
mockDebugLogBridgeConnected: boolean
|
||
mockDebugLogBridgeStatusText: string
|
||
mockDebugLogBridgeUrlText: string
|
||
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
|
||
gameModeText: string
|
||
panelTimerText: string
|
||
panelTimerMode: 'elapsed' | 'countdown'
|
||
panelMileageText: string
|
||
panelActionTagText: string
|
||
panelDistanceTagText: string
|
||
panelTargetSummaryText: string
|
||
panelDistanceValueText: string
|
||
panelDistanceUnitText: string
|
||
panelProgressText: string
|
||
panelSpeedValueText: string
|
||
panelTelemetryTone: 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
|
||
trackDisplayMode: TrackDisplayMode
|
||
trackTailLength: TrackTailLengthPreset
|
||
trackColorPreset: TrackColorPreset
|
||
trackStyleProfile: TrackStyleProfile
|
||
gpsMarkerVisible: boolean
|
||
gpsMarkerStyle: GpsMarkerStyleId
|
||
gpsMarkerSize: GpsMarkerSizePreset
|
||
gpsMarkerColorPreset: GpsMarkerColorPreset
|
||
gpsLogoStatusText: string
|
||
gpsLogoSourceText: string
|
||
panelHeartRateZoneNameText: string
|
||
panelHeartRateZoneRangeText: string
|
||
panelHeartRateValueText: string
|
||
panelHeartRateUnitText: string
|
||
panelCaloriesValueText: string
|
||
panelCaloriesUnitText: string
|
||
panelAverageSpeedValueText: string
|
||
panelAverageSpeedUnitText: string
|
||
panelAccuracyValueText: string
|
||
panelAccuracyUnitText: string
|
||
punchButtonText: string
|
||
punchButtonEnabled: boolean
|
||
skipButtonEnabled: boolean
|
||
punchHintText: string
|
||
punchFeedbackVisible: boolean
|
||
punchFeedbackText: string
|
||
punchFeedbackTone: 'neutral' | 'success' | 'warning'
|
||
contentCardVisible: boolean
|
||
contentCardTemplate: ContentCardTemplate
|
||
contentCardTitle: string
|
||
contentCardBody: string
|
||
contentCardActions: ContentCardActionViewModel[]
|
||
contentQuizVisible: boolean
|
||
contentQuizQuestionText: string
|
||
contentQuizCountdownText: string
|
||
contentQuizOptions: ContentCardQuizOptionViewModel[]
|
||
contentQuizFeedbackVisible: boolean
|
||
contentQuizFeedbackText: string
|
||
contentQuizFeedbackTone: 'success' | 'error' | 'neutral'
|
||
pendingContentEntryVisible: boolean
|
||
pendingContentEntryText: string
|
||
punchButtonFxClass: string
|
||
panelProgressFxClass: string
|
||
panelDistanceFxClass: string
|
||
punchFeedbackFxClass: string
|
||
contentCardFxClass: string
|
||
mapPulseVisible: boolean
|
||
mapPulseLeftPx: number
|
||
mapPulseTopPx: number
|
||
mapPulseFxClass: string
|
||
stageFxVisible: boolean
|
||
stageFxClass: string
|
||
osmReferenceEnabled: boolean
|
||
osmReferenceText: string
|
||
}
|
||
|
||
export interface MapEngineCallbacks {
|
||
onData: (patch: Partial<MapEngineViewState>) => void
|
||
onOpenH5Experience?: (request: H5ExperienceRequest) => void
|
||
}
|
||
|
||
interface GpsTrackSample {
|
||
point: LonLatPoint
|
||
at: number
|
||
}
|
||
|
||
interface ContentCardEntry {
|
||
template: ContentCardTemplate
|
||
title: string
|
||
body: string
|
||
motionClass: string
|
||
contentKey: string
|
||
once: boolean
|
||
priority: number
|
||
autoPopup: boolean
|
||
ctas: ContentCardCtaConfig[]
|
||
h5Request: H5ExperienceRequest | null
|
||
}
|
||
|
||
export interface ContentCardQuizOptionViewModel {
|
||
key: string
|
||
label: string
|
||
}
|
||
|
||
export interface MapEngineGameInfoRow {
|
||
label: string
|
||
value: string
|
||
}
|
||
|
||
export interface MapEngineGameInfoSnapshot {
|
||
title: string
|
||
subtitle: string
|
||
localRows: MapEngineGameInfoRow[]
|
||
globalRows: MapEngineGameInfoRow[]
|
||
}
|
||
|
||
export type MapEngineResultSnapshot = ResultSummarySnapshot
|
||
|
||
export interface MapEngineSessionFinishSummary {
|
||
status: 'finished' | 'failed' | 'cancelled'
|
||
finalDurationSec?: number
|
||
finalScore?: number
|
||
completedControls?: number
|
||
totalControls?: number
|
||
distanceMeters?: number
|
||
averageSpeedKmh?: number
|
||
}
|
||
|
||
const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
||
'animationLevel',
|
||
'buildVersion',
|
||
'renderMode',
|
||
'projectionMode',
|
||
'mapReady',
|
||
'mapReadyText',
|
||
'mapName',
|
||
'configStatusText',
|
||
'zoom',
|
||
'centerTileX',
|
||
'centerTileY',
|
||
'rotationDeg',
|
||
'rotationText',
|
||
'rotationMode',
|
||
'rotationModeText',
|
||
'rotationToggleText',
|
||
'orientationMode',
|
||
'orientationModeText',
|
||
'sensorHeadingText',
|
||
'deviceHeadingText',
|
||
'devicePoseText',
|
||
'headingConfidenceText',
|
||
'accelerometerText',
|
||
'gyroscopeText',
|
||
'deviceMotionText',
|
||
'compassSourceText',
|
||
'compassTuningProfile',
|
||
'compassTuningProfileText',
|
||
'compassDeclinationText',
|
||
'northReferenceMode',
|
||
'northReferenceButtonText',
|
||
'autoRotateSourceText',
|
||
'autoRotateCalibrationText',
|
||
'northReferenceText',
|
||
'compassNeedleDeg',
|
||
'centerText',
|
||
'tileSource',
|
||
'visibleTileCount',
|
||
'readyTileCount',
|
||
'memoryTileCount',
|
||
'diskTileCount',
|
||
'memoryHitCount',
|
||
'diskHitCount',
|
||
'networkFetchCount',
|
||
'cacheHitRateText',
|
||
'tileSizePx',
|
||
'previewScale',
|
||
'stageWidth',
|
||
'stageHeight',
|
||
'stageLeft',
|
||
'stageTop',
|
||
'statusText',
|
||
'gpsTracking',
|
||
'gpsTrackingText',
|
||
'gpsLockEnabled',
|
||
'gpsLockAvailable',
|
||
'locationSourceMode',
|
||
'locationSourceText',
|
||
'mockBridgeConnected',
|
||
'mockBridgeStatusText',
|
||
'mockBridgeUrlText',
|
||
'mockChannelIdText',
|
||
'mockCoordText',
|
||
'mockSpeedText',
|
||
'gpsCoordText',
|
||
'heartRateSourceMode',
|
||
'heartRateSourceText',
|
||
'heartRateConnected',
|
||
'heartRateStatusText',
|
||
'heartRateDeviceText',
|
||
'heartRateScanText',
|
||
'heartRateDiscoveredDevices',
|
||
'mockHeartRateBridgeConnected',
|
||
'mockHeartRateBridgeStatusText',
|
||
'mockHeartRateBridgeUrlText',
|
||
'mockHeartRateText',
|
||
'mockDebugLogBridgeConnected',
|
||
'mockDebugLogBridgeStatusText',
|
||
'mockDebugLogBridgeUrlText',
|
||
'gameSessionStatus',
|
||
'gameModeText',
|
||
'panelTimerText',
|
||
'panelTimerMode',
|
||
'panelMileageText',
|
||
'panelActionTagText',
|
||
'panelDistanceTagText',
|
||
'panelTargetSummaryText',
|
||
'panelDistanceValueText',
|
||
'panelDistanceUnitText',
|
||
'panelProgressText',
|
||
'panelSpeedValueText',
|
||
'panelTelemetryTone',
|
||
'trackDisplayMode',
|
||
'trackTailLength',
|
||
'trackColorPreset',
|
||
'trackStyleProfile',
|
||
'gpsMarkerVisible',
|
||
'gpsMarkerStyle',
|
||
'gpsMarkerSize',
|
||
'gpsMarkerColorPreset',
|
||
'gpsLogoStatusText',
|
||
'gpsLogoSourceText',
|
||
'panelHeartRateZoneNameText',
|
||
'panelHeartRateZoneRangeText',
|
||
'panelHeartRateValueText',
|
||
'panelHeartRateUnitText',
|
||
'panelCaloriesValueText',
|
||
'panelCaloriesUnitText',
|
||
'panelAverageSpeedValueText',
|
||
'panelAverageSpeedUnitText',
|
||
'panelAccuracyValueText',
|
||
'panelAccuracyUnitText',
|
||
'punchButtonText',
|
||
'punchButtonEnabled',
|
||
'skipButtonEnabled',
|
||
'punchHintText',
|
||
'punchFeedbackVisible',
|
||
'punchFeedbackText',
|
||
'punchFeedbackTone',
|
||
'contentCardVisible',
|
||
'contentCardTemplate',
|
||
'contentCardTitle',
|
||
'contentCardBody',
|
||
'contentCardActions',
|
||
'contentQuizVisible',
|
||
'contentQuizQuestionText',
|
||
'contentQuizCountdownText',
|
||
'contentQuizOptions',
|
||
'contentQuizFeedbackVisible',
|
||
'contentQuizFeedbackText',
|
||
'contentQuizFeedbackTone',
|
||
'pendingContentEntryVisible',
|
||
'pendingContentEntryText',
|
||
'punchButtonFxClass',
|
||
'panelProgressFxClass',
|
||
'panelDistanceFxClass',
|
||
'punchFeedbackFxClass',
|
||
'contentCardFxClass',
|
||
'mapPulseVisible',
|
||
'mapPulseLeftPx',
|
||
'mapPulseTopPx',
|
||
'mapPulseFxClass',
|
||
'stageFxVisible',
|
||
'stageFxClass',
|
||
'osmReferenceEnabled',
|
||
'osmReferenceText',
|
||
]
|
||
|
||
const INTERACTION_DEFERRED_VIEW_KEYS = new Set<keyof MapEngineViewState>([
|
||
'rotationText',
|
||
'sensorHeadingText',
|
||
'deviceHeadingText',
|
||
'devicePoseText',
|
||
'headingConfidenceText',
|
||
'accelerometerText',
|
||
'gyroscopeText',
|
||
'deviceMotionText',
|
||
'compassSourceText',
|
||
'compassTuningProfile',
|
||
'compassTuningProfileText',
|
||
'compassDeclinationText',
|
||
'autoRotateSourceText',
|
||
'autoRotateCalibrationText',
|
||
'northReferenceText',
|
||
'centerText',
|
||
'gpsCoordText',
|
||
'visibleTileCount',
|
||
'readyTileCount',
|
||
'memoryTileCount',
|
||
'diskTileCount',
|
||
'memoryHitCount',
|
||
'diskHitCount',
|
||
'networkFetchCount',
|
||
'cacheHitRateText',
|
||
'heartRateDiscoveredDevices',
|
||
'mockCoordText',
|
||
'mockSpeedText',
|
||
'mockHeartRateText',
|
||
])
|
||
|
||
function buildCenterText(zoom: number, x: number, y: number): string {
|
||
return `z${zoom} / x${x} / y${y}`
|
||
}
|
||
|
||
function clamp(value: number, min: number, max: number): number {
|
||
return Math.max(min, Math.min(max, value))
|
||
}
|
||
|
||
function normalizeRotationDeg(rotationDeg: number): number {
|
||
const normalized = rotationDeg % 360
|
||
return normalized < 0 ? normalized + 360 : normalized
|
||
}
|
||
|
||
function normalizeAngleDeltaRad(angleDeltaRad: number): number {
|
||
let normalized = angleDeltaRad
|
||
|
||
while (normalized > Math.PI) {
|
||
normalized -= Math.PI * 2
|
||
}
|
||
|
||
while (normalized < -Math.PI) {
|
||
normalized += Math.PI * 2
|
||
}
|
||
|
||
return normalized
|
||
}
|
||
|
||
function normalizeAngleDeltaDeg(angleDeltaDeg: number): number {
|
||
let normalized = angleDeltaDeg
|
||
|
||
while (normalized > 180) {
|
||
normalized -= 360
|
||
}
|
||
|
||
while (normalized < -180) {
|
||
normalized += 360
|
||
}
|
||
|
||
return normalized
|
||
}
|
||
|
||
function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: number): number {
|
||
return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
|
||
}
|
||
|
||
function getCompassNeedleSmoothingFactor(
|
||
currentDeg: number,
|
||
targetDeg: number,
|
||
profile: CompassTuningProfile,
|
||
): number {
|
||
const preset = COMPASS_TUNING_PRESETS[profile]
|
||
const deltaDeg = Math.abs(normalizeAngleDeltaDeg(targetDeg - currentDeg))
|
||
if (deltaDeg <= 4) {
|
||
return preset.needleMinSmoothing
|
||
}
|
||
if (deltaDeg >= 36) {
|
||
return preset.needleMaxSmoothing
|
||
}
|
||
|
||
const progress = (deltaDeg - 4) / (36 - 4)
|
||
return preset.needleMinSmoothing
|
||
+ (preset.needleMaxSmoothing - preset.needleMinSmoothing) * progress
|
||
}
|
||
|
||
function getMovementHeadingSmoothingFactor(speedKmh: number | null): number {
|
||
if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
|
||
return SMART_HEADING_MOVEMENT_MIN_SMOOTHING
|
||
}
|
||
|
||
if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
|
||
return SMART_HEADING_MOVEMENT_MAX_SMOOTHING
|
||
}
|
||
|
||
const progress = (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH)
|
||
/ (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH)
|
||
return SMART_HEADING_MOVEMENT_MIN_SMOOTHING
|
||
+ (SMART_HEADING_MOVEMENT_MAX_SMOOTHING - SMART_HEADING_MOVEMENT_MIN_SMOOTHING) * progress
|
||
}
|
||
|
||
function formatGameSessionStatusText(status: 'idle' | 'running' | 'finished' | 'failed'): string {
|
||
if (status === 'running') {
|
||
return '进行中'
|
||
}
|
||
if (status === 'finished') {
|
||
return '已结束'
|
||
}
|
||
if (status === 'failed') {
|
||
return '已失败'
|
||
}
|
||
return '未开始'
|
||
}
|
||
|
||
function formatRotationText(rotationDeg: number): string {
|
||
return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
|
||
}
|
||
|
||
function normalizeDegreeDisplayText(text: string): string {
|
||
return text.replace(/[掳•˚]/g, '°')
|
||
}
|
||
|
||
function formatHeadingText(headingDeg: number | null): string {
|
||
if (headingDeg === null) {
|
||
return '--'
|
||
}
|
||
|
||
return `${Math.round(normalizeRotationDeg(headingDeg))}°`
|
||
}
|
||
|
||
function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string {
|
||
if (pose === 'flat') {
|
||
return '平放'
|
||
}
|
||
|
||
if (pose === 'tilted') {
|
||
return '倾斜'
|
||
}
|
||
|
||
return '竖持'
|
||
}
|
||
|
||
function formatHeadingConfidenceText(confidence: 'low' | 'medium' | 'high'): string {
|
||
if (confidence === 'high') {
|
||
return '高'
|
||
}
|
||
|
||
if (confidence === 'medium') {
|
||
return '中'
|
||
}
|
||
|
||
return '低'
|
||
}
|
||
|
||
function formatClockTime(timestamp: number | null): string {
|
||
if (!timestamp || !Number.isFinite(timestamp)) {
|
||
return '--:--:--'
|
||
}
|
||
|
||
const date = new Date(timestamp)
|
||
const hh = String(date.getHours()).padStart(2, '0')
|
||
const mm = String(date.getMinutes()).padStart(2, '0')
|
||
const ss = String(date.getSeconds()).padStart(2, '0')
|
||
return `${hh}:${mm}:${ss}`
|
||
}
|
||
|
||
function formatGyroscopeText(gyroscope: { x: number; y: number; z: number } | null): string {
|
||
if (!gyroscope) {
|
||
return '--'
|
||
}
|
||
|
||
return `x:${gyroscope.x.toFixed(2)} y:${gyroscope.y.toFixed(2)} z:${gyroscope.z.toFixed(2)}`
|
||
}
|
||
|
||
function formatDeviceMotionText(motion: { alpha: number | null; beta: number | null; gamma: number | null } | null): string {
|
||
if (!motion) {
|
||
return '--'
|
||
}
|
||
|
||
const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha))
|
||
const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta)
|
||
const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma)
|
||
return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}`
|
||
}
|
||
|
||
function formatOrientationModeText(mode: OrientationMode): string {
|
||
if (mode === 'north-up') {
|
||
return 'North Up'
|
||
}
|
||
|
||
if (mode === 'heading-up') {
|
||
return 'Heading Up'
|
||
}
|
||
|
||
return 'Manual Gesture'
|
||
}
|
||
|
||
function formatRotationModeText(mode: OrientationMode): string {
|
||
return formatOrientationModeText(mode)
|
||
}
|
||
|
||
function formatRotationToggleText(mode: OrientationMode): string {
|
||
if (mode === 'manual') {
|
||
return '切到北朝上'
|
||
}
|
||
|
||
if (mode === 'north-up') {
|
||
return '切到朝向朝上'
|
||
}
|
||
|
||
return '切到手动旋转'
|
||
}
|
||
|
||
function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
|
||
if (mode === 'smart') {
|
||
return 'Smart / 手机朝向'
|
||
}
|
||
|
||
if (mode === 'sensor') {
|
||
return 'Sensor Only'
|
||
}
|
||
|
||
if (mode === 'course') {
|
||
return hasCourseHeading ? 'Course Only' : 'Course Pending'
|
||
}
|
||
|
||
return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
|
||
}
|
||
|
||
function formatSmartHeadingSourceText(source: SmartHeadingSource): string {
|
||
if (source === 'movement') {
|
||
return 'Smart / 前进方向'
|
||
}
|
||
|
||
if (source === 'blended') {
|
||
return 'Smart / 融合'
|
||
}
|
||
|
||
return 'Smart / 手机朝向'
|
||
}
|
||
|
||
function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string {
|
||
if (pending) {
|
||
return 'Pending'
|
||
}
|
||
|
||
if (offsetDeg === null) {
|
||
return '--'
|
||
}
|
||
|
||
return `Offset ${Math.round(normalizeRotationDeg(offsetDeg))}deg`
|
||
}
|
||
|
||
function getTrueHeadingDeg(magneticHeadingDeg: number): number {
|
||
return normalizeRotationDeg(magneticHeadingDeg + MAGNETIC_DECLINATION_DEG)
|
||
}
|
||
|
||
function getMagneticHeadingDeg(trueHeadingDeg: number): number {
|
||
return normalizeRotationDeg(trueHeadingDeg - MAGNETIC_DECLINATION_DEG)
|
||
}
|
||
|
||
function getMapNorthOffsetDeg(_mode: NorthReferenceMode): number {
|
||
return MAP_NORTH_OFFSET_DEG
|
||
}
|
||
|
||
function getCompassReferenceHeadingDeg(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
|
||
if (mode === 'true') {
|
||
return getTrueHeadingDeg(magneticHeadingDeg)
|
||
}
|
||
|
||
return normalizeRotationDeg(magneticHeadingDeg)
|
||
}
|
||
|
||
function getMapReferenceHeadingDegFromSensor(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
|
||
if (mode === 'magnetic') {
|
||
return normalizeRotationDeg(magneticHeadingDeg)
|
||
}
|
||
|
||
return getTrueHeadingDeg(magneticHeadingDeg)
|
||
}
|
||
|
||
function getMapReferenceHeadingDegFromCourse(mode: NorthReferenceMode, trueHeadingDeg: number): number {
|
||
if (mode === 'magnetic') {
|
||
return getMagneticHeadingDeg(trueHeadingDeg)
|
||
}
|
||
|
||
return normalizeRotationDeg(trueHeadingDeg)
|
||
}
|
||
|
||
function formatNorthReferenceText(mode: NorthReferenceMode): string {
|
||
if (mode === 'magnetic') {
|
||
return `Compass Magnetic / Heading-Up Magnetic (${MAGNETIC_DECLINATION_TEXT})`
|
||
}
|
||
|
||
return `Compass True / Heading-Up True (${MAGNETIC_DECLINATION_TEXT})`
|
||
}
|
||
|
||
function formatCompassDeclinationText(mode: NorthReferenceMode): string {
|
||
if (mode === 'true') {
|
||
return MAGNETIC_DECLINATION_TEXT
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
function formatCompassSourceText(source: 'compass' | 'motion' | null): string {
|
||
if (source === 'compass') {
|
||
return '罗盘'
|
||
}
|
||
if (source === 'motion') {
|
||
return '设备方向兜底'
|
||
}
|
||
return '无数据'
|
||
}
|
||
|
||
function formatCompassTuningProfileText(profile: CompassTuningProfile): string {
|
||
if (profile === 'smooth') {
|
||
return '顺滑'
|
||
}
|
||
if (profile === 'responsive') {
|
||
return '跟手'
|
||
}
|
||
return '平衡'
|
||
}
|
||
|
||
function formatTrackDisplayModeText(mode: TrackDisplayMode): string {
|
||
if (mode === 'none') {
|
||
return '无'
|
||
}
|
||
if (mode === 'tail') {
|
||
return '彗尾'
|
||
}
|
||
return '全轨迹'
|
||
}
|
||
|
||
function formatTrackTailLengthText(length: TrackTailLengthPreset): string {
|
||
if (length === 'short') {
|
||
return '短'
|
||
}
|
||
if (length === 'long') {
|
||
return '长'
|
||
}
|
||
return '中'
|
||
}
|
||
|
||
function formatTrackColorPresetText(colorPreset: TrackColorPreset): string {
|
||
const labels: Record<TrackColorPreset, string> = {
|
||
mint: '薄荷',
|
||
cyan: '青绿',
|
||
sky: '天蓝',
|
||
blue: '深蓝',
|
||
violet: '紫罗兰',
|
||
pink: '玫红',
|
||
orange: '橙色',
|
||
yellow: '亮黄',
|
||
}
|
||
return labels[colorPreset]
|
||
}
|
||
|
||
function formatGpsMarkerSizeText(size: GpsMarkerSizePreset): string {
|
||
if (size === 'small') {
|
||
return '小'
|
||
}
|
||
if (size === 'large') {
|
||
return '大'
|
||
}
|
||
return '中'
|
||
}
|
||
|
||
function formatGpsMarkerStyleText(style: GpsMarkerStyleId): string {
|
||
if (style === 'dot') {
|
||
return '圆点'
|
||
}
|
||
if (style === 'disc') {
|
||
return '圆盘'
|
||
}
|
||
if (style === 'badge') {
|
||
return '徽章'
|
||
}
|
||
return '信标'
|
||
}
|
||
|
||
function formatGpsMarkerColorPresetText(colorPreset: GpsMarkerColorPreset): string {
|
||
const labels: Record<GpsMarkerColorPreset, string> = {
|
||
mint: '薄荷',
|
||
cyan: '青绿',
|
||
sky: '天蓝',
|
||
blue: '深蓝',
|
||
violet: '紫罗兰',
|
||
pink: '玫红',
|
||
orange: '橙色',
|
||
yellow: '亮黄',
|
||
}
|
||
return labels[colorPreset]
|
||
}
|
||
|
||
function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
|
||
return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北'
|
||
}
|
||
|
||
function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
|
||
if (mode === 'magnetic') {
|
||
return '已切到磁北模式'
|
||
}
|
||
|
||
return '已切到真北模式'
|
||
}
|
||
|
||
function getNextNorthReferenceMode(mode: NorthReferenceMode): NorthReferenceMode {
|
||
return mode === 'magnetic' ? 'true' : 'magnetic'
|
||
}
|
||
|
||
function formatCompassNeedleDegForMode(mode: NorthReferenceMode, magneticHeadingDeg: number | null): number {
|
||
if (magneticHeadingDeg === null) {
|
||
return 0
|
||
}
|
||
|
||
const referenceHeadingDeg = mode === 'true'
|
||
? getTrueHeadingDeg(magneticHeadingDeg)
|
||
: normalizeRotationDeg(magneticHeadingDeg)
|
||
|
||
return normalizeRotationDeg(360 - referenceHeadingDeg)
|
||
}
|
||
|
||
function formatCacheHitRate(memoryHitCount: number, diskHitCount: number, networkFetchCount: number): string {
|
||
const total = memoryHitCount + diskHitCount + networkFetchCount
|
||
if (!total) {
|
||
return '--'
|
||
}
|
||
|
||
const hitRate = ((memoryHitCount + diskHitCount) / total) * 100
|
||
return `${Math.round(hitRate)}%`
|
||
}
|
||
|
||
function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number | null): string {
|
||
if (!point) {
|
||
return '--'
|
||
}
|
||
|
||
const base = `${point.lat.toFixed(6)}, ${point.lon.toFixed(6)}`
|
||
if (accuracyMeters === null || !Number.isFinite(accuracyMeters)) {
|
||
return base
|
||
}
|
||
|
||
return `${base} / 卤${Math.round(accuracyMeters)}m`
|
||
}
|
||
|
||
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 resolveSmartHeadingSource(speedKmh: number | null, movementReliable: boolean): SmartHeadingSource {
|
||
if (!movementReliable || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
|
||
return 'sensor'
|
||
}
|
||
|
||
if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
|
||
return 'movement'
|
||
}
|
||
|
||
return 'blended'
|
||
}
|
||
|
||
function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
|
||
const fromLatRad = from.lat * Math.PI / 180
|
||
const toLatRad = to.lat * Math.PI / 180
|
||
const deltaLonRad = (to.lon - from.lon) * Math.PI / 180
|
||
const y = Math.sin(deltaLonRad) * Math.cos(toLatRad)
|
||
const x = Math.cos(fromLatRad) * Math.sin(toLatRad) - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad)
|
||
const bearingDeg = Math.atan2(y, x) * 180 / Math.PI
|
||
return normalizeRotationDeg(bearingDeg)
|
||
}
|
||
|
||
export class MapEngine {
|
||
buildVersion: string
|
||
animationLevel: AnimationLevel
|
||
renderer: WebGLMapRenderer
|
||
accelerometerController: AccelerometerController
|
||
compassController: CompassHeadingController
|
||
gyroscopeController: GyroscopeController
|
||
deviceMotionController: DeviceMotionController
|
||
locationController: LocationController
|
||
heartRateController: HeartRateInputController
|
||
feedbackDirector: FeedbackDirector
|
||
mockSimulatorDebugLogger: MockSimulatorDebugLogger
|
||
onData: (patch: Partial<MapEngineViewState>) => void
|
||
state: MapEngineViewState
|
||
accelerometerErrorText: string | null
|
||
previewScale: number
|
||
previewOriginX: number
|
||
previewOriginY: number
|
||
panLastX: number
|
||
panLastY: number
|
||
panLastTimestamp: number
|
||
tapStartX: number
|
||
tapStartY: number
|
||
tapStartAt: number
|
||
panVelocityX: number
|
||
panVelocityY: number
|
||
pinchStartDistance: number
|
||
pinchStartScale: number
|
||
pinchStartAngle: number
|
||
pinchStartRotationDeg: number
|
||
pinchAnchorWorldX: number
|
||
pinchAnchorWorldY: number
|
||
gestureMode: GestureMode
|
||
inertiaTimer: number
|
||
previewResetTimer: number
|
||
viewSyncTimer: number
|
||
autoRotateTimer: number
|
||
compassNeedleTimer: number
|
||
compassBootstrapRetryTimer: number
|
||
pendingViewPatch: Partial<MapEngineViewState>
|
||
mounted: boolean
|
||
diagnosticUiEnabled: boolean
|
||
northReferenceMode: NorthReferenceMode
|
||
sensorHeadingDeg: number | null
|
||
smoothedSensorHeadingDeg: number | null
|
||
compassDisplayHeadingDeg: number | null
|
||
targetCompassDisplayHeadingDeg: number | null
|
||
lastCompassSampleAt: number
|
||
compassSource: 'compass' | 'motion' | null
|
||
compassTuningProfile: CompassTuningProfile
|
||
smoothedMovementHeadingDeg: number | null
|
||
autoRotateHeadingDeg: number | null
|
||
courseHeadingDeg: number | null
|
||
targetAutoRotationDeg: number | null
|
||
autoRotateSourceMode: AutoRotateSourceMode
|
||
autoRotateCalibrationOffsetDeg: number | null
|
||
autoRotateCalibrationPending: boolean
|
||
lastStatsUiSyncAt: number
|
||
minZoom: number
|
||
maxZoom: number
|
||
defaultZoom: number
|
||
defaultCenterTileX: number
|
||
defaultCenterTileY: number
|
||
tileBoundsByZoom: Record<number, TileZoomBounds> | null
|
||
currentGpsPoint: LonLatPoint | null
|
||
currentGpsTrack: LonLatPoint[]
|
||
currentGpsTrackSamples: GpsTrackSample[]
|
||
currentGpsAccuracyMeters: number | null
|
||
currentGpsInsideMap: boolean
|
||
lastTrackMotionAt: number
|
||
courseData: OrienteeringCourseData | null
|
||
courseOverlayVisible: boolean
|
||
cpRadiusMeters: number
|
||
configAppId: string
|
||
configSchemaVersion: string
|
||
configVersion: string
|
||
playfieldKind: string
|
||
controlScoreOverrides: Record<string, number>
|
||
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
|
||
defaultControlContentOverride: GameControlDisplayContentOverride | null
|
||
defaultControlPointStyleOverride: ControlPointStyleEntry | null
|
||
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
|
||
defaultLegStyleOverride: CourseLegStyleEntry | null
|
||
legStyleOverrides: Record<number, CourseLegStyleEntry>
|
||
defaultControlScore: number | null
|
||
courseStyleConfig: CourseStyleConfig
|
||
trackStyleConfig: TrackVisualizationConfig
|
||
gpsMarkerStyleConfig: GpsMarkerStyleConfig
|
||
gameRuntime: GameRuntime
|
||
telemetryRuntime: TelemetryRuntime
|
||
telemetryPlayerProfile: PlayerTelemetryProfile | null
|
||
gamePresentation: GamePresentationState
|
||
gameMode: 'classic-sequential' | 'score-o'
|
||
sessionCloseAfterMs: number
|
||
sessionCloseWarningMs: number
|
||
minCompletedControlsBeforeFinish: number
|
||
punchPolicy: 'enter' | 'enter-confirm'
|
||
punchRadiusMeters: number
|
||
requiresFocusSelection: boolean
|
||
skipEnabled: boolean
|
||
skipRadiusMeters: number
|
||
skipRequiresConfirm: boolean
|
||
autoFinishOnLastControl: boolean
|
||
punchFeedbackTimer: number
|
||
contentCardTimer: number
|
||
contentQuizTimer: number
|
||
contentQuizFeedbackTimer: number
|
||
currentContentCardPriority: number
|
||
shownContentCardKeys: Record<string, true>
|
||
consumedContentQuizKeys: Record<string, true>
|
||
rewardedContentQuizKeys: Record<string, true>
|
||
sessionBonusScore: number
|
||
currentContentCard: ContentCardEntry | null
|
||
pendingContentCards: ContentCardEntry[]
|
||
currentContentCardH5Request: H5ExperienceRequest | null
|
||
currentH5ExperienceOpen: boolean
|
||
currentContentQuizKey: string
|
||
currentContentQuizAnswer: number
|
||
currentContentQuizBonusScore: number
|
||
sessionQuizCorrectCount: number
|
||
sessionQuizWrongCount: number
|
||
sessionQuizTimeoutCount: number
|
||
mapPulseTimer: number
|
||
stageFxTimer: number
|
||
sessionTimerInterval: number
|
||
hasGpsCenteredOnce: boolean
|
||
gpsLockEnabled: boolean
|
||
onOpenH5Experience?: (request: H5ExperienceRequest) => void
|
||
|
||
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
||
this.buildVersion = buildVersion
|
||
this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync())
|
||
this.compassTuningProfile = 'balanced'
|
||
this.onData = callbacks.onData
|
||
this.onOpenH5Experience = callbacks.onOpenH5Experience
|
||
this.accelerometerErrorText = null
|
||
this.mockSimulatorDebugLogger = new MockSimulatorDebugLogger((debugState) => {
|
||
this.setState({
|
||
mockDebugLogBridgeConnected: debugState.connected,
|
||
mockDebugLogBridgeStatusText: debugState.statusText,
|
||
mockDebugLogBridgeUrlText: debugState.url,
|
||
})
|
||
})
|
||
this.renderer = new WebGLMapRenderer(
|
||
(stats) => {
|
||
this.applyStats(stats)
|
||
},
|
||
(message) => {
|
||
this.setState({
|
||
statusText: `${message} (${this.buildVersion})`,
|
||
})
|
||
},
|
||
(info) => {
|
||
const statusText = !info.url
|
||
? '未配置'
|
||
: info.status === 'ready'
|
||
? '已就绪'
|
||
: info.status === 'loading'
|
||
? '加载中'
|
||
: info.status === 'error'
|
||
? '加载失败'
|
||
: '空闲'
|
||
this.setState({
|
||
gpsLogoStatusText: statusText,
|
||
gpsLogoSourceText: info.resolvedSrc || info.url || '--',
|
||
})
|
||
},
|
||
(scope, level, message, payload) => {
|
||
this.mockSimulatorDebugLogger.log(scope, level, message, payload)
|
||
},
|
||
)
|
||
this.accelerometerController = new AccelerometerController({
|
||
onSample: (x, y, z) => {
|
||
this.accelerometerErrorText = null
|
||
this.telemetryRuntime.dispatch({
|
||
type: 'accelerometer_updated',
|
||
at: Date.now(),
|
||
x,
|
||
y,
|
||
z,
|
||
})
|
||
if (this.diagnosticUiEnabled) {
|
||
this.setState(this.getTelemetrySensorViewPatch())
|
||
}
|
||
},
|
||
onError: (message) => {
|
||
this.accelerometerErrorText = `不可用: ${message}`
|
||
if (this.diagnosticUiEnabled) {
|
||
this.setState({
|
||
...this.getTelemetrySensorViewPatch(),
|
||
statusText: `加速度计启动失败 (${this.buildVersion})`,
|
||
})
|
||
}
|
||
},
|
||
})
|
||
this.compassController = new CompassHeadingController({
|
||
onHeading: (headingDeg) => {
|
||
this.handleCompassHeading(headingDeg)
|
||
},
|
||
onError: (message) => {
|
||
this.handleCompassError(message)
|
||
},
|
||
})
|
||
this.compassController.setTuningProfile(this.compassTuningProfile)
|
||
this.gyroscopeController = new GyroscopeController({
|
||
onSample: (x, y, z) => {
|
||
this.telemetryRuntime.dispatch({
|
||
type: 'gyroscope_updated',
|
||
at: Date.now(),
|
||
x,
|
||
y,
|
||
z,
|
||
})
|
||
if (this.diagnosticUiEnabled) {
|
||
this.setState(this.getTelemetrySensorViewPatch())
|
||
}
|
||
},
|
||
onError: () => {
|
||
if (this.diagnosticUiEnabled) {
|
||
this.setState(this.getTelemetrySensorViewPatch())
|
||
}
|
||
},
|
||
})
|
||
this.deviceMotionController = new DeviceMotionController({
|
||
onSample: (alpha, beta, gamma) => {
|
||
this.telemetryRuntime.dispatch({
|
||
type: 'device_motion_updated',
|
||
at: Date.now(),
|
||
alpha,
|
||
beta,
|
||
gamma,
|
||
})
|
||
if (this.diagnosticUiEnabled) {
|
||
this.setState({
|
||
...this.getTelemetrySensorViewPatch(),
|
||
autoRotateSourceText: this.getAutoRotateSourceText(),
|
||
})
|
||
}
|
||
},
|
||
onError: () => {
|
||
if (this.diagnosticUiEnabled) {
|
||
this.setState(this.getTelemetrySensorViewPatch())
|
||
}
|
||
},
|
||
})
|
||
this.locationController = new LocationController({
|
||
onLocation: (update) => {
|
||
this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
|
||
},
|
||
onStatus: (message) => {
|
||
this.setState({
|
||
gpsTracking: this.locationController.listening,
|
||
gpsTrackingText: message,
|
||
...this.getLocationControllerViewPatch(),
|
||
})
|
||
},
|
||
onError: (message) => {
|
||
this.setState({
|
||
gpsTracking: this.locationController.listening,
|
||
gpsTrackingText: message,
|
||
...this.getLocationControllerViewPatch(),
|
||
statusText: `${message} (${this.buildVersion})`,
|
||
})
|
||
},
|
||
onDebugStateChange: () => {
|
||
if (this.diagnosticUiEnabled) {
|
||
this.setState(this.getLocationControllerViewPatch())
|
||
}
|
||
},
|
||
})
|
||
this.heartRateController = new HeartRateInputController({
|
||
onHeartRate: (bpm) => {
|
||
this.telemetryRuntime.dispatch({
|
||
type: 'heart_rate_updated',
|
||
at: Date.now(),
|
||
bpm,
|
||
})
|
||
this.syncSessionTimerText()
|
||
},
|
||
onStatus: (message) => {
|
||
const deviceName = this.heartRateController.currentDeviceName
|
||
|| (this.heartRateController.reconnecting ? this.heartRateController.lastDeviceName : null)
|
||
|| '--'
|
||
this.setState({
|
||
heartRateStatusText: message,
|
||
heartRateDeviceText: deviceName,
|
||
heartRateScanText: this.getHeartRateScanText(),
|
||
...this.getHeartRateControllerViewPatch(),
|
||
})
|
||
},
|
||
onError: (message) => {
|
||
this.clearHeartRateSignal()
|
||
const deviceName = this.heartRateController.reconnecting
|
||
? (this.heartRateController.lastDeviceName || '--')
|
||
: '--'
|
||
this.setState({
|
||
heartRateConnected: false,
|
||
heartRateStatusText: message,
|
||
heartRateDeviceText: deviceName,
|
||
heartRateScanText: this.getHeartRateScanText(),
|
||
...this.getHeartRateControllerViewPatch(),
|
||
statusText: `${message} (${this.buildVersion})`,
|
||
})
|
||
},
|
||
onConnectionChange: (connected, deviceName) => {
|
||
if (!connected) {
|
||
this.clearHeartRateSignal()
|
||
}
|
||
const resolvedDeviceName = connected
|
||
? (deviceName || '--')
|
||
: (this.heartRateController.reconnecting
|
||
? (this.heartRateController.lastDeviceName || '--')
|
||
: '--')
|
||
this.setState({
|
||
heartRateConnected: connected,
|
||
heartRateDeviceText: resolvedDeviceName,
|
||
heartRateStatusText: connected
|
||
? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
|
||
: (this.heartRateController.reconnecting ? '心率带自动重连中' : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接')),
|
||
heartRateScanText: this.getHeartRateScanText(),
|
||
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
|
||
...this.getHeartRateControllerViewPatch(),
|
||
})
|
||
},
|
||
onDeviceListChange: (devices) => {
|
||
if (this.diagnosticUiEnabled) {
|
||
this.setState({
|
||
heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
|
||
heartRateScanText: this.getHeartRateScanText(),
|
||
...this.getHeartRateControllerViewPatch(),
|
||
})
|
||
}
|
||
},
|
||
onDebugStateChange: () => {
|
||
if (this.diagnosticUiEnabled) {
|
||
this.setState(this.getHeartRateControllerViewPatch())
|
||
}
|
||
},
|
||
})
|
||
this.feedbackDirector = new FeedbackDirector({
|
||
showPunchFeedback: (text, tone, motionClass) => {
|
||
this.showPunchFeedback(text, tone, motionClass)
|
||
},
|
||
showContentCard: (title, body, motionClass, options) => {
|
||
this.showContentCard(title, body, motionClass, options)
|
||
},
|
||
setPunchButtonFxClass: (className) => {
|
||
this.setPunchButtonFxClass(className)
|
||
},
|
||
setHudProgressFxClass: (className) => {
|
||
this.setHudProgressFxClass(className)
|
||
},
|
||
setHudDistanceFxClass: (className) => {
|
||
this.setHudDistanceFxClass(className)
|
||
},
|
||
showMapPulse: (controlId, motionClass) => {
|
||
this.showMapPulse(controlId, motionClass)
|
||
},
|
||
showStageFx: (className) => {
|
||
this.showStageFx(className)
|
||
},
|
||
stopLocationTracking: () => {
|
||
if (this.locationController.listening) {
|
||
this.locationController.stop()
|
||
}
|
||
},
|
||
})
|
||
this.feedbackDirector.setAnimationLevel(this.animationLevel)
|
||
this.minZoom = MIN_ZOOM
|
||
this.maxZoom = MAX_ZOOM
|
||
this.defaultZoom = DEFAULT_ZOOM
|
||
this.defaultCenterTileX = DEFAULT_CENTER_TILE_X
|
||
this.defaultCenterTileY = DEFAULT_CENTER_TILE_Y
|
||
this.tileBoundsByZoom = null
|
||
this.currentGpsPoint = null
|
||
this.currentGpsTrack = []
|
||
this.currentGpsTrackSamples = []
|
||
this.currentGpsAccuracyMeters = null
|
||
this.currentGpsInsideMap = false
|
||
this.lastTrackMotionAt = 0
|
||
this.courseData = null
|
||
this.courseOverlayVisible = false
|
||
this.cpRadiusMeters = 5
|
||
this.configAppId = ''
|
||
this.configSchemaVersion = '1'
|
||
this.configVersion = ''
|
||
this.playfieldKind = ''
|
||
this.controlScoreOverrides = {}
|
||
this.controlContentOverrides = {}
|
||
this.defaultControlContentOverride = null
|
||
this.defaultControlPointStyleOverride = null
|
||
this.controlPointStyleOverrides = {}
|
||
this.defaultLegStyleOverride = null
|
||
this.legStyleOverrides = {}
|
||
this.defaultControlScore = null
|
||
this.courseStyleConfig = DEFAULT_COURSE_STYLE_CONFIG
|
||
this.trackStyleConfig = DEFAULT_TRACK_VISUALIZATION_CONFIG
|
||
this.gpsMarkerStyleConfig = DEFAULT_GPS_MARKER_STYLE_CONFIG
|
||
this.gameRuntime = new GameRuntime()
|
||
this.telemetryRuntime = new TelemetryRuntime()
|
||
this.telemetryPlayerProfile = null
|
||
this.telemetryRuntime.configure()
|
||
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
|
||
this.gameMode = 'classic-sequential'
|
||
const modeDefaults = getGameModeDefaults(this.gameMode)
|
||
this.sessionCloseAfterMs = modeDefaults.sessionCloseAfterMs
|
||
this.sessionCloseWarningMs = modeDefaults.sessionCloseWarningMs
|
||
this.minCompletedControlsBeforeFinish = modeDefaults.minCompletedControlsBeforeFinish
|
||
this.punchPolicy = 'enter-confirm'
|
||
this.punchRadiusMeters = 5
|
||
this.requiresFocusSelection = modeDefaults.requiresFocusSelection
|
||
this.skipEnabled = modeDefaults.skipEnabled
|
||
this.skipRadiusMeters = getDefaultSkipRadiusMeters(this.gameMode, this.punchRadiusMeters)
|
||
this.skipRequiresConfirm = modeDefaults.skipRequiresConfirm
|
||
this.autoFinishOnLastControl = modeDefaults.autoFinishOnLastControl
|
||
this.defaultControlScore = modeDefaults.defaultControlScore
|
||
this.gpsLockEnabled = false
|
||
this.punchFeedbackTimer = 0
|
||
this.contentCardTimer = 0
|
||
this.contentQuizTimer = 0
|
||
this.contentQuizFeedbackTimer = 0
|
||
this.currentContentCardPriority = 0
|
||
this.shownContentCardKeys = {}
|
||
this.consumedContentQuizKeys = {}
|
||
this.rewardedContentQuizKeys = {}
|
||
this.sessionBonusScore = 0
|
||
this.currentContentCard = null
|
||
this.pendingContentCards = []
|
||
this.currentContentCardH5Request = null
|
||
this.currentH5ExperienceOpen = false
|
||
this.currentContentQuizKey = ''
|
||
this.currentContentQuizAnswer = 0
|
||
this.currentContentQuizBonusScore = 0
|
||
this.sessionQuizCorrectCount = 0
|
||
this.sessionQuizWrongCount = 0
|
||
this.sessionQuizTimeoutCount = 0
|
||
this.mapPulseTimer = 0
|
||
this.stageFxTimer = 0
|
||
this.sessionTimerInterval = 0
|
||
this.hasGpsCenteredOnce = false
|
||
this.state = {
|
||
animationLevel: this.animationLevel,
|
||
buildVersion: this.buildVersion,
|
||
renderMode: RENDER_MODE,
|
||
projectionMode: PROJECTION_MODE,
|
||
mapReady: false,
|
||
mapReadyText: 'BOOTING',
|
||
mapName: '未命名配置',
|
||
configStatusText: '远程配置待加载',
|
||
zoom: DEFAULT_ZOOM,
|
||
rotationDeg: 0,
|
||
rotationText: formatRotationText(0),
|
||
rotationMode: 'manual',
|
||
rotationModeText: formatRotationModeText('manual'),
|
||
rotationToggleText: formatRotationToggleText('manual'),
|
||
orientationMode: 'manual',
|
||
orientationModeText: formatOrientationModeText('manual'),
|
||
sensorHeadingText: '--',
|
||
deviceHeadingText: '--',
|
||
devicePoseText: '竖持',
|
||
headingConfidenceText: '低',
|
||
accelerometerText: '未启用',
|
||
gyroscopeText: '--',
|
||
deviceMotionText: '--',
|
||
compassSourceText: '无数据',
|
||
compassTuningProfile: this.compassTuningProfile,
|
||
compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
|
||
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
|
||
northReferenceMode: DEFAULT_NORTH_REFERENCE_MODE,
|
||
northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
|
||
autoRotateSourceText: formatAutoRotateSourceText('smart', false),
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
|
||
northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
|
||
compassNeedleDeg: 0,
|
||
centerTileX: DEFAULT_CENTER_TILE_X,
|
||
centerTileY: DEFAULT_CENTER_TILE_Y,
|
||
centerText: buildCenterText(DEFAULT_ZOOM, DEFAULT_CENTER_TILE_X, DEFAULT_CENTER_TILE_Y),
|
||
tileSource: TILE_SOURCE,
|
||
visibleColumnCount: DESIRED_VISIBLE_COLUMNS,
|
||
visibleTileCount: 0,
|
||
readyTileCount: 0,
|
||
memoryTileCount: 0,
|
||
diskTileCount: 0,
|
||
memoryHitCount: 0,
|
||
diskHitCount: 0,
|
||
networkFetchCount: 0,
|
||
cacheHitRateText: '--',
|
||
tileTranslateX: 0,
|
||
tileTranslateY: 0,
|
||
tileSizePx: 0,
|
||
previewScale: 1,
|
||
stageWidth: 0,
|
||
stageHeight: 0,
|
||
stageLeft: 0,
|
||
stageTop: 0,
|
||
statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
|
||
gpsTracking: false,
|
||
gpsTrackingText: '持续定位待启动',
|
||
gpsLockEnabled: false,
|
||
gpsLockAvailable: false,
|
||
locationSourceMode: 'real',
|
||
locationSourceText: '真实定位',
|
||
mockBridgeConnected: false,
|
||
mockBridgeStatusText: '未连接',
|
||
mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||
mockChannelIdText: 'default',
|
||
mockCoordText: '--',
|
||
mockSpeedText: '--',
|
||
gpsCoordText: '--',
|
||
heartRateSourceMode: 'real',
|
||
heartRateSourceText: '真实心率',
|
||
heartRateConnected: false,
|
||
heartRateStatusText: '心率带未连接',
|
||
heartRateDeviceText: '--',
|
||
heartRateScanText: '未扫描',
|
||
heartRateDiscoveredDevices: [],
|
||
mockHeartRateBridgeConnected: false,
|
||
mockHeartRateBridgeStatusText: '未连接',
|
||
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr',
|
||
mockHeartRateText: '--',
|
||
mockDebugLogBridgeConnected: false,
|
||
mockDebugLogBridgeStatusText: '已关闭 (wss://gs.gotomars.xyz/debug-log)',
|
||
mockDebugLogBridgeUrlText: 'wss://gs.gotomars.xyz/debug-log',
|
||
panelTimerText: '00:00:00',
|
||
panelTimerMode: 'elapsed',
|
||
panelMileageText: '0m',
|
||
panelActionTagText: '目标',
|
||
panelDistanceTagText: '点距',
|
||
panelTargetSummaryText: '等待选择目标',
|
||
panelDistanceValueText: '--',
|
||
panelDistanceUnitText: '',
|
||
panelProgressText: '0/0',
|
||
panelSpeedValueText: '0',
|
||
panelTelemetryTone: 'blue',
|
||
trackDisplayMode: DEFAULT_TRACK_VISUALIZATION_CONFIG.mode,
|
||
trackTailLength: DEFAULT_TRACK_VISUALIZATION_CONFIG.tailLength,
|
||
trackColorPreset: DEFAULT_TRACK_VISUALIZATION_CONFIG.colorPreset,
|
||
trackStyleProfile: DEFAULT_TRACK_VISUALIZATION_CONFIG.style,
|
||
gpsMarkerVisible: DEFAULT_GPS_MARKER_STYLE_CONFIG.visible,
|
||
gpsMarkerStyle: DEFAULT_GPS_MARKER_STYLE_CONFIG.style,
|
||
gpsMarkerSize: DEFAULT_GPS_MARKER_STYLE_CONFIG.size,
|
||
gpsMarkerColorPreset: DEFAULT_GPS_MARKER_STYLE_CONFIG.colorPreset,
|
||
gpsLogoStatusText: '未配置',
|
||
gpsLogoSourceText: '--',
|
||
panelHeartRateZoneNameText: '激活放松',
|
||
panelHeartRateZoneRangeText: '<=39%',
|
||
panelHeartRateValueText: '--',
|
||
panelHeartRateUnitText: '',
|
||
panelCaloriesValueText: '0',
|
||
panelCaloriesUnitText: 'kcal',
|
||
panelAverageSpeedValueText: '0',
|
||
panelAverageSpeedUnitText: 'km/h',
|
||
panelAccuracyValueText: '--',
|
||
panelAccuracyUnitText: '',
|
||
punchButtonText: '打点',
|
||
gameSessionStatus: 'idle',
|
||
gameModeText: '顺序赛',
|
||
punchButtonEnabled: false,
|
||
skipButtonEnabled: false,
|
||
punchHintText: '等待进入检查点范围',
|
||
punchFeedbackVisible: false,
|
||
punchFeedbackText: '',
|
||
punchFeedbackTone: 'neutral',
|
||
contentCardVisible: false,
|
||
contentCardTemplate: 'story',
|
||
contentCardTitle: '',
|
||
contentCardBody: '',
|
||
contentCardActions: [],
|
||
contentQuizVisible: false,
|
||
contentQuizQuestionText: '',
|
||
contentQuizCountdownText: '',
|
||
contentQuizOptions: [],
|
||
contentQuizFeedbackVisible: false,
|
||
contentQuizFeedbackText: '',
|
||
contentQuizFeedbackTone: 'neutral',
|
||
pendingContentEntryVisible: false,
|
||
pendingContentEntryText: '',
|
||
punchButtonFxClass: '',
|
||
panelProgressFxClass: '',
|
||
panelDistanceFxClass: '',
|
||
punchFeedbackFxClass: '',
|
||
contentCardFxClass: '',
|
||
mapPulseVisible: false,
|
||
mapPulseLeftPx: 0,
|
||
mapPulseTopPx: 0,
|
||
mapPulseFxClass: '',
|
||
stageFxVisible: false,
|
||
stageFxClass: '',
|
||
osmReferenceEnabled: false,
|
||
osmReferenceText: 'OSM参考:关',
|
||
}
|
||
this.previewScale = 1
|
||
this.previewOriginX = 0
|
||
this.previewOriginY = 0
|
||
this.panLastX = 0
|
||
this.panLastY = 0
|
||
this.panLastTimestamp = 0
|
||
this.tapStartX = 0
|
||
this.tapStartY = 0
|
||
this.tapStartAt = 0
|
||
this.panVelocityX = 0
|
||
this.panVelocityY = 0
|
||
this.pinchStartDistance = 0
|
||
this.pinchStartScale = 1
|
||
this.pinchStartAngle = 0
|
||
this.pinchStartRotationDeg = 0
|
||
this.pinchAnchorWorldX = 0
|
||
this.pinchAnchorWorldY = 0
|
||
this.gestureMode = 'idle'
|
||
this.inertiaTimer = 0
|
||
this.previewResetTimer = 0
|
||
this.viewSyncTimer = 0
|
||
this.autoRotateTimer = 0
|
||
this.compassNeedleTimer = 0
|
||
this.compassBootstrapRetryTimer = 0
|
||
this.pendingViewPatch = {}
|
||
this.mounted = false
|
||
this.diagnosticUiEnabled = false
|
||
this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
|
||
this.sensorHeadingDeg = null
|
||
this.smoothedSensorHeadingDeg = null
|
||
this.compassDisplayHeadingDeg = null
|
||
this.targetCompassDisplayHeadingDeg = null
|
||
this.lastCompassSampleAt = 0
|
||
this.compassSource = null
|
||
this.compassTuningProfile = 'balanced'
|
||
this.smoothedMovementHeadingDeg = null
|
||
this.autoRotateHeadingDeg = null
|
||
this.courseHeadingDeg = null
|
||
this.targetAutoRotationDeg = null
|
||
this.autoRotateSourceMode = 'smart'
|
||
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
|
||
this.autoRotateCalibrationPending = false
|
||
this.lastStatsUiSyncAt = 0
|
||
}
|
||
|
||
getInitialData(): MapEngineViewState {
|
||
return { ...this.state }
|
||
}
|
||
|
||
setDiagnosticUiEnabled(enabled: boolean): void {
|
||
if (this.diagnosticUiEnabled === enabled) {
|
||
return
|
||
}
|
||
|
||
this.diagnosticUiEnabled = enabled
|
||
this.mockSimulatorDebugLogger.setEnabled(enabled)
|
||
|
||
if (!enabled) {
|
||
return
|
||
}
|
||
|
||
this.setState({
|
||
...this.getTelemetrySensorViewPatch(),
|
||
...this.getLocationControllerViewPatch(),
|
||
...this.getHeartRateControllerViewPatch(),
|
||
...this.getMockDebugLogViewPatch(),
|
||
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
|
||
autoRotateSourceText: this.getAutoRotateSourceText(),
|
||
visibleTileCount: this.state.visibleTileCount,
|
||
readyTileCount: this.state.readyTileCount,
|
||
memoryTileCount: this.state.memoryTileCount,
|
||
diskTileCount: this.state.diskTileCount,
|
||
memoryHitCount: this.state.memoryHitCount,
|
||
diskHitCount: this.state.diskHitCount,
|
||
networkFetchCount: this.state.networkFetchCount,
|
||
cacheHitRateText: this.state.cacheHitRateText,
|
||
}, true)
|
||
}
|
||
|
||
getGameInfoSnapshot(): MapEngineGameInfoSnapshot {
|
||
const definition = this.gameRuntime.definition
|
||
const sessionState = this.gameRuntime.state
|
||
const telemetryState = this.telemetryRuntime.state
|
||
const telemetryPresentation = this.telemetryRuntime.getPresentation()
|
||
const currentTarget = definition && sessionState
|
||
? definition.controls.find((control) => control.id === sessionState.currentTargetControlId) || null
|
||
: null
|
||
const currentTargetText = currentTarget
|
||
? `${currentTarget.label} / ${currentTarget.kind === 'start'
|
||
? '开始点'
|
||
: currentTarget.kind === 'finish'
|
||
? '结束点'
|
||
: '检查点'}`
|
||
: '--'
|
||
const title = this.state.mapName || (definition ? definition.title : '当前游戏')
|
||
const subtitle = `${this.getGameModeText()} / ${formatGameSessionStatusText(this.state.gameSessionStatus)}`
|
||
const localRows: MapEngineGameInfoRow[] = [
|
||
{ label: '比赛名称', value: title || '--' },
|
||
{ label: '配置版本', value: this.configVersion || '--' },
|
||
{ label: 'Schema版本', value: this.configSchemaVersion || '--' },
|
||
{ label: '场地类型', value: this.playfieldKind || '--' },
|
||
{ label: '模式编码', value: this.gameMode || '--' },
|
||
{ label: '活动ID', value: this.configAppId || '--' },
|
||
{ label: '动画等级', value: formatAnimationLevelText(this.state.animationLevel) },
|
||
{ label: '地图', value: this.state.mapName || '--' },
|
||
{ label: '模式', value: this.getGameModeText() },
|
||
{ label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) },
|
||
{ label: '当前目标', value: currentTargetText },
|
||
{ label: '进度', value: this.gamePresentation.hud.progressText || '--' },
|
||
{ label: '当前积分', value: sessionState ? String(this.getTotalSessionScore()) : '0' },
|
||
{ label: '已完成点', value: sessionState ? String(sessionState.completedControlIds.length) : '0' },
|
||
{ label: '已跳过点', value: sessionState ? String(sessionState.skippedControlIds.length) : '0' },
|
||
{ label: '打点规则', value: `${this.punchPolicy} / ${this.punchRadiusMeters}m` },
|
||
{ label: '跳点规则', value: this.skipEnabled ? `${this.skipRadiusMeters}m / ${this.skipRequiresConfirm ? '确认跳过' : '直接跳过'}` : '关闭' },
|
||
{ label: '定位源', value: this.state.locationSourceText || '--' },
|
||
{ label: '当前位置', value: this.state.gpsCoordText || '--' },
|
||
{ label: 'GPS精度', value: telemetryState.lastGpsAccuracyMeters == null ? '--' : `${telemetryState.lastGpsAccuracyMeters.toFixed(1)}m` },
|
||
{ label: '设备朝向', value: this.state.deviceHeadingText || '--' },
|
||
{ label: '设备姿态', value: this.state.devicePoseText || '--' },
|
||
{ label: '朝向可信度', value: this.state.headingConfidenceText || '--' },
|
||
{ label: '目标距离', value: `${telemetryPresentation.distanceToTargetValueText}${telemetryPresentation.distanceToTargetUnitText}` || '--' },
|
||
{ label: '当前速度', value: `${telemetryPresentation.speedText} km/h` },
|
||
{ label: '心率源', value: this.state.heartRateSourceText || '--' },
|
||
{ label: '当前心率', value: this.state.panelHeartRateValueText === '--' ? '--' : `${this.state.panelHeartRateValueText}${this.state.panelHeartRateUnitText}` },
|
||
{ label: '心率设备', value: this.state.heartRateDeviceText || '--' },
|
||
{ label: '心率分区', value: this.state.panelHeartRateZoneNameText === '--' ? '--' : `${this.state.panelHeartRateZoneNameText} ${this.state.panelHeartRateZoneRangeText}` },
|
||
{ label: '本局用时', value: telemetryPresentation.timerText },
|
||
{ label: '累计里程', value: telemetryPresentation.mileageText },
|
||
{ label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` },
|
||
{ label: '提示状态', value: this.state.punchHintText || '--' },
|
||
]
|
||
const globalRows: MapEngineGameInfoRow[] = [
|
||
{ label: '全球积分', value: '未接入' },
|
||
{ label: '全球排名', value: '未接入' },
|
||
{ label: '在线人数', value: '未接入' },
|
||
{ label: '队伍状态', value: '未接入' },
|
||
{ label: '实时广播', value: '未接入' },
|
||
]
|
||
|
||
return {
|
||
title,
|
||
subtitle,
|
||
localRows,
|
||
globalRows,
|
||
}
|
||
}
|
||
|
||
getResultSceneSnapshot(): MapEngineResultSnapshot {
|
||
const sessionState = this.gameRuntime.state || null
|
||
return buildResultSummarySnapshot(
|
||
this.gameRuntime.definition,
|
||
sessionState,
|
||
this.telemetryRuntime.getPresentation(),
|
||
this.state.mapName || (this.gameRuntime.definition ? this.gameRuntime.definition.title : '本局结果'),
|
||
{
|
||
totalScore: this.getTotalSessionScore(),
|
||
baseScore: this.getBaseSessionScore(),
|
||
bonusScore: this.sessionBonusScore,
|
||
quizCorrectCount: this.sessionQuizCorrectCount,
|
||
quizWrongCount: this.sessionQuizWrongCount,
|
||
quizTimeoutCount: this.sessionQuizTimeoutCount,
|
||
},
|
||
)
|
||
}
|
||
|
||
getSessionFinishSummary(statusOverride?: 'finished' | 'failed' | 'cancelled'): MapEngineSessionFinishSummary | null {
|
||
const definition = this.gameRuntime.definition
|
||
const sessionState = this.gameRuntime.state
|
||
if (!definition || !sessionState) {
|
||
return null
|
||
}
|
||
|
||
let status: 'finished' | 'failed' | 'cancelled'
|
||
if (statusOverride) {
|
||
status = statusOverride
|
||
} else if (sessionState.endReason === 'timed_out' || sessionState.status === 'failed') {
|
||
status = 'failed'
|
||
} else {
|
||
status = 'finished'
|
||
}
|
||
|
||
const endAt = sessionState.endedAt !== null ? sessionState.endedAt : Date.now()
|
||
const finalDurationSec = sessionState.startedAt !== null
|
||
? Math.max(0, Math.floor((endAt - sessionState.startedAt) / 1000))
|
||
: undefined
|
||
const totalControls = definition.controls.filter((control) => control.kind === 'control').length
|
||
|
||
return {
|
||
status,
|
||
finalDurationSec,
|
||
finalScore: this.getTotalSessionScore(),
|
||
completedControls: sessionState.completedControlIds.length,
|
||
totalControls,
|
||
distanceMeters: this.telemetryRuntime.state.distanceMeters,
|
||
averageSpeedKmh: this.telemetryRuntime.state.averageSpeedKmh === null
|
||
? undefined
|
||
: this.telemetryRuntime.state.averageSpeedKmh,
|
||
}
|
||
}
|
||
|
||
buildSessionRecoveryRuntimeSnapshot(): RecoveryRuntimeSnapshot | null {
|
||
const definition = this.gameRuntime.definition
|
||
const state = this.gameRuntime.state
|
||
if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
gameState: {
|
||
status: state.status,
|
||
endReason: state.endReason,
|
||
startedAt: state.startedAt,
|
||
endedAt: state.endedAt,
|
||
completedControlIds: state.completedControlIds.slice(),
|
||
skippedControlIds: state.skippedControlIds.slice(),
|
||
currentTargetControlId: state.currentTargetControlId,
|
||
inRangeControlId: state.inRangeControlId,
|
||
score: state.score,
|
||
guidanceState: state.guidanceState,
|
||
modeState: state.modeState
|
||
? JSON.parse(JSON.stringify(state.modeState)) as Record<string, unknown>
|
||
: null,
|
||
},
|
||
telemetry: this.telemetryRuntime.exportRecoveryState(),
|
||
viewport: {
|
||
zoom: this.state.zoom,
|
||
centerTileX: this.state.centerTileX,
|
||
centerTileY: this.state.centerTileY,
|
||
rotationDeg: this.state.rotationDeg,
|
||
gpsLockEnabled: this.gpsLockEnabled,
|
||
hasGpsCenteredOnce: this.hasGpsCenteredOnce,
|
||
},
|
||
currentGpsPoint: this.currentGpsPoint
|
||
? {
|
||
lon: this.currentGpsPoint.lon,
|
||
lat: this.currentGpsPoint.lat,
|
||
}
|
||
: null,
|
||
currentGpsAccuracyMeters: this.currentGpsAccuracyMeters,
|
||
currentGpsInsideMap: this.currentGpsInsideMap,
|
||
bonusScore: this.sessionBonusScore,
|
||
quizCorrectCount: this.sessionQuizCorrectCount,
|
||
quizWrongCount: this.sessionQuizWrongCount,
|
||
quizTimeoutCount: this.sessionQuizTimeoutCount,
|
||
}
|
||
}
|
||
|
||
restoreSessionRecoveryRuntimeSnapshot(snapshot: RecoveryRuntimeSnapshot): boolean {
|
||
const definition = this.buildCurrentGameDefinition()
|
||
if (!definition) {
|
||
return false
|
||
}
|
||
|
||
this.feedbackDirector.reset()
|
||
this.resetTransientGameUiState()
|
||
const result = this.gameRuntime.restoreDefinition(definition, snapshot.gameState)
|
||
this.telemetryRuntime.restoreRecoveryState(
|
||
definition,
|
||
snapshot.gameState,
|
||
snapshot.telemetry,
|
||
result.presentation.hud.hudTargetControlId,
|
||
)
|
||
this.syncGameResultState(result)
|
||
this.currentGpsPoint = snapshot.currentGpsPoint
|
||
? {
|
||
lon: snapshot.currentGpsPoint.lon,
|
||
lat: snapshot.currentGpsPoint.lat,
|
||
}
|
||
: null
|
||
this.currentGpsAccuracyMeters = snapshot.currentGpsAccuracyMeters
|
||
this.currentGpsInsideMap = snapshot.currentGpsInsideMap
|
||
this.gpsLockEnabled = snapshot.viewport.gpsLockEnabled && !!this.currentGpsPoint && snapshot.currentGpsInsideMap
|
||
this.hasGpsCenteredOnce = snapshot.viewport.hasGpsCenteredOnce || !!this.currentGpsPoint
|
||
this.sessionBonusScore = snapshot.bonusScore
|
||
this.sessionQuizCorrectCount = snapshot.quizCorrectCount
|
||
this.sessionQuizWrongCount = snapshot.quizWrongCount
|
||
this.sessionQuizTimeoutCount = snapshot.quizTimeoutCount
|
||
this.courseOverlayVisible = true
|
||
if (!this.locationController.listening) {
|
||
this.locationController.start()
|
||
}
|
||
this.updateSessionTimerLoop()
|
||
|
||
this.commitViewport({
|
||
zoom: snapshot.viewport.zoom,
|
||
centerTileX: snapshot.viewport.centerTileX,
|
||
centerTileY: snapshot.viewport.centerTileY,
|
||
rotationDeg: snapshot.viewport.rotationDeg,
|
||
rotationText: formatRotationText(snapshot.viewport.rotationDeg),
|
||
gpsTracking: !!this.currentGpsPoint,
|
||
gpsTrackingText: this.currentGpsPoint ? '已恢复上一局定位状态' : '已恢复上一局',
|
||
gpsCoordText: formatGpsCoordText(this.currentGpsPoint, this.currentGpsAccuracyMeters),
|
||
gpsLockEnabled: this.gpsLockEnabled,
|
||
gpsLockAvailable: !!this.currentGpsPoint && snapshot.currentGpsInsideMap,
|
||
autoRotateSourceText: this.getAutoRotateSourceText(),
|
||
...this.getGameViewPatch(`已恢复上一局 (${this.buildVersion})`),
|
||
}, `已恢复上一局 (${this.buildVersion})`, true, () => {
|
||
this.syncRenderer()
|
||
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
||
this.scheduleAutoRotate()
|
||
}
|
||
})
|
||
return true
|
||
}
|
||
|
||
destroy(): void {
|
||
this.clearInertiaTimer()
|
||
this.clearPreviewResetTimer()
|
||
this.clearViewSyncTimer()
|
||
this.clearAutoRotateTimer()
|
||
this.clearCompassNeedleTimer()
|
||
this.clearCompassBootstrapRetryTimer()
|
||
this.clearPunchFeedbackTimer()
|
||
this.clearContentCardTimer()
|
||
this.clearMapPulseTimer()
|
||
this.clearStageFxTimer()
|
||
this.clearSessionTimerInterval()
|
||
this.accelerometerController.destroy()
|
||
this.compassController.destroy()
|
||
this.gyroscopeController.destroy()
|
||
this.deviceMotionController.destroy()
|
||
this.locationController.destroy()
|
||
this.heartRateController.destroy()
|
||
this.feedbackDirector.destroy()
|
||
this.mockSimulatorDebugLogger.destroy()
|
||
this.renderer.destroy()
|
||
this.mounted = false
|
||
}
|
||
|
||
handleAppShow(): void {
|
||
this.feedbackDirector.setAppAudioMode('foreground')
|
||
if (this.mounted) {
|
||
this.lastCompassSampleAt = 0
|
||
this.compassController.start()
|
||
this.scheduleCompassBootstrapRetry()
|
||
}
|
||
}
|
||
|
||
handleAppHide(): void {
|
||
this.feedbackDirector.setAppAudioMode('foreground')
|
||
}
|
||
|
||
|
||
clearGameRuntime(): void {
|
||
this.gameRuntime.clear()
|
||
this.telemetryRuntime.reset()
|
||
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
|
||
this.courseOverlayVisible = !!this.courseData
|
||
this.clearSessionTimerInterval()
|
||
this.setCourseHeading(null)
|
||
}
|
||
|
||
clearHeartRateSignal(): void {
|
||
this.telemetryRuntime.dispatch({
|
||
type: 'heart_rate_updated',
|
||
at: Date.now(),
|
||
bpm: null,
|
||
})
|
||
this.syncSessionTimerText()
|
||
}
|
||
|
||
clearFinishedTestOverlay(): void {
|
||
this.currentGpsPoint = null
|
||
this.currentGpsTrack = []
|
||
this.currentGpsTrackSamples = []
|
||
this.currentGpsAccuracyMeters = null
|
||
this.currentGpsInsideMap = false
|
||
this.smoothedMovementHeadingDeg = null
|
||
this.lastTrackMotionAt = 0
|
||
this.courseOverlayVisible = false
|
||
this.setCourseHeading(null)
|
||
}
|
||
|
||
clearStartSessionResidue(): void {
|
||
this.currentGpsTrack = []
|
||
this.currentGpsTrackSamples = []
|
||
this.smoothedMovementHeadingDeg = null
|
||
this.lastTrackMotionAt = 0
|
||
this.courseOverlayVisible = false
|
||
this.setCourseHeading(null)
|
||
}
|
||
|
||
handleClearMapTestArtifacts(): void {
|
||
this.clearFinishedTestOverlay()
|
||
this.setState({
|
||
gpsTracking: false,
|
||
gpsTrackingText: '测试痕迹已清空',
|
||
gpsCoordText: '--',
|
||
statusText: `已清空地图点位与轨迹 (${this.buildVersion})`,
|
||
}, true)
|
||
this.syncRenderer()
|
||
}
|
||
|
||
getHudTargetControlId(): string | null {
|
||
return this.gamePresentation.hud.hudTargetControlId
|
||
}
|
||
|
||
isSkipAvailable(): boolean {
|
||
const definition = this.gameRuntime.definition
|
||
const state = this.gameRuntime.state
|
||
if (!definition || !state || state.status !== 'running' || !definition.skipEnabled) {
|
||
return false
|
||
}
|
||
|
||
const currentTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null
|
||
if (!currentTarget || currentTarget.kind !== 'control' || !this.currentGpsPoint) {
|
||
return false
|
||
}
|
||
|
||
const avgLatRad = ((currentTarget.point.lat + this.currentGpsPoint.lat) / 2) * Math.PI / 180
|
||
const dx = (this.currentGpsPoint.lon - currentTarget.point.lon) * 111320 * Math.cos(avgLatRad)
|
||
const dy = (this.currentGpsPoint.lat - currentTarget.point.lat) * 110540
|
||
const distanceMeters = Math.sqrt(dx * dx + dy * dy)
|
||
return distanceMeters <= definition.skipRadiusMeters
|
||
}
|
||
|
||
shouldConfirmSkipAction(): boolean {
|
||
return !!(this.gameRuntime.definition && this.gameRuntime.definition.skipRequiresConfirm)
|
||
}
|
||
|
||
getLocationControllerViewPatch(): Partial<MapEngineViewState> {
|
||
const debugState = this.locationController.getDebugState()
|
||
return {
|
||
gpsTracking: debugState.listening,
|
||
gpsLockEnabled: this.gpsLockEnabled,
|
||
gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
|
||
locationSourceMode: debugState.sourceMode,
|
||
locationSourceText: debugState.sourceModeText,
|
||
mockBridgeConnected: debugState.mockBridgeConnected,
|
||
mockBridgeStatusText: debugState.mockBridgeStatusText,
|
||
mockBridgeUrlText: debugState.mockBridgeUrlText,
|
||
mockChannelIdText: debugState.mockChannelIdText,
|
||
mockCoordText: debugState.mockCoordText,
|
||
mockSpeedText: debugState.mockSpeedText,
|
||
}
|
||
}
|
||
|
||
getHeartRateControllerViewPatch(): Partial<MapEngineViewState> {
|
||
const debugState = this.heartRateController.getDebugState()
|
||
return {
|
||
heartRateSourceMode: debugState.sourceMode,
|
||
heartRateSourceText: debugState.sourceModeText,
|
||
mockHeartRateBridgeConnected: debugState.mockBridgeConnected,
|
||
mockHeartRateBridgeStatusText: debugState.mockBridgeStatusText,
|
||
mockHeartRateBridgeUrlText: debugState.mockBridgeUrlText,
|
||
mockHeartRateText: debugState.mockHeartRateText,
|
||
}
|
||
}
|
||
|
||
getMockDebugLogViewPatch(): Partial<MapEngineViewState> {
|
||
const debugState = this.mockSimulatorDebugLogger.getState()
|
||
return {
|
||
mockDebugLogBridgeConnected: debugState.connected,
|
||
mockDebugLogBridgeStatusText: debugState.statusText,
|
||
mockDebugLogBridgeUrlText: debugState.url,
|
||
}
|
||
}
|
||
|
||
getTelemetrySensorViewPatch(): Partial<MapEngineViewState> {
|
||
const telemetryState = this.telemetryRuntime.state
|
||
return {
|
||
deviceHeadingText: formatHeadingText(
|
||
telemetryState.deviceHeadingDeg === null
|
||
? null
|
||
: getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg),
|
||
),
|
||
devicePoseText: formatDevicePoseText(telemetryState.devicePose),
|
||
headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence),
|
||
accelerometerText: telemetryState.accelerometer
|
||
? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}`
|
||
: '未启用',
|
||
gyroscopeText: formatGyroscopeText(telemetryState.gyroscope),
|
||
deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion),
|
||
compassSourceText: formatCompassSourceText(this.compassSource),
|
||
compassTuningProfile: this.compassTuningProfile,
|
||
compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
|
||
}
|
||
}
|
||
|
||
getGameModeText(): string {
|
||
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
||
}
|
||
|
||
buildCurrentGameDefinition(): ReturnType<typeof buildGameDefinitionFromCourse> | null {
|
||
if (!this.courseData) {
|
||
return null
|
||
}
|
||
|
||
return buildGameDefinitionFromCourse(
|
||
this.courseData,
|
||
this.cpRadiusMeters,
|
||
this.gameMode,
|
||
this.sessionCloseAfterMs,
|
||
this.sessionCloseWarningMs,
|
||
this.minCompletedControlsBeforeFinish,
|
||
this.autoFinishOnLastControl,
|
||
this.punchPolicy,
|
||
this.punchRadiusMeters,
|
||
this.requiresFocusSelection,
|
||
this.skipEnabled,
|
||
this.skipRadiusMeters,
|
||
this.skipRequiresConfirm,
|
||
this.controlScoreOverrides,
|
||
this.defaultControlContentOverride,
|
||
this.controlContentOverrides,
|
||
this.defaultControlScore,
|
||
)
|
||
}
|
||
|
||
loadGameDefinitionFromCourse(): GameResult | null {
|
||
const definition = this.buildCurrentGameDefinition()
|
||
if (!definition) {
|
||
this.clearGameRuntime()
|
||
return null
|
||
}
|
||
const result = this.gameRuntime.loadDefinition(definition)
|
||
this.telemetryRuntime.loadDefinition(definition)
|
||
this.courseOverlayVisible = true
|
||
this.syncGameResultState(result)
|
||
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, result.presentation.hud.hudTargetControlId)
|
||
this.updateSessionTimerLoop()
|
||
return result
|
||
}
|
||
|
||
refreshCourseHeadingFromPresentation(): void {
|
||
if (!this.courseData || !this.gamePresentation.map.activeLegIndices.length) {
|
||
this.setCourseHeading(null)
|
||
return
|
||
}
|
||
|
||
const activeLegIndex = this.gamePresentation.map.activeLegIndices[0]
|
||
const activeLeg = this.courseData.layers.legs[activeLegIndex]
|
||
if (!activeLeg) {
|
||
this.setCourseHeading(null)
|
||
return
|
||
}
|
||
|
||
this.setCourseHeading(getInitialBearingDeg(activeLeg.fromPoint, activeLeg.toPoint))
|
||
}
|
||
|
||
resolveGameStatusText(effects: GameEffect[]): string | null {
|
||
const lastEffect = effects.length ? effects[effects.length - 1] : null
|
||
if (!lastEffect) {
|
||
return null
|
||
}
|
||
|
||
if (lastEffect.type === 'control_completed') {
|
||
const sequenceText = typeof lastEffect.sequence === 'number' ? String(lastEffect.sequence) : lastEffect.controlId
|
||
return `宸插畬鎴愭鏌ョ偣 ${sequenceText} (${this.buildVersion})`
|
||
}
|
||
|
||
if (lastEffect.type === 'session_finished') {
|
||
return `璺嚎宸插畬鎴?(${this.buildVersion})`
|
||
}
|
||
|
||
if (lastEffect.type === 'session_timed_out') {
|
||
return `已到关门时间,超时结束 (${this.buildVersion})`
|
||
}
|
||
|
||
if (lastEffect.type === 'session_started') {
|
||
return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})`
|
||
}
|
||
|
||
return null
|
||
}
|
||
getGameViewPatch(statusText?: string | null): Partial<MapEngineViewState> {
|
||
const telemetryPresentation = this.telemetryRuntime.getPresentation()
|
||
const patch: Partial<MapEngineViewState> = {
|
||
gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
|
||
gameModeText: this.getGameModeText(),
|
||
panelTimerText: telemetryPresentation.timerText,
|
||
panelTimerMode: telemetryPresentation.timerMode,
|
||
panelMileageText: telemetryPresentation.mileageText,
|
||
panelActionTagText: this.gamePresentation.hud.actionTagText,
|
||
panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
|
||
panelTargetSummaryText: this.gamePresentation.hud.targetSummaryText,
|
||
panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
|
||
panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
|
||
panelSpeedValueText: telemetryPresentation.speedText,
|
||
panelTelemetryTone: telemetryPresentation.heartRateTone,
|
||
panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText,
|
||
panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText,
|
||
panelHeartRateValueText: telemetryPresentation.heartRateValueText,
|
||
panelHeartRateUnitText: telemetryPresentation.heartRateUnitText,
|
||
panelCaloriesValueText: telemetryPresentation.caloriesValueText,
|
||
panelCaloriesUnitText: telemetryPresentation.caloriesUnitText,
|
||
panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText,
|
||
panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
|
||
panelAccuracyValueText: telemetryPresentation.accuracyValueText,
|
||
panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
|
||
panelProgressText: this.resolveHudProgressText(),
|
||
punchButtonText: this.gamePresentation.hud.punchButtonText,
|
||
punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
|
||
skipButtonEnabled: this.isSkipAvailable(),
|
||
punchHintText: this.gamePresentation.hud.punchHintText,
|
||
gpsLockEnabled: this.gpsLockEnabled,
|
||
gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
|
||
}
|
||
|
||
if (statusText) {
|
||
patch.statusText = statusText
|
||
}
|
||
|
||
return patch
|
||
}
|
||
|
||
clearPunchFeedbackTimer(): void {
|
||
if (this.punchFeedbackTimer) {
|
||
clearTimeout(this.punchFeedbackTimer)
|
||
this.punchFeedbackTimer = 0
|
||
}
|
||
}
|
||
|
||
clearContentCardTimer(): void {
|
||
if (this.contentCardTimer) {
|
||
clearTimeout(this.contentCardTimer)
|
||
this.contentCardTimer = 0
|
||
}
|
||
}
|
||
|
||
getPendingManualContentCount(): number {
|
||
return this.pendingContentCards.filter((item) => !item.autoPopup).length
|
||
}
|
||
|
||
buildPendingContentEntryText(): string {
|
||
const count = this.getPendingManualContentCount()
|
||
if (count <= 1) {
|
||
return count === 1 ? '查看内容' : ''
|
||
}
|
||
return `查看内容(${count})`
|
||
}
|
||
|
||
syncPendingContentEntryState(immediate = true): void {
|
||
const count = this.getPendingManualContentCount()
|
||
this.setState({
|
||
pendingContentEntryVisible: count > 0,
|
||
pendingContentEntryText: this.buildPendingContentEntryText(),
|
||
}, immediate)
|
||
}
|
||
|
||
getBaseSessionScore(): number {
|
||
return this.gameRuntime.state && typeof this.gameRuntime.state.score === 'number'
|
||
? this.gameRuntime.state.score
|
||
: 0
|
||
}
|
||
|
||
getTotalSessionScore(): number {
|
||
return this.getBaseSessionScore() + this.sessionBonusScore
|
||
}
|
||
|
||
resolveHudProgressText(): string {
|
||
const definition = this.gameRuntime.definition
|
||
const sessionState = this.gameRuntime.state
|
||
if (!definition || !sessionState) {
|
||
return this.gamePresentation.hud.progressText
|
||
}
|
||
|
||
const scoringControls = definition.controls.filter((control) => control.kind === 'control')
|
||
const scoringControlIdSet = new Set(scoringControls.map((control) => control.id))
|
||
const completedCount = sessionState.completedControlIds.filter((controlId) => scoringControlIdSet.has(controlId)).length
|
||
const skippedCount = sessionState.skippedControlIds.filter((controlId) => scoringControlIdSet.has(controlId)).length
|
||
const totalCount = scoringControls.length
|
||
|
||
if (definition.mode === 'score-o') {
|
||
return `${this.getTotalSessionScore()}分 ${completedCount}/${totalCount}`
|
||
}
|
||
|
||
return skippedCount > 0
|
||
? `${completedCount}/${totalCount} 跳${skippedCount}`
|
||
: `${completedCount}/${totalCount}`
|
||
}
|
||
|
||
buildContentCardActions(
|
||
ctas: ContentCardCtaConfig[],
|
||
h5Request: H5ExperienceRequest | null,
|
||
contentKey = '',
|
||
): ContentCardActionViewModel[] {
|
||
const resolved = this.resolveContentControlByKey(contentKey)
|
||
if (resolved && resolved.displayMode === 'click') {
|
||
return []
|
||
}
|
||
|
||
const actions = ctas
|
||
.filter((item) => item.type !== 'detail' || !!h5Request)
|
||
.map((item, index) => ({
|
||
key: `cta-${index + 1}`,
|
||
type: item.type,
|
||
label: item.label || buildDefaultContentCardCtaLabel(item.type),
|
||
})) as ContentCardActionViewModel[]
|
||
|
||
if (h5Request && !actions.some((item) => item.type === 'detail')) {
|
||
actions.unshift({
|
||
key: 'cta-detail',
|
||
type: 'detail',
|
||
label: '查看详情',
|
||
})
|
||
}
|
||
|
||
return actions.slice(0, 3)
|
||
}
|
||
|
||
isClickContentCardEntry(item: ContentCardEntry | null): boolean {
|
||
if (!item) {
|
||
return false
|
||
}
|
||
|
||
const resolved = this.resolveContentControlByKey(item.contentKey)
|
||
return !!resolved && resolved.displayMode === 'click'
|
||
}
|
||
|
||
resolveContentCardAutoDismissMs(
|
||
item: ContentCardEntry,
|
||
actions: ContentCardActionViewModel[],
|
||
): number {
|
||
if (this.isClickContentCardEntry(item)) {
|
||
return 4000
|
||
}
|
||
|
||
return actions.length ? 0 : 2600
|
||
}
|
||
|
||
clearContentQuizTimer(): void {
|
||
if (this.contentQuizTimer) {
|
||
clearInterval(this.contentQuizTimer)
|
||
this.contentQuizTimer = 0
|
||
}
|
||
}
|
||
|
||
clearContentQuizFeedbackTimer(): void {
|
||
if (this.contentQuizFeedbackTimer) {
|
||
clearTimeout(this.contentQuizFeedbackTimer)
|
||
this.contentQuizFeedbackTimer = 0
|
||
}
|
||
}
|
||
|
||
closeContentQuiz(immediate = true): void {
|
||
this.clearContentQuizTimer()
|
||
this.clearContentQuizFeedbackTimer()
|
||
this.currentContentQuizKey = ''
|
||
this.currentContentQuizAnswer = 0
|
||
this.currentContentQuizBonusScore = 0
|
||
this.setState({
|
||
contentQuizVisible: false,
|
||
contentQuizQuestionText: '',
|
||
contentQuizCountdownText: '',
|
||
contentQuizOptions: [],
|
||
contentQuizFeedbackVisible: false,
|
||
contentQuizFeedbackText: '',
|
||
contentQuizFeedbackTone: 'neutral',
|
||
}, immediate)
|
||
}
|
||
|
||
buildContentQuizSession(quizConfig: ContentCardQuizConfig): {
|
||
questionText: string
|
||
correctAnswer: number
|
||
options: ContentCardQuizOptionViewModel[]
|
||
} {
|
||
const minValue = Math.max(10, Math.round(quizConfig.minValue))
|
||
const maxValue = Math.max(minValue + 10, Math.round(quizConfig.maxValue))
|
||
const leftValue = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue
|
||
const rightValue = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue
|
||
const allowSubtraction = quizConfig.allowSubtraction !== false
|
||
const useSubtraction = allowSubtraction && Math.random() < 0.45
|
||
const safeLeft = useSubtraction && leftValue < rightValue ? rightValue : leftValue
|
||
const safeRight = useSubtraction && leftValue < rightValue ? leftValue : rightValue
|
||
const correctAnswer = useSubtraction ? safeLeft - safeRight : leftValue + rightValue
|
||
const questionText = useSubtraction
|
||
? `${safeLeft} - ${safeRight} = ?`
|
||
: `${leftValue} + ${rightValue} = ?`
|
||
const distractorA = correctAnswer + (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 8) + 2)
|
||
const distractorB = correctAnswer + (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 15) + 9)
|
||
const values = [correctAnswer, distractorA, distractorB]
|
||
.map((item) => Math.max(0, Math.round(item)))
|
||
while (new Set(values).size < 3) {
|
||
values[2] += 7
|
||
}
|
||
const shuffled = values
|
||
.map((value) => ({ sort: Math.random(), value }))
|
||
.sort((a, b) => a.sort - b.sort)
|
||
.map((item) => item.value)
|
||
return {
|
||
questionText,
|
||
correctAnswer,
|
||
options: shuffled.map((value, index) => ({
|
||
key: `quiz-${index + 1}`,
|
||
label: `${value}`,
|
||
})),
|
||
}
|
||
}
|
||
|
||
openContentQuizFromEntry(entry: ContentCardEntry): void {
|
||
if (!entry.contentKey) {
|
||
return
|
||
}
|
||
if (this.consumedContentQuizKeys[entry.contentKey]) {
|
||
return
|
||
}
|
||
const quizCta = entry.ctas.find((item) => item.type === 'quiz')
|
||
if (!quizCta) {
|
||
return
|
||
}
|
||
const quizConfig = buildDefaultContentCardQuizConfig(quizCta.quiz)
|
||
const session = this.buildContentQuizSession(quizConfig)
|
||
this.closeContentQuiz(false)
|
||
this.currentContentQuizKey = entry.contentKey
|
||
this.consumedContentQuizKeys[entry.contentKey] = true
|
||
this.currentContentQuizAnswer = session.correctAnswer
|
||
this.currentContentQuizBonusScore = Math.max(0, Math.round(quizConfig.bonusScore))
|
||
const expiresAt = Date.now() + (Math.max(3, quizConfig.countdownSeconds) * 1000)
|
||
const syncCountdown = () => {
|
||
const remainingMs = Math.max(0, expiresAt - Date.now())
|
||
const remainingSeconds = Math.ceil(remainingMs / 1000)
|
||
this.setState({
|
||
contentQuizCountdownText: `${remainingSeconds}s`,
|
||
})
|
||
if (remainingMs <= 0) {
|
||
this.handleContentCardQuizTimeout()
|
||
}
|
||
}
|
||
|
||
this.setState({
|
||
contentQuizVisible: true,
|
||
contentQuizQuestionText: session.questionText,
|
||
contentQuizCountdownText: `${Math.max(3, quizConfig.countdownSeconds)}s`,
|
||
contentQuizOptions: session.options,
|
||
contentQuizFeedbackVisible: false,
|
||
contentQuizFeedbackText: '',
|
||
contentQuizFeedbackTone: 'neutral',
|
||
}, true)
|
||
this.contentQuizTimer = setInterval(syncCountdown, 250) as unknown as number
|
||
}
|
||
|
||
openCurrentContentCardQuiz(): void {
|
||
if (!this.currentContentCard || !this.currentContentCard.contentKey) {
|
||
return
|
||
}
|
||
this.openContentQuizFromEntry(this.currentContentCard)
|
||
}
|
||
|
||
finishContentQuizFeedback(text: string, tone: 'success' | 'error'): void {
|
||
this.clearContentQuizTimer()
|
||
this.clearContentQuizFeedbackTimer()
|
||
this.setState({
|
||
contentQuizFeedbackVisible: true,
|
||
contentQuizFeedbackText: text,
|
||
contentQuizFeedbackTone: tone,
|
||
}, true)
|
||
this.contentQuizFeedbackTimer = setTimeout(() => {
|
||
this.contentQuizFeedbackTimer = 0
|
||
this.closeContentQuiz(true)
|
||
}, 1200) as unknown as number
|
||
}
|
||
|
||
handleContentCardQuizAnswer(optionKey: string): void {
|
||
if (!this.state.contentQuizVisible) {
|
||
return
|
||
}
|
||
const option = this.state.contentQuizOptions.find((item) => item.key === optionKey)
|
||
if (!option) {
|
||
return
|
||
}
|
||
const selectedValue = Number(option.label)
|
||
const quizKey = this.currentContentQuizKey
|
||
const isCorrect = selectedValue === this.currentContentQuizAnswer
|
||
if (isCorrect && quizKey && !this.rewardedContentQuizKeys[quizKey]) {
|
||
this.rewardedContentQuizKeys[quizKey] = true
|
||
this.sessionBonusScore += this.currentContentQuizBonusScore
|
||
}
|
||
if (isCorrect) {
|
||
this.sessionQuizCorrectCount += 1
|
||
} else {
|
||
this.sessionQuizWrongCount += 1
|
||
}
|
||
this.feedbackDirector.playAudioCue(isCorrect ? 'control_completed:control' : 'punch_feedback:warning')
|
||
this.finishContentQuizFeedback(isCorrect ? `回答正确 +${this.currentContentQuizBonusScore}分` : '回答错误 未获得加分', isCorrect ? 'success' : 'error')
|
||
}
|
||
|
||
handleContentCardQuizTimeout(): void {
|
||
if (!this.state.contentQuizVisible) {
|
||
return
|
||
}
|
||
this.sessionQuizTimeoutCount += 1
|
||
this.feedbackDirector.playAudioCue('punch_feedback:warning')
|
||
this.finishContentQuizFeedback('答题超时 未获得加分', 'error')
|
||
}
|
||
|
||
resolveContentControlByKey(contentKey: string): { control: GameControl; displayMode: 'auto' | 'click' } | null {
|
||
if (!contentKey || !this.gameRuntime.definition) {
|
||
return null
|
||
}
|
||
|
||
const isClickContent = contentKey.indexOf(':click') >= 0
|
||
const controlId = isClickContent ? contentKey.replace(/:click$/, '') : contentKey
|
||
const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
|
||
if (!control || !control.displayContent) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
control,
|
||
displayMode: isClickContent ? 'click' : 'auto',
|
||
}
|
||
}
|
||
|
||
buildContentH5Request(
|
||
contentKey: string,
|
||
title: string,
|
||
body: string,
|
||
motionClass: string,
|
||
once: boolean,
|
||
priority: number,
|
||
autoPopup: boolean,
|
||
): H5ExperienceRequest | null {
|
||
const resolved = this.resolveContentControlByKey(contentKey)
|
||
if (!resolved) {
|
||
return null
|
||
}
|
||
|
||
const displayContent = resolved.control.displayContent
|
||
if (!displayContent) {
|
||
return null
|
||
}
|
||
|
||
const experienceConfig = resolved.displayMode === 'click'
|
||
? displayContent.clickExperience
|
||
: displayContent.contentExperience
|
||
if (!experienceConfig || experienceConfig.type !== 'h5' || !experienceConfig.url) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
kind: 'content',
|
||
title: title || resolved.control.label || '内容体验',
|
||
subtitle: resolved.displayMode === 'click' ? '点击查看内容' : '打点内容体验',
|
||
url: experienceConfig.url,
|
||
bridgeVersion: experienceConfig.bridge || 'content-v1',
|
||
presentation: experienceConfig.presentation || 'sheet',
|
||
context: {
|
||
eventId: this.configAppId || '',
|
||
configTitle: this.state.mapName || '',
|
||
configVersion: this.configVersion || '',
|
||
mode: this.gameMode,
|
||
sessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
|
||
controlId: resolved.control.id,
|
||
controlKind: resolved.control.kind,
|
||
controlCode: resolved.control.code,
|
||
controlLabel: resolved.control.label,
|
||
controlSequence: resolved.control.sequence,
|
||
displayMode: resolved.displayMode,
|
||
title,
|
||
body,
|
||
},
|
||
fallback: {
|
||
title,
|
||
body,
|
||
motionClass,
|
||
contentKey,
|
||
once,
|
||
priority,
|
||
autoPopup,
|
||
},
|
||
}
|
||
}
|
||
|
||
buildControlContentCardEntry(
|
||
contentKey: string,
|
||
options: {
|
||
title?: string
|
||
body?: string
|
||
motionClass?: string
|
||
autoPopup?: boolean
|
||
once?: boolean
|
||
priority?: number
|
||
} = {},
|
||
): ContentCardEntry | null {
|
||
const resolved = this.resolveContentControlByKey(contentKey)
|
||
if (!resolved || !resolved.control.displayContent) {
|
||
return null
|
||
}
|
||
|
||
const displayContent = resolved.control.displayContent
|
||
const motionClass = options.motionClass || ''
|
||
const autoPopup = options.autoPopup !== false
|
||
const once = options.once !== undefined ? options.once : displayContent.once
|
||
const priority = typeof options.priority === 'number' ? options.priority : displayContent.priority
|
||
const title = options.title !== undefined ? options.title : displayContent.title
|
||
const body = options.body !== undefined ? options.body : displayContent.body
|
||
|
||
return {
|
||
template: displayContent.template,
|
||
title,
|
||
body,
|
||
motionClass,
|
||
contentKey,
|
||
once,
|
||
priority,
|
||
autoPopup,
|
||
ctas: displayContent.ctas,
|
||
h5Request: this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup),
|
||
}
|
||
}
|
||
|
||
removePendingContentCardsByKey(contentKey: string): void {
|
||
if (!contentKey || !this.pendingContentCards.length) {
|
||
return
|
||
}
|
||
|
||
const nextPendingCards = this.pendingContentCards.filter((item) => item.contentKey !== contentKey)
|
||
if (nextPendingCards.length === this.pendingContentCards.length) {
|
||
return
|
||
}
|
||
|
||
this.pendingContentCards = nextPendingCards
|
||
this.syncPendingContentEntryState()
|
||
}
|
||
|
||
removePendingClickContentCards(): void {
|
||
if (!this.pendingContentCards.length) {
|
||
return
|
||
}
|
||
|
||
const nextPendingCards = this.pendingContentCards.filter((item) => item.contentKey.indexOf(':click') < 0)
|
||
if (nextPendingCards.length === this.pendingContentCards.length) {
|
||
return
|
||
}
|
||
|
||
this.pendingContentCards = nextPendingCards
|
||
this.syncPendingContentEntryState()
|
||
}
|
||
|
||
replaceVisibleContentCard(item: ContentCardEntry): void {
|
||
this.clearContentCardTimer()
|
||
this.closeContentQuiz(false)
|
||
this.removePendingClickContentCards()
|
||
this.currentContentCardPriority = 0
|
||
this.currentContentCard = null
|
||
this.currentContentCardH5Request = null
|
||
this.currentH5ExperienceOpen = false
|
||
this.setState({
|
||
contentCardVisible: false,
|
||
contentCardTemplate: 'story',
|
||
contentCardTitle: '',
|
||
contentCardBody: '',
|
||
contentCardFxClass: '',
|
||
contentCardActions: [],
|
||
}, true)
|
||
this.openContentCardEntry(item)
|
||
}
|
||
|
||
applyAutoContentQuizEffects(effects: GameEffect[]): void {
|
||
for (let index = effects.length - 1; index >= 0; index -= 1) {
|
||
const effect = effects[index]
|
||
if (effect.type !== 'control_completed' || !effect.autoOpenQuiz) {
|
||
continue
|
||
}
|
||
|
||
let readyForQuiz = !!this.currentContentCard && this.currentContentCard.contentKey === effect.controlId
|
||
if (!readyForQuiz) {
|
||
const entry = this.buildControlContentCardEntry(effect.controlId, {
|
||
title: effect.displayTitle,
|
||
body: effect.displayBody,
|
||
autoPopup: effect.displayAutoPopup,
|
||
once: effect.displayOnce,
|
||
priority: effect.displayPriority + 100,
|
||
})
|
||
if (!entry) {
|
||
continue
|
||
}
|
||
this.removePendingContentCardsByKey(effect.controlId)
|
||
if (effect.displayAutoPopup) {
|
||
this.openContentCardEntry(entry)
|
||
readyForQuiz = true
|
||
} else {
|
||
this.currentContentCardPriority = entry.priority
|
||
this.currentContentCard = entry
|
||
this.currentContentCardH5Request = entry.h5Request
|
||
readyForQuiz = true
|
||
}
|
||
}
|
||
|
||
if (readyForQuiz && this.currentContentCard && this.currentContentCard.ctas.some((item) => item.type === 'quiz')) {
|
||
this.openContentQuizFromEntry(this.currentContentCard)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
hasActiveContentExperience(): boolean {
|
||
return this.state.contentCardVisible || this.currentH5ExperienceOpen
|
||
}
|
||
|
||
enqueueContentCard(item: ContentCardEntry): void {
|
||
if (item.once && item.contentKey && this.shownContentCardKeys[item.contentKey]) {
|
||
return
|
||
}
|
||
if (item.contentKey && this.pendingContentCards.some((pending) => pending.contentKey === item.contentKey && pending.autoPopup === item.autoPopup)) {
|
||
return
|
||
}
|
||
this.pendingContentCards.push(item)
|
||
this.syncPendingContentEntryState()
|
||
}
|
||
|
||
openContentCardEntry(item: ContentCardEntry): void {
|
||
this.clearContentCardTimer()
|
||
this.closeContentQuiz(false)
|
||
const actions = this.buildContentCardActions(item.ctas, item.h5Request, item.contentKey)
|
||
const autoDismissMs = this.resolveContentCardAutoDismissMs(item, actions)
|
||
this.setState({
|
||
contentCardVisible: true,
|
||
contentCardTemplate: item.template,
|
||
contentCardTitle: item.title,
|
||
contentCardBody: item.body,
|
||
contentCardActions: actions,
|
||
contentCardFxClass: item.motionClass,
|
||
pendingContentEntryVisible: false,
|
||
pendingContentEntryText: '',
|
||
}, true)
|
||
this.currentContentCardPriority = item.priority
|
||
this.currentContentCard = item
|
||
this.currentContentCardH5Request = item.h5Request
|
||
if (item.once && item.contentKey) {
|
||
this.shownContentCardKeys[item.contentKey] = true
|
||
}
|
||
if (autoDismissMs <= 0) {
|
||
return
|
||
}
|
||
this.contentCardTimer = setTimeout(() => {
|
||
this.contentCardTimer = 0
|
||
this.currentContentCardPriority = 0
|
||
this.currentContentCard = null
|
||
this.currentContentCardH5Request = null
|
||
this.setState({
|
||
contentCardVisible: false,
|
||
contentCardTemplate: 'story',
|
||
contentCardFxClass: '',
|
||
contentCardActions: [],
|
||
}, true)
|
||
this.flushQueuedContentCards()
|
||
}, autoDismissMs) as unknown as number
|
||
}
|
||
|
||
openCurrentContentCardDetail(): void {
|
||
if (!this.currentContentCard) {
|
||
this.setState({
|
||
statusText: `当前没有可打开的内容详情 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
if (!this.currentContentCardH5Request) {
|
||
this.setState({
|
||
statusText: `当前内容未配置 H5 详情 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
if (!this.onOpenH5Experience) {
|
||
this.setState({
|
||
statusText: `H5 详情入口未就绪 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
if (this.currentH5ExperienceOpen) {
|
||
this.setState({
|
||
statusText: `H5 详情页已在打开中 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
const request = this.currentContentCardH5Request
|
||
this.clearContentCardTimer()
|
||
this.closeContentQuiz(false)
|
||
this.setState({
|
||
contentCardVisible: false,
|
||
contentCardTemplate: 'story',
|
||
contentCardTitle: '',
|
||
contentCardBody: '',
|
||
contentCardFxClass: '',
|
||
contentCardActions: [],
|
||
}, true)
|
||
this.currentH5ExperienceOpen = true
|
||
|
||
try {
|
||
this.onOpenH5Experience(request)
|
||
} catch {
|
||
this.currentH5ExperienceOpen = false
|
||
this.openContentCardEntry({
|
||
...this.currentContentCard,
|
||
h5Request: null,
|
||
})
|
||
}
|
||
}
|
||
|
||
openCurrentContentCardAction(actionType: string): 'detail' | 'photo' | 'audio' | 'quiz' | null {
|
||
if (!this.currentContentCard) {
|
||
return null
|
||
}
|
||
if (actionType === 'detail') {
|
||
this.openCurrentContentCardDetail()
|
||
return 'detail'
|
||
}
|
||
if (actionType === 'quiz') {
|
||
this.openCurrentContentCardQuiz()
|
||
return 'quiz'
|
||
}
|
||
if (actionType === 'photo') {
|
||
return 'photo'
|
||
}
|
||
if (actionType === 'audio') {
|
||
return 'audio'
|
||
}
|
||
return null
|
||
}
|
||
|
||
handleContentCardPhotoCaptured(): void {
|
||
this.setState({
|
||
statusText: `已完成拍照,照片待接入上传 (${this.buildVersion})`,
|
||
}, true)
|
||
}
|
||
|
||
handleContentCardAudioRecorded(): void {
|
||
this.setState({
|
||
statusText: `已完成录音,音频待接入上传 (${this.buildVersion})`,
|
||
}, true)
|
||
}
|
||
|
||
flushQueuedContentCards(): void {
|
||
if (this.state.contentCardVisible || !this.pendingContentCards.length) {
|
||
this.syncPendingContentEntryState()
|
||
return
|
||
}
|
||
|
||
let candidateIndex = -1
|
||
let candidatePriority = Number.NEGATIVE_INFINITY
|
||
|
||
for (let index = 0; index < this.pendingContentCards.length; index += 1) {
|
||
const item = this.pendingContentCards[index]
|
||
if (!item.autoPopup) {
|
||
continue
|
||
}
|
||
if (item.priority > candidatePriority) {
|
||
candidatePriority = item.priority
|
||
candidateIndex = index
|
||
}
|
||
}
|
||
|
||
if (candidateIndex < 0) {
|
||
this.syncPendingContentEntryState()
|
||
return
|
||
}
|
||
|
||
const nextItem = this.pendingContentCards.splice(candidateIndex, 1)[0]
|
||
this.openContentCardEntry(nextItem)
|
||
}
|
||
|
||
clearMapPulseTimer(): void {
|
||
if (this.mapPulseTimer) {
|
||
clearTimeout(this.mapPulseTimer)
|
||
this.mapPulseTimer = 0
|
||
}
|
||
}
|
||
|
||
clearStageFxTimer(): void {
|
||
if (this.stageFxTimer) {
|
||
clearTimeout(this.stageFxTimer)
|
||
this.stageFxTimer = 0
|
||
}
|
||
}
|
||
|
||
resetTransientGameUiState(): void {
|
||
this.clearPunchFeedbackTimer()
|
||
this.clearContentCardTimer()
|
||
this.closeContentQuiz(false)
|
||
this.clearMapPulseTimer()
|
||
this.clearStageFxTimer()
|
||
this.setState({
|
||
punchFeedbackVisible: false,
|
||
punchFeedbackText: '',
|
||
punchFeedbackTone: 'neutral',
|
||
punchFeedbackFxClass: '',
|
||
contentCardVisible: false,
|
||
contentCardTemplate: 'story',
|
||
contentCardTitle: '',
|
||
contentCardBody: '',
|
||
contentCardActions: [],
|
||
pendingContentEntryVisible: this.getPendingManualContentCount() > 0,
|
||
pendingContentEntryText: this.buildPendingContentEntryText(),
|
||
contentCardFxClass: '',
|
||
mapPulseVisible: false,
|
||
mapPulseFxClass: '',
|
||
stageFxVisible: false,
|
||
stageFxClass: '',
|
||
punchButtonFxClass: '',
|
||
panelProgressFxClass: '',
|
||
panelDistanceFxClass: '',
|
||
}, true)
|
||
this.currentContentCardPriority = 0
|
||
this.currentContentCard = null
|
||
this.currentContentCardH5Request = null
|
||
this.currentH5ExperienceOpen = false
|
||
}
|
||
|
||
resetSessionContentExperienceState(): void {
|
||
this.shownContentCardKeys = {}
|
||
this.consumedContentQuizKeys = {}
|
||
this.rewardedContentQuizKeys = {}
|
||
this.sessionBonusScore = 0
|
||
this.sessionQuizCorrectCount = 0
|
||
this.sessionQuizWrongCount = 0
|
||
this.sessionQuizTimeoutCount = 0
|
||
this.currentContentCardPriority = 0
|
||
this.currentContentCard = null
|
||
this.currentContentCardH5Request = null
|
||
this.pendingContentCards = []
|
||
this.currentH5ExperienceOpen = false
|
||
this.closeContentQuiz(false)
|
||
this.setState({
|
||
pendingContentEntryVisible: false,
|
||
pendingContentEntryText: '',
|
||
})
|
||
}
|
||
|
||
clearSessionTimerInterval(): void {
|
||
if (this.sessionTimerInterval) {
|
||
clearInterval(this.sessionTimerInterval)
|
||
this.sessionTimerInterval = 0
|
||
}
|
||
}
|
||
|
||
syncSessionTimerText(): void {
|
||
const telemetryPresentation = this.telemetryRuntime.getPresentation()
|
||
this.setState({
|
||
panelTimerText: telemetryPresentation.timerText,
|
||
panelTimerMode: telemetryPresentation.timerMode,
|
||
panelMileageText: telemetryPresentation.mileageText,
|
||
panelActionTagText: this.gamePresentation.hud.actionTagText,
|
||
panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
|
||
panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
|
||
panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
|
||
panelSpeedValueText: telemetryPresentation.speedText,
|
||
panelTelemetryTone: telemetryPresentation.heartRateTone,
|
||
panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText,
|
||
panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText,
|
||
panelHeartRateValueText: telemetryPresentation.heartRateValueText,
|
||
panelHeartRateUnitText: telemetryPresentation.heartRateUnitText,
|
||
panelCaloriesValueText: telemetryPresentation.caloriesValueText,
|
||
panelCaloriesUnitText: telemetryPresentation.caloriesUnitText,
|
||
panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText,
|
||
panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
|
||
panelAccuracyValueText: telemetryPresentation.accuracyValueText,
|
||
panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
|
||
})
|
||
}
|
||
|
||
shouldAutoCloseSession(now = Date.now()): boolean {
|
||
const definition = this.gameRuntime.definition
|
||
const state = this.gameRuntime.state
|
||
if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) {
|
||
return false
|
||
}
|
||
|
||
return now - state.startedAt >= definition.sessionCloseAfterMs
|
||
}
|
||
|
||
handleSessionCloseTimeout(now = Date.now()): void {
|
||
if (!this.shouldAutoCloseSession(now)) {
|
||
return
|
||
}
|
||
|
||
this.clearSessionTimerInterval()
|
||
const result = this.gameRuntime.dispatch({
|
||
type: 'session_timed_out',
|
||
at: now,
|
||
})
|
||
this.commitGameResult(result, `已到关门时间,超时结束 (${this.buildVersion})`)
|
||
}
|
||
|
||
applyDebugSessionElapsedMs(elapsedMs: number, labelText: string): void {
|
||
const definition = this.gameRuntime.definition
|
||
const state = this.gameRuntime.state
|
||
if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) {
|
||
this.setState({
|
||
statusText: `当前对局未在进行中,无法调整${labelText} (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
const boundedElapsedMs = Math.max(0, Math.min(elapsedMs, Math.max(0, definition.sessionCloseAfterMs - 1000)))
|
||
const now = Date.now()
|
||
const nextState = {
|
||
...state,
|
||
startedAt: now - boundedElapsedMs,
|
||
endedAt: null,
|
||
status: 'running' as const,
|
||
}
|
||
this.gameRuntime.state = nextState
|
||
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, nextState, this.getHudTargetControlId())
|
||
this.updateSessionTimerLoop()
|
||
this.setState({
|
||
...this.getGameViewPatch(`调试已设置${labelText} (${this.buildVersion})`),
|
||
}, true)
|
||
}
|
||
|
||
handleDebugSetSessionRemainingWarning(): void {
|
||
const definition = this.gameRuntime.definition
|
||
if (!definition) {
|
||
return
|
||
}
|
||
|
||
this.applyDebugSessionElapsedMs(
|
||
Math.max(0, definition.sessionCloseAfterMs - definition.sessionCloseWarningMs),
|
||
'剩余10分钟',
|
||
)
|
||
}
|
||
|
||
handleDebugSetSessionRemainingOneMinute(): void {
|
||
const definition = this.gameRuntime.definition
|
||
if (!definition) {
|
||
return
|
||
}
|
||
|
||
this.applyDebugSessionElapsedMs(
|
||
Math.max(0, definition.sessionCloseAfterMs - 60 * 1000),
|
||
'剩余1分钟',
|
||
)
|
||
}
|
||
|
||
handleDebugTimeoutSession(): void {
|
||
const definition = this.gameRuntime.definition
|
||
const state = this.gameRuntime.state
|
||
if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) {
|
||
this.setState({
|
||
statusText: `当前对局未在进行中,无法触发超时 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
this.gameRuntime.state = {
|
||
...state,
|
||
startedAt: Date.now() - definition.sessionCloseAfterMs,
|
||
}
|
||
this.handleSessionCloseTimeout(Date.now())
|
||
}
|
||
|
||
updateSessionTimerLoop(): void {
|
||
const gameState = this.gameRuntime.state
|
||
const shouldRun = !!gameState && gameState.status === 'running' && gameState.endedAt === null
|
||
|
||
this.syncSessionTimerText()
|
||
if (this.shouldAutoCloseSession()) {
|
||
this.handleSessionCloseTimeout()
|
||
return
|
||
}
|
||
if (!shouldRun) {
|
||
this.clearSessionTimerInterval()
|
||
return
|
||
}
|
||
|
||
if (this.sessionTimerInterval) {
|
||
return
|
||
}
|
||
|
||
this.sessionTimerInterval = setInterval(() => {
|
||
this.syncSessionTimerText()
|
||
if (this.shouldAutoCloseSession()) {
|
||
this.handleSessionCloseTimeout()
|
||
}
|
||
}, 1000) as unknown as number
|
||
}
|
||
|
||
getControlScreenPoint(controlId: string): { x: number; y: number } | null {
|
||
if (!this.gameRuntime.definition || !this.state.stageWidth || !this.state.stageHeight) {
|
||
return null
|
||
}
|
||
|
||
const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
|
||
if (!control) {
|
||
return null
|
||
}
|
||
|
||
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
|
||
const screenPoint = worldToScreen({
|
||
centerWorldX: exactCenter.x,
|
||
centerWorldY: exactCenter.y,
|
||
viewportWidth: this.state.stageWidth,
|
||
viewportHeight: this.state.stageHeight,
|
||
visibleColumns: DESIRED_VISIBLE_COLUMNS,
|
||
rotationRad: this.getRotationRad(this.state.rotationDeg),
|
||
}, lonLatToWorldTile(control.point, this.state.zoom), false)
|
||
|
||
if (screenPoint.x < -80 || screenPoint.x > this.state.stageWidth + 80 || screenPoint.y < -80 || screenPoint.y > this.state.stageHeight + 80) {
|
||
return null
|
||
}
|
||
|
||
return screenPoint
|
||
}
|
||
|
||
setPunchButtonFxClass(className: string): void {
|
||
this.setState({
|
||
punchButtonFxClass: className,
|
||
}, true)
|
||
}
|
||
|
||
setHudProgressFxClass(className: string): void {
|
||
this.setState({
|
||
panelProgressFxClass: className,
|
||
}, true)
|
||
}
|
||
|
||
setHudDistanceFxClass(className: string): void {
|
||
this.setState({
|
||
panelDistanceFxClass: className,
|
||
}, true)
|
||
}
|
||
|
||
showMapPulse(controlId: string, motionClass = ''): void {
|
||
const screenPoint = this.getControlScreenPoint(controlId)
|
||
if (!screenPoint) {
|
||
return
|
||
}
|
||
|
||
this.clearMapPulseTimer()
|
||
this.setState({
|
||
mapPulseVisible: true,
|
||
mapPulseLeftPx: screenPoint.x,
|
||
mapPulseTopPx: screenPoint.y,
|
||
mapPulseFxClass: motionClass,
|
||
}, true)
|
||
this.mapPulseTimer = setTimeout(() => {
|
||
this.mapPulseTimer = 0
|
||
this.setState({
|
||
mapPulseVisible: false,
|
||
mapPulseFxClass: '',
|
||
}, true)
|
||
}, 820) as unknown as number
|
||
}
|
||
|
||
showStageFx(className: string): void {
|
||
if (!className) {
|
||
return
|
||
}
|
||
|
||
this.clearStageFxTimer()
|
||
this.setState({
|
||
stageFxVisible: true,
|
||
stageFxClass: className,
|
||
}, true)
|
||
this.stageFxTimer = setTimeout(() => {
|
||
this.stageFxTimer = 0
|
||
this.setState({
|
||
stageFxVisible: false,
|
||
stageFxClass: '',
|
||
}, true)
|
||
}, 760) as unknown as number
|
||
}
|
||
|
||
showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning', motionClass = ''): void {
|
||
this.clearPunchFeedbackTimer()
|
||
this.setState({
|
||
punchFeedbackVisible: true,
|
||
punchFeedbackText: text,
|
||
punchFeedbackTone: tone,
|
||
punchFeedbackFxClass: motionClass,
|
||
}, true)
|
||
this.punchFeedbackTimer = setTimeout(() => {
|
||
this.punchFeedbackTimer = 0
|
||
this.setState({
|
||
punchFeedbackVisible: false,
|
||
punchFeedbackFxClass: '',
|
||
}, true)
|
||
}, 1400) as unknown as number
|
||
}
|
||
|
||
showContentCard(title: string, body: string, motionClass = '', options?: { contentKey?: string; autoPopup?: boolean; once?: boolean; priority?: number }): void {
|
||
const autoPopup = !options || options.autoPopup !== false
|
||
const once = !!(options && options.once)
|
||
const priority = options && typeof options.priority === 'number' ? options.priority : 0
|
||
const contentKey = options && options.contentKey ? options.contentKey : ''
|
||
const resolved = this.resolveContentControlByKey(contentKey)
|
||
const resolvedCtas = resolved && resolved.control.displayContent ? resolved.control.displayContent.ctas : []
|
||
const h5Request = this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup)
|
||
const entry = {
|
||
template: resolved && resolved.control.displayContent ? resolved.control.displayContent.template : 'story',
|
||
title,
|
||
body,
|
||
motionClass,
|
||
contentKey,
|
||
once,
|
||
priority,
|
||
autoPopup,
|
||
ctas: resolvedCtas,
|
||
h5Request,
|
||
}
|
||
|
||
if (once && contentKey && this.shownContentCardKeys[contentKey]) {
|
||
return
|
||
}
|
||
|
||
if (!autoPopup) {
|
||
this.enqueueContentCard(entry)
|
||
return
|
||
}
|
||
|
||
if (this.currentH5ExperienceOpen) {
|
||
this.enqueueContentCard(entry)
|
||
return
|
||
}
|
||
|
||
if (this.state.contentCardVisible) {
|
||
if (priority > this.currentContentCardPriority) {
|
||
this.openContentCardEntry(entry)
|
||
return
|
||
}
|
||
|
||
this.enqueueContentCard(entry)
|
||
return
|
||
}
|
||
|
||
this.openContentCardEntry(entry)
|
||
}
|
||
|
||
closeContentCard(): void {
|
||
this.clearContentCardTimer()
|
||
this.closeContentQuiz(false)
|
||
this.currentContentCardPriority = 0
|
||
this.currentContentCard = null
|
||
this.currentContentCardH5Request = null
|
||
this.currentH5ExperienceOpen = false
|
||
this.setState({
|
||
contentCardVisible: false,
|
||
contentCardTemplate: 'story',
|
||
contentCardTitle: '',
|
||
contentCardBody: '',
|
||
contentCardFxClass: '',
|
||
contentCardActions: [],
|
||
}, true)
|
||
this.flushQueuedContentCards()
|
||
}
|
||
|
||
openPendingContentCard(): void {
|
||
if (!this.pendingContentCards.length) {
|
||
return
|
||
}
|
||
|
||
let candidateIndex = -1
|
||
let candidatePriority = Number.NEGATIVE_INFINITY
|
||
for (let index = 0; index < this.pendingContentCards.length; index += 1) {
|
||
const item = this.pendingContentCards[index]
|
||
if (item.autoPopup) {
|
||
continue
|
||
}
|
||
if (item.priority > candidatePriority) {
|
||
candidatePriority = item.priority
|
||
candidateIndex = index
|
||
}
|
||
}
|
||
|
||
if (candidateIndex < 0) {
|
||
return
|
||
}
|
||
|
||
const pending = this.pendingContentCards.splice(candidateIndex, 1)[0]
|
||
this.openContentCardEntry({
|
||
...pending,
|
||
autoPopup: true,
|
||
})
|
||
}
|
||
|
||
handleH5ExperienceClosed(): void {
|
||
this.currentH5ExperienceOpen = false
|
||
this.currentContentCardPriority = 0
|
||
this.currentContentCard = null
|
||
this.currentContentCardH5Request = null
|
||
this.flushQueuedContentCards()
|
||
}
|
||
|
||
handleH5ExperienceFallback(fallback: H5ExperienceFallbackPayload): void {
|
||
this.currentH5ExperienceOpen = false
|
||
this.currentContentCardPriority = 0
|
||
this.currentContentCard = null
|
||
this.currentContentCardH5Request = null
|
||
this.openContentCardEntry({
|
||
template: 'story',
|
||
...fallback,
|
||
ctas: [],
|
||
h5Request: null,
|
||
})
|
||
}
|
||
|
||
clearContentExperienceForResultScene(): void {
|
||
this.clearContentCardTimer()
|
||
this.closeContentQuiz(false)
|
||
this.currentContentCardPriority = 0
|
||
this.currentContentCard = null
|
||
this.currentContentCardH5Request = null
|
||
this.currentH5ExperienceOpen = false
|
||
this.pendingContentCards = []
|
||
this.setState({
|
||
contentCardVisible: false,
|
||
contentCardTemplate: 'story',
|
||
contentCardTitle: '',
|
||
contentCardBody: '',
|
||
contentCardFxClass: '',
|
||
contentCardActions: [],
|
||
pendingContentEntryVisible: false,
|
||
pendingContentEntryText: '',
|
||
}, true)
|
||
}
|
||
|
||
applyGameEffects(effects: GameEffect[]): string | null {
|
||
this.feedbackDirector.handleEffects(effects)
|
||
this.applyAutoContentQuizEffects(effects)
|
||
if (effects.some((effect) => effect.type === 'session_finished' || effect.type === 'session_timed_out')) {
|
||
this.clearContentExperienceForResultScene()
|
||
if (this.locationController.listening) {
|
||
this.locationController.stop()
|
||
}
|
||
this.setState({
|
||
gpsTracking: false,
|
||
gpsTrackingText: '测试结束,定位已停止',
|
||
}, true)
|
||
}
|
||
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId())
|
||
this.updateSessionTimerLoop()
|
||
return this.resolveGameStatusText(effects)
|
||
}
|
||
|
||
syncGameResultState(result: GameResult): void {
|
||
this.gamePresentation = result.presentation
|
||
this.refreshCourseHeadingFromPresentation()
|
||
}
|
||
|
||
resolveAppliedGameStatusText(result: GameResult, fallbackStatusText?: string | null): string | null {
|
||
return this.applyGameEffects(result.effects) || fallbackStatusText || this.resolveGameStatusText(result.effects)
|
||
}
|
||
|
||
commitGameResult(
|
||
result: GameResult,
|
||
fallbackStatusText?: string | null,
|
||
extraPatch: Partial<MapEngineViewState> = {},
|
||
syncRenderer = true,
|
||
): string | null {
|
||
this.syncGameResultState(result)
|
||
const gameStatusText = this.resolveAppliedGameStatusText(result, fallbackStatusText)
|
||
this.setState({
|
||
...this.getGameViewPatch(gameStatusText),
|
||
...extraPatch,
|
||
}, true)
|
||
if (syncRenderer) {
|
||
this.syncRenderer()
|
||
}
|
||
return gameStatusText
|
||
}
|
||
|
||
handleStartGame(): void {
|
||
if (!this.gameRuntime.definition || !this.gameRuntime.state) {
|
||
this.setState({
|
||
statusText: `当前还没有可开始的路线 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
if (this.gameRuntime.state.status !== 'idle') {
|
||
if (this.gameRuntime.state.status === 'finished' || this.gameRuntime.state.status === 'failed') {
|
||
const reloadedResult = this.loadGameDefinitionFromCourse()
|
||
if (!reloadedResult || !this.gameRuntime.state) {
|
||
return
|
||
}
|
||
} else {
|
||
return
|
||
}
|
||
}
|
||
|
||
this.feedbackDirector.reset()
|
||
this.resetTransientGameUiState()
|
||
this.resetSessionContentExperienceState()
|
||
this.clearStartSessionResidue()
|
||
|
||
if (!this.locationController.listening) {
|
||
this.locationController.start()
|
||
}
|
||
|
||
const startedAt = Date.now()
|
||
const startResult = this.gameRuntime.startSession(startedAt)
|
||
let gameResult = startResult
|
||
if (this.currentGpsPoint) {
|
||
const gpsResult = this.gameRuntime.dispatch({
|
||
type: 'gps_updated',
|
||
at: Date.now(),
|
||
lon: this.currentGpsPoint.lon,
|
||
lat: this.currentGpsPoint.lat,
|
||
accuracyMeters: this.currentGpsAccuracyMeters,
|
||
})
|
||
gameResult = {
|
||
nextState: gpsResult.nextState,
|
||
presentation: gpsResult.presentation,
|
||
effects: [...startResult.effects, ...gpsResult.effects],
|
||
}
|
||
}
|
||
|
||
this.courseOverlayVisible = true
|
||
const gameModeText = this.gameMode === 'score-o' ? '积分赛' : '顺序打点'
|
||
const defaultStatusText = this.currentGpsPoint
|
||
? `已进入${gameModeText},请先打开始点 (${this.buildVersion})`
|
||
: `已进入${gameModeText},GPS定位启动中,请先打开始点 (${this.buildVersion})`
|
||
this.commitGameResult(gameResult, defaultStatusText)
|
||
}
|
||
|
||
handleForceExitGame(): void {
|
||
this.feedbackDirector.reset()
|
||
if (this.locationController.listening) {
|
||
this.locationController.stop()
|
||
}
|
||
|
||
if (!this.courseData) {
|
||
this.clearGameRuntime()
|
||
this.resetTransientGameUiState()
|
||
this.resetSessionContentExperienceState()
|
||
this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
|
||
this.setState({
|
||
gpsTracking: false,
|
||
gpsTrackingText: '已退出对局,定位已停止',
|
||
...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
|
||
}, true)
|
||
this.syncRenderer()
|
||
return
|
||
}
|
||
|
||
this.loadGameDefinitionFromCourse()
|
||
this.resetTransientGameUiState()
|
||
this.resetSessionContentExperienceState()
|
||
this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
|
||
this.setState({
|
||
gpsTracking: false,
|
||
gpsTrackingText: '已退出对局,定位已停止',
|
||
...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
|
||
}, true)
|
||
this.syncRenderer()
|
||
}
|
||
|
||
|
||
handlePunchAction(): void {
|
||
const currentPoint = this.currentGpsPoint
|
||
const gameResult = this.gameRuntime.dispatch({
|
||
type: 'punch_requested',
|
||
at: Date.now(),
|
||
lon: currentPoint ? currentPoint.lon : null,
|
||
lat: currentPoint ? currentPoint.lat : null,
|
||
})
|
||
this.commitGameResult(gameResult)
|
||
}
|
||
|
||
handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
|
||
const nextPoint: LonLatPoint = { lon: longitude, lat: latitude }
|
||
const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
|
||
if (!lastTrackPoint || getApproxDistanceMeters(lastTrackPoint, nextPoint) >= GPS_TRACK_MIN_STEP_METERS) {
|
||
const sampleAt = Date.now()
|
||
this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS)
|
||
this.currentGpsTrackSamples = [...this.currentGpsTrackSamples, { point: nextPoint, at: sampleAt }].slice(-GPS_TRACK_MAX_POINTS)
|
||
this.lastTrackMotionAt = sampleAt
|
||
}
|
||
|
||
this.currentGpsPoint = nextPoint
|
||
this.currentGpsAccuracyMeters = accuracyMeters
|
||
this.updateMovementHeadingDeg()
|
||
|
||
const gpsWorldPoint = lonLatToWorldTile(nextPoint, this.state.zoom)
|
||
const gpsTileX = Math.floor(gpsWorldPoint.x)
|
||
const gpsTileY = Math.floor(gpsWorldPoint.y)
|
||
const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
|
||
this.currentGpsInsideMap = gpsInsideMap
|
||
let gameStatusText: string | null = null
|
||
|
||
if (!gpsInsideMap && this.gpsLockEnabled) {
|
||
this.gpsLockEnabled = false
|
||
gameStatusText = `GPS已超出地图范围,锁定已关闭 (${this.buildVersion})`
|
||
}
|
||
|
||
if (this.courseData) {
|
||
const eventAt = Date.now()
|
||
const gameResult = this.gameRuntime.dispatch({
|
||
type: 'gps_updated',
|
||
at: eventAt,
|
||
lon: longitude,
|
||
lat: latitude,
|
||
accuracyMeters,
|
||
})
|
||
this.telemetryRuntime.dispatch({
|
||
type: 'gps_updated',
|
||
at: eventAt,
|
||
lon: longitude,
|
||
lat: latitude,
|
||
accuracyMeters,
|
||
})
|
||
this.syncGameResultState(gameResult)
|
||
gameStatusText = this.resolveAppliedGameStatusText(gameResult)
|
||
}
|
||
|
||
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
||
this.scheduleAutoRotate()
|
||
}
|
||
|
||
if (gpsInsideMap && (this.gpsLockEnabled || !this.hasGpsCenteredOnce)) {
|
||
this.hasGpsCenteredOnce = true
|
||
const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
|
||
this.commitViewport({
|
||
...lockedViewport,
|
||
gpsTracking: true,
|
||
gpsTrackingText: '持续定位进行中',
|
||
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
|
||
autoRotateSourceText: this.getAutoRotateSourceText(),
|
||
gpsLockEnabled: this.gpsLockEnabled,
|
||
gpsLockAvailable: true,
|
||
...this.getGameViewPatch(),
|
||
}, gameStatusText || (this.gpsLockEnabled ? `GPS锁定跟随中 (${this.buildVersion})` : `GPS定位成功,已定位到当前位置 (${this.buildVersion})`), true)
|
||
return
|
||
}
|
||
|
||
this.setState({
|
||
gpsTracking: true,
|
||
gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
|
||
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
|
||
autoRotateSourceText: this.getAutoRotateSourceText(),
|
||
gpsLockEnabled: this.gpsLockEnabled,
|
||
gpsLockAvailable: gpsInsideMap,
|
||
...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
|
||
})
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleToggleGpsLock(): void {
|
||
if (!this.currentGpsPoint || !this.currentGpsInsideMap) {
|
||
this.setState({
|
||
gpsLockEnabled: false,
|
||
gpsLockAvailable: false,
|
||
statusText: this.currentGpsPoint
|
||
? `当前位置不在地图范围内,无法锁定 (${this.buildVersion})`
|
||
: `当前还没有可锁定的GPS位置 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
const nextEnabled = !this.gpsLockEnabled
|
||
this.gpsLockEnabled = nextEnabled
|
||
|
||
if (nextEnabled) {
|
||
const gpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
|
||
const gpsTileX = Math.floor(gpsWorldPoint.x)
|
||
const gpsTileY = Math.floor(gpsWorldPoint.y)
|
||
const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
|
||
if (gpsInsideMap) {
|
||
this.hasGpsCenteredOnce = true
|
||
const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
|
||
this.commitViewport({
|
||
...lockedViewport,
|
||
gpsLockEnabled: true,
|
||
gpsLockAvailable: true,
|
||
}, `GPS已锁定在屏幕中央 (${this.buildVersion})`, true)
|
||
return
|
||
}
|
||
|
||
this.setState({
|
||
gpsLockEnabled: true,
|
||
gpsLockAvailable: true,
|
||
statusText: `GPS锁定已开启,等待进入地图范围 (${this.buildVersion})`,
|
||
}, true)
|
||
this.syncRenderer()
|
||
return
|
||
}
|
||
|
||
this.setState({
|
||
gpsLockEnabled: false,
|
||
gpsLockAvailable: true,
|
||
statusText: `GPS锁定已关闭 (${this.buildVersion})`,
|
||
}, true)
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleToggleOsmReference(): void {
|
||
const nextEnabled = !this.state.osmReferenceEnabled
|
||
this.setState({
|
||
osmReferenceEnabled: nextEnabled,
|
||
osmReferenceText: nextEnabled ? 'OSM参考:开' : 'OSM参考:关',
|
||
statusText: nextEnabled ? `OSM参考底图已开启 (${this.buildVersion})` : `OSM参考底图已关闭 (${this.buildVersion})`,
|
||
}, true)
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleToggleGpsTracking(): void {
|
||
if (this.locationController.listening) {
|
||
this.locationController.stop()
|
||
return
|
||
}
|
||
|
||
this.locationController.start()
|
||
}
|
||
|
||
handleSetRealLocationMode(): void {
|
||
this.locationController.setSourceMode('real')
|
||
}
|
||
|
||
handleSetMockLocationMode(): void {
|
||
const wasListening = this.locationController.listening
|
||
if (!this.locationController.mockBridge.connected && !this.locationController.mockBridge.connecting) {
|
||
this.locationController.connectMockBridge()
|
||
}
|
||
this.locationController.setSourceMode('mock')
|
||
if (!wasListening && !this.locationController.listening) {
|
||
this.locationController.start()
|
||
}
|
||
}
|
||
|
||
handleConnectMockLocationBridge(): void {
|
||
this.locationController.connectMockBridge()
|
||
}
|
||
|
||
handleDisconnectMockLocationBridge(): void {
|
||
this.locationController.disconnectMockBridge()
|
||
}
|
||
|
||
handleSetMockLocationBridgeUrl(url: string): void {
|
||
this.locationController.setMockBridgeUrl(url)
|
||
}
|
||
|
||
handleSetMockChannelId(channelId: string): void {
|
||
const normalized = String(channelId || '').trim() || 'default'
|
||
const shouldReconnectLocation = this.locationController.mockBridge.connected || this.locationController.mockBridge.connecting
|
||
const locationBridgeUrl = this.locationController.mockBridgeUrl
|
||
const shouldReconnectHeartRate = this.heartRateController.mockBridge.connected || this.heartRateController.mockBridge.connecting
|
||
const heartRateBridgeUrl = this.heartRateController.mockBridgeUrl
|
||
const shouldReconnectDebugLog = this.mockSimulatorDebugLogger.enabled
|
||
this.locationController.setMockChannelId(normalized)
|
||
this.heartRateController.setMockChannelId(normalized)
|
||
this.mockSimulatorDebugLogger.setChannelId(normalized)
|
||
if (shouldReconnectLocation) {
|
||
this.locationController.disconnectMockBridge()
|
||
this.locationController.connectMockBridge(locationBridgeUrl)
|
||
}
|
||
if (shouldReconnectHeartRate) {
|
||
this.heartRateController.disconnectMockBridge()
|
||
this.heartRateController.connectMockBridge(heartRateBridgeUrl)
|
||
}
|
||
if (shouldReconnectDebugLog) {
|
||
this.mockSimulatorDebugLogger.disconnect()
|
||
this.mockSimulatorDebugLogger.connect()
|
||
}
|
||
this.setState({
|
||
mockChannelIdText: normalized,
|
||
})
|
||
}
|
||
|
||
handleSetMockDebugLogBridgeUrl(url: string): void {
|
||
this.mockSimulatorDebugLogger.setUrl(url)
|
||
}
|
||
|
||
handleConnectMockDebugLogBridge(): void {
|
||
this.mockSimulatorDebugLogger.connect()
|
||
}
|
||
|
||
handleDisconnectMockDebugLogBridge(): void {
|
||
this.mockSimulatorDebugLogger.disconnect()
|
||
}
|
||
|
||
handleEmitMockDebugLog(
|
||
scope: string,
|
||
level: 'info' | 'warn' | 'error',
|
||
message: string,
|
||
payload?: Record<string, unknown>,
|
||
): void {
|
||
this.mockSimulatorDebugLogger.log(scope, level, message, payload)
|
||
}
|
||
|
||
handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void {
|
||
if (this.gameMode === nextMode) {
|
||
return
|
||
}
|
||
|
||
this.gameMode = nextMode
|
||
const modeDefaults = getGameModeDefaults(nextMode)
|
||
this.sessionCloseAfterMs = modeDefaults.sessionCloseAfterMs
|
||
this.sessionCloseWarningMs = modeDefaults.sessionCloseWarningMs
|
||
this.minCompletedControlsBeforeFinish = modeDefaults.minCompletedControlsBeforeFinish
|
||
this.requiresFocusSelection = modeDefaults.requiresFocusSelection
|
||
this.skipEnabled = modeDefaults.skipEnabled
|
||
this.skipRadiusMeters = getDefaultSkipRadiusMeters(nextMode, this.punchRadiusMeters)
|
||
this.skipRequiresConfirm = modeDefaults.skipRequiresConfirm
|
||
this.autoFinishOnLastControl = modeDefaults.autoFinishOnLastControl
|
||
this.defaultControlScore = modeDefaults.defaultControlScore
|
||
const result = this.loadGameDefinitionFromCourse()
|
||
const modeText = this.getGameModeText()
|
||
if (!result) {
|
||
return
|
||
}
|
||
this.commitGameResult(result, `已切换到${modeText} (${this.buildVersion})`, {
|
||
gameModeText: modeText,
|
||
})
|
||
}
|
||
|
||
handleSkipAction(): void {
|
||
const gameResult = this.gameRuntime.dispatch({
|
||
type: 'skip_requested',
|
||
at: Date.now(),
|
||
lon: this.currentGpsPoint ? this.currentGpsPoint.lon : null,
|
||
lat: this.currentGpsPoint ? this.currentGpsPoint.lat : null,
|
||
})
|
||
this.commitGameResult(gameResult)
|
||
}
|
||
|
||
handleConnectHeartRate(): void {
|
||
this.heartRateController.startScanAndConnect()
|
||
}
|
||
|
||
handleDisconnectHeartRate(): void {
|
||
this.heartRateController.disconnect()
|
||
}
|
||
|
||
handleSetRealHeartRateMode(): void {
|
||
this.heartRateController.setSourceMode('real')
|
||
}
|
||
|
||
handleSetMockHeartRateMode(): void {
|
||
const wasConnected = this.heartRateController.connected
|
||
if (!this.heartRateController.mockBridge.connected && !this.heartRateController.mockBridge.connecting) {
|
||
this.heartRateController.connectMockBridge()
|
||
}
|
||
this.heartRateController.setSourceMode('mock')
|
||
if (!wasConnected && !this.heartRateController.connected) {
|
||
this.heartRateController.startScanAndConnect()
|
||
}
|
||
}
|
||
|
||
handleConnectMockHeartRateBridge(): void {
|
||
this.heartRateController.connectMockBridge()
|
||
}
|
||
|
||
handleDisconnectMockHeartRateBridge(): void {
|
||
this.heartRateController.disconnectMockBridge()
|
||
}
|
||
|
||
handleSetMockHeartRateBridgeUrl(url: string): void {
|
||
this.heartRateController.setMockBridgeUrl(url)
|
||
}
|
||
|
||
handleConnectHeartRateDevice(deviceId: string): void {
|
||
this.heartRateController.connectToDiscoveredDevice(deviceId)
|
||
}
|
||
|
||
handleClearPreferredHeartRateDevice(): void {
|
||
this.heartRateController.clearPreferredDevice()
|
||
this.setState({
|
||
heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
|
||
heartRateScanText: this.getHeartRateScanText(),
|
||
})
|
||
}
|
||
|
||
handleDebugHeartRateTone(tone: HeartRateTone): void {
|
||
const sampleBpm = getHeartRateToneSampleBpm(tone, this.telemetryRuntime.config)
|
||
this.telemetryRuntime.dispatch({
|
||
type: 'heart_rate_updated',
|
||
at: Date.now(),
|
||
bpm: sampleBpm,
|
||
})
|
||
this.setState({
|
||
heartRateStatusText: `调试心率: ${sampleBpm} bpm / ${tone.toUpperCase()}`,
|
||
})
|
||
this.syncSessionTimerText()
|
||
}
|
||
|
||
handleClearDebugHeartRate(): void {
|
||
this.telemetryRuntime.dispatch({
|
||
type: 'heart_rate_updated',
|
||
at: Date.now(),
|
||
bpm: null,
|
||
})
|
||
this.setState({
|
||
heartRateStatusText: this.heartRateController.connected
|
||
? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
|
||
: (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'),
|
||
heartRateScanText: this.getHeartRateScanText(),
|
||
...this.getHeartRateControllerViewPatch(),
|
||
})
|
||
this.syncSessionTimerText()
|
||
}
|
||
|
||
formatHeartRateDevices(devices: HeartRateDiscoveredDevice[]): Array<{ deviceId: string; name: string; rssiText: string; preferred: boolean; connected: boolean }> {
|
||
return devices.map((device) => ({
|
||
deviceId: device.deviceId,
|
||
name: device.name,
|
||
rssiText: device.rssi === null ? '--' : `${device.rssi} dBm`,
|
||
preferred: device.isPreferred,
|
||
connected: !!this.heartRateController.currentDeviceId && this.heartRateController.currentDeviceId === device.deviceId && this.heartRateController.connected,
|
||
}))
|
||
}
|
||
|
||
getHeartRateScanText(): string {
|
||
if (this.heartRateController.sourceMode === 'mock') {
|
||
if (this.heartRateController.connected) {
|
||
return '模拟源已连接'
|
||
}
|
||
|
||
if (this.heartRateController.connecting) {
|
||
return '模拟源连接中'
|
||
}
|
||
|
||
return '模拟模式'
|
||
}
|
||
|
||
if (this.heartRateController.connected) {
|
||
return '已连接'
|
||
}
|
||
|
||
if (this.heartRateController.connecting) {
|
||
return '连接中'
|
||
}
|
||
|
||
if (this.heartRateController.disconnecting) {
|
||
return '断开中'
|
||
}
|
||
|
||
if (this.heartRateController.scanning) {
|
||
return this.heartRateController.lastDeviceId ? '扫描中(优先首选)' : '扫描中(等待选择)'
|
||
}
|
||
|
||
return this.heartRateController.discoveredDevices.length
|
||
? `已发现 ${this.heartRateController.discoveredDevices.length} 个设备`
|
||
: '未扫描'
|
||
}
|
||
setStage(rect: MapEngineStageRect): void {
|
||
this.previewScale = 1
|
||
this.previewOriginX = rect.width / 2
|
||
this.previewOriginY = rect.height / 2
|
||
this.commitViewport(
|
||
{
|
||
stageWidth: rect.width,
|
||
stageHeight: rect.height,
|
||
stageLeft: rect.left,
|
||
stageTop: rect.top,
|
||
},
|
||
`地图视口已与 WebGL 引擎对齐 (${this.buildVersion})`,
|
||
true,
|
||
)
|
||
}
|
||
|
||
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
|
||
if (this.mounted) {
|
||
return
|
||
}
|
||
|
||
this.renderer.attachCanvas(canvasNode, width, height, dpr, labelCanvasNode)
|
||
this.mounted = true
|
||
this.state.mapReady = true
|
||
this.state.mapReadyText = 'READY'
|
||
this.onData({
|
||
mapReady: true,
|
||
mapReadyText: 'READY',
|
||
statusText: `单 WebGL 管线已完成,可切换手动或自动朝向 (${this.buildVersion})`,
|
||
})
|
||
this.syncRenderer()
|
||
this.accelerometerErrorText = null
|
||
this.lastCompassSampleAt = 0
|
||
this.compassController.start()
|
||
this.scheduleCompassBootstrapRetry()
|
||
this.gyroscopeController.start()
|
||
this.deviceMotionController.start()
|
||
}
|
||
|
||
applyRemoteMapConfig(config: RemoteMapConfig): void {
|
||
this.courseData = config.course
|
||
this.configAppId = config.configAppId
|
||
this.configSchemaVersion = config.configSchemaVersion
|
||
this.configVersion = config.configVersion
|
||
this.playfieldKind = config.playfieldKind
|
||
this.controlScoreOverrides = config.controlScoreOverrides
|
||
this.controlContentOverrides = config.controlContentOverrides
|
||
this.defaultControlContentOverride = config.defaultControlContentOverride
|
||
this.defaultControlPointStyleOverride = config.defaultControlPointStyleOverride
|
||
this.controlPointStyleOverrides = config.controlPointStyleOverrides
|
||
this.defaultLegStyleOverride = config.defaultLegStyleOverride
|
||
this.legStyleOverrides = config.legStyleOverrides
|
||
const statePatch: Partial<MapEngineViewState> = {
|
||
mapName: config.configTitle,
|
||
configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`,
|
||
projectionMode: config.projectionModeText,
|
||
tileSource: config.tileSource,
|
||
sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
|
||
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
||
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
|
||
}
|
||
|
||
if (!this.state.stageWidth || !this.state.stageHeight) {
|
||
this.setState({
|
||
...statePatch,
|
||
zoom: this.defaultZoom,
|
||
centerTileX: this.defaultCenterTileX,
|
||
centerTileY: this.defaultCenterTileY,
|
||
centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
|
||
statusText: `路线已载入,点击开始进入游戏 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
this.commitViewport({
|
||
...statePatch,
|
||
zoom: this.defaultZoom,
|
||
centerTileX: this.defaultCenterTileX,
|
||
centerTileY: this.defaultCenterTileY,
|
||
tileTranslateX: 0,
|
||
tileTranslateY: 0,
|
||
}, `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => {
|
||
this.resetPreviewState()
|
||
this.syncRenderer()
|
||
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
||
this.scheduleAutoRotate()
|
||
}
|
||
})
|
||
}
|
||
|
||
handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
|
||
this.clearInertiaTimer()
|
||
this.clearPreviewResetTimer()
|
||
this.panVelocityX = 0
|
||
this.panVelocityY = 0
|
||
|
||
if (event.touches.length >= 2) {
|
||
const origin = this.gpsLockEnabled
|
||
? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
|
||
: this.getStagePoint(event.touches)
|
||
this.gestureMode = 'pinch'
|
||
this.pinchStartDistance = this.getTouchDistance(event.touches)
|
||
this.pinchStartScale = this.previewScale || 1
|
||
this.pinchStartAngle = this.getTouchAngle(event.touches)
|
||
this.pinchStartRotationDeg = this.state.rotationDeg
|
||
const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
|
||
? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
|
||
: screenToWorld(this.getCameraState(), origin, true)
|
||
this.pinchAnchorWorldX = anchorWorld.x
|
||
this.pinchAnchorWorldY = anchorWorld.y
|
||
this.setPreviewState(this.pinchStartScale, origin.x, origin.y)
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
return
|
||
}
|
||
|
||
if (event.touches.length === 1) {
|
||
this.gestureMode = 'pan'
|
||
this.panLastX = event.touches[0].pageX
|
||
this.panLastY = event.touches[0].pageY
|
||
this.panLastTimestamp = event.timeStamp || Date.now()
|
||
this.tapStartX = event.touches[0].pageX
|
||
this.tapStartY = event.touches[0].pageY
|
||
this.tapStartAt = event.timeStamp || Date.now()
|
||
}
|
||
}
|
||
|
||
handleTouchMove(event: WechatMiniprogram.TouchEvent): void {
|
||
if (event.touches.length >= 2) {
|
||
const distance = this.getTouchDistance(event.touches)
|
||
const angle = this.getTouchAngle(event.touches)
|
||
const origin = this.gpsLockEnabled
|
||
? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
|
||
: this.getStagePoint(event.touches)
|
||
if (!this.pinchStartDistance) {
|
||
this.pinchStartDistance = distance
|
||
this.pinchStartScale = this.previewScale || 1
|
||
this.pinchStartAngle = angle
|
||
this.pinchStartRotationDeg = this.state.rotationDeg
|
||
const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
|
||
? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
|
||
: screenToWorld(this.getCameraState(), origin, true)
|
||
this.pinchAnchorWorldX = anchorWorld.x
|
||
this.pinchAnchorWorldY = anchorWorld.y
|
||
}
|
||
|
||
this.gestureMode = 'pinch'
|
||
const nextRotationDeg = this.state.orientationMode === 'heading-up'
|
||
? this.state.rotationDeg
|
||
: normalizeRotationDeg(this.pinchStartRotationDeg + normalizeAngleDeltaRad(angle - this.pinchStartAngle) * 180 / Math.PI)
|
||
const anchorOffset = this.getWorldOffsetFromScreen(origin.x, origin.y, nextRotationDeg)
|
||
const resolvedViewport = this.resolveViewportForExactCenter(
|
||
this.pinchAnchorWorldX - anchorOffset.x,
|
||
this.pinchAnchorWorldY - anchorOffset.y,
|
||
nextRotationDeg,
|
||
)
|
||
this.setPreviewState(
|
||
clamp(this.pinchStartScale * (distance / this.pinchStartDistance), MIN_PREVIEW_SCALE, MAX_PREVIEW_SCALE),
|
||
origin.x,
|
||
origin.y,
|
||
)
|
||
this.commitViewport(
|
||
{
|
||
...resolvedViewport,
|
||
rotationDeg: nextRotationDeg,
|
||
rotationText: formatRotationText(nextRotationDeg),
|
||
},
|
||
this.state.orientationMode === 'heading-up'
|
||
? `双指缩放中,自动朝向保持开启 (${this.buildVersion})`
|
||
: `双指缩放与旋转中 (${this.buildVersion})`,
|
||
)
|
||
return
|
||
}
|
||
|
||
if (this.gestureMode !== 'pan' || event.touches.length !== 1) {
|
||
return
|
||
}
|
||
const touch = event.touches[0]
|
||
const deltaX = touch.pageX - this.panLastX
|
||
const deltaY = touch.pageY - this.panLastY
|
||
const nextTimestamp = event.timeStamp || Date.now()
|
||
const elapsed = Math.max(nextTimestamp - this.panLastTimestamp, 16)
|
||
const instantVelocityX = deltaX / elapsed
|
||
const instantVelocityY = deltaY / elapsed
|
||
|
||
this.panVelocityX = this.panVelocityX * 0.72 + instantVelocityX * 0.28
|
||
this.panVelocityY = this.panVelocityY * 0.72 + instantVelocityY * 0.28
|
||
this.panLastX = touch.pageX
|
||
this.panLastY = touch.pageY
|
||
this.panLastTimestamp = nextTimestamp
|
||
|
||
if (this.gpsLockEnabled) {
|
||
this.panVelocityX = 0
|
||
this.panVelocityY = 0
|
||
return
|
||
}
|
||
|
||
this.normalizeTranslate(
|
||
this.state.tileTranslateX + deltaX,
|
||
this.state.tileTranslateY + deltaY,
|
||
`宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`,
|
||
)
|
||
}
|
||
|
||
handleTouchEnd(event: WechatMiniprogram.TouchEvent): void {
|
||
const changedTouch = event.changedTouches && event.changedTouches.length ? event.changedTouches[0] : null
|
||
const endedAsTap = changedTouch
|
||
&& this.gestureMode === 'pan'
|
||
&& event.touches.length === 0
|
||
&& Math.abs(changedTouch.pageX - this.tapStartX) <= MAP_TAP_MOVE_THRESHOLD_PX
|
||
&& Math.abs(changedTouch.pageY - this.tapStartY) <= MAP_TAP_MOVE_THRESHOLD_PX
|
||
&& ((event.timeStamp || Date.now()) - this.tapStartAt) <= MAP_TAP_DURATION_MS
|
||
|
||
if (this.gestureMode === 'pinch' && event.touches.length < 2) {
|
||
const gestureScale = this.previewScale || 1
|
||
const zoomDelta = Math.round(Math.log2(gestureScale))
|
||
const originX = this.gpsLockEnabled ? this.state.stageWidth / 2 : (this.previewOriginX || this.state.stageWidth / 2)
|
||
const originY = this.gpsLockEnabled ? this.state.stageHeight / 2 : (this.previewOriginY || this.state.stageHeight / 2)
|
||
|
||
if (zoomDelta) {
|
||
const residualScale = gestureScale / Math.pow(2, zoomDelta)
|
||
this.zoomAroundPoint(zoomDelta, originX, originY, residualScale)
|
||
} else {
|
||
this.animatePreviewToRest()
|
||
}
|
||
|
||
this.resetPinchState()
|
||
this.panVelocityX = 0
|
||
this.panVelocityY = 0
|
||
|
||
if (event.touches.length === 1) {
|
||
this.gestureMode = 'pan'
|
||
this.panLastX = event.touches[0].pageX
|
||
this.panLastY = event.touches[0].pageY
|
||
this.panLastTimestamp = event.timeStamp || Date.now()
|
||
return
|
||
}
|
||
|
||
this.gestureMode = 'idle'
|
||
this.renderer.setAnimationPaused(false)
|
||
this.scheduleAutoRotate()
|
||
return
|
||
}
|
||
|
||
if (event.touches.length === 1) {
|
||
this.gestureMode = 'pan'
|
||
this.panLastX = event.touches[0].pageX
|
||
this.panLastY = event.touches[0].pageY
|
||
this.panLastTimestamp = event.timeStamp || Date.now()
|
||
return
|
||
}
|
||
|
||
if (this.gestureMode === 'pan' && (Math.abs(this.panVelocityX) >= INERTIA_MIN_SPEED || Math.abs(this.panVelocityY) >= INERTIA_MIN_SPEED)) {
|
||
this.startInertia()
|
||
this.gestureMode = 'idle'
|
||
this.resetPinchState()
|
||
return
|
||
}
|
||
|
||
if (endedAsTap && changedTouch) {
|
||
this.handleMapTap(changedTouch.pageX - this.state.stageLeft, changedTouch.pageY - this.state.stageTop)
|
||
}
|
||
|
||
this.gestureMode = 'idle'
|
||
this.resetPinchState()
|
||
this.renderer.setAnimationPaused(false)
|
||
this.scheduleAutoRotate()
|
||
}
|
||
|
||
handleTouchCancel(): void {
|
||
this.gestureMode = 'idle'
|
||
this.resetPinchState()
|
||
this.panVelocityX = 0
|
||
this.panVelocityY = 0
|
||
this.clearInertiaTimer()
|
||
this.animatePreviewToRest()
|
||
this.renderer.setAnimationPaused(false)
|
||
this.scheduleAutoRotate()
|
||
}
|
||
|
||
handleMapTap(stageX: number, stageY: number): void {
|
||
if (!this.gameRuntime.definition || !this.gameRuntime.state) {
|
||
return
|
||
}
|
||
|
||
if (this.gameRuntime.definition.mode === 'score-o') {
|
||
const focusedControlId = this.findFocusableControlAt(stageX, stageY)
|
||
if (focusedControlId !== undefined) {
|
||
const gameResult = this.gameRuntime.dispatch({
|
||
type: 'control_focused',
|
||
at: Date.now(),
|
||
controlId: focusedControlId,
|
||
})
|
||
this.commitGameResult(
|
||
gameResult,
|
||
this.buildFocusSelectionStatusText(focusedControlId),
|
||
)
|
||
}
|
||
}
|
||
|
||
const contentControlId = this.findContentControlAt(stageX, stageY)
|
||
if (contentControlId) {
|
||
this.openControlClickContent(contentControlId)
|
||
}
|
||
}
|
||
|
||
findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
|
||
if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
|
||
return undefined
|
||
}
|
||
|
||
const focusableControls = this.gameRuntime.definition.controls.filter((control) => (
|
||
this.gamePresentation.map.focusableControlIds.includes(control.id)
|
||
))
|
||
|
||
let matchedControlId: string | null | undefined
|
||
let matchedDistance = Number.POSITIVE_INFINITY
|
||
const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())
|
||
|
||
for (const control of focusableControls) {
|
||
const screenPoint = this.getControlScreenPoint(control.id)
|
||
if (!screenPoint) {
|
||
continue
|
||
}
|
||
|
||
const distancePx = Math.sqrt(
|
||
Math.pow(screenPoint.x - stageX, 2)
|
||
+ Math.pow(screenPoint.y - stageY, 2),
|
||
)
|
||
if (distancePx <= hitRadiusPx && distancePx < matchedDistance) {
|
||
matchedDistance = distancePx
|
||
matchedControlId = control.id
|
||
}
|
||
}
|
||
|
||
if (matchedControlId === undefined) {
|
||
return undefined
|
||
}
|
||
|
||
return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId
|
||
}
|
||
|
||
buildFocusSelectionStatusText(controlId: string | null): string {
|
||
if (!controlId || !this.gameRuntime.definition) {
|
||
return `已取消目标点选择 (${this.buildVersion})`
|
||
}
|
||
|
||
const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
|
||
if (!control) {
|
||
return `已更新目标点选择 (${this.buildVersion})`
|
||
}
|
||
|
||
if (control.kind === 'finish') {
|
||
return `已选择终点 ${control.label} (${this.buildVersion})`
|
||
}
|
||
|
||
if (control.kind === 'start') {
|
||
return `已选择开始点 ${control.label} (${this.buildVersion})`
|
||
}
|
||
|
||
const scoreText = typeof control.score === 'number' ? ` / ${control.score}分` : ''
|
||
return `已选择目标点 ${control.label}${scoreText} (${this.buildVersion})`
|
||
}
|
||
|
||
findContentControlAt(stageX: number, stageY: number): string | undefined {
|
||
if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
|
||
return undefined
|
||
}
|
||
|
||
let matchedControlId: string | undefined
|
||
let matchedDistance = Number.POSITIVE_INFINITY
|
||
let matchedPriority = Number.NEGATIVE_INFINITY
|
||
const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())
|
||
|
||
for (const control of this.gameRuntime.definition.controls) {
|
||
if (
|
||
!control.displayContent
|
||
|| (
|
||
!control.displayContent.clickTitle
|
||
&& !control.displayContent.clickBody
|
||
&& !(control.displayContent.clickExperience && control.displayContent.clickExperience.type === 'h5')
|
||
&& !(control.displayContent.contentExperience && control.displayContent.contentExperience.type === 'h5')
|
||
)
|
||
) {
|
||
continue
|
||
}
|
||
if (!this.isControlTapContentVisible(control)) {
|
||
continue
|
||
}
|
||
|
||
const screenPoint = this.getControlScreenPoint(control.id)
|
||
if (!screenPoint) {
|
||
continue
|
||
}
|
||
|
||
const distancePx = Math.sqrt(
|
||
Math.pow(screenPoint.x - stageX, 2)
|
||
+ Math.pow(screenPoint.y - stageY, 2),
|
||
)
|
||
if (distancePx > hitRadiusPx) {
|
||
continue
|
||
}
|
||
|
||
const controlPriority = this.getControlTapContentPriority(control)
|
||
const sameDistance = Math.abs(distancePx - matchedDistance) <= 2
|
||
if (
|
||
distancePx < matchedDistance
|
||
|| (sameDistance && controlPriority > matchedPriority)
|
||
) {
|
||
matchedDistance = distancePx
|
||
matchedPriority = controlPriority
|
||
matchedControlId = control.id
|
||
}
|
||
}
|
||
|
||
return matchedControlId
|
||
}
|
||
|
||
getControlTapContentPriority(control: { kind: 'start' | 'control' | 'finish'; id: string }): number {
|
||
if (!this.gameRuntime.state || !this.gamePresentation.map) {
|
||
return 0
|
||
}
|
||
|
||
const currentTargetControlId = this.gameRuntime.state.currentTargetControlId
|
||
const completedControlIds = this.gameRuntime.state.completedControlIds
|
||
|
||
if (currentTargetControlId === control.id) {
|
||
return 100
|
||
}
|
||
|
||
if (control.kind === 'start') {
|
||
return completedControlIds.includes(control.id) ? 10 : 90
|
||
}
|
||
|
||
if (control.kind === 'finish') {
|
||
return completedControlIds.includes(control.id)
|
||
? 80
|
||
: (this.gamePresentation.map.completedStart ? 85 : 5)
|
||
}
|
||
|
||
return completedControlIds.includes(control.id) ? 40 : 60
|
||
}
|
||
|
||
isControlTapContentVisible(control: { kind: 'start' | 'control' | 'finish'; sequence: number | null; id: string }): boolean {
|
||
if (this.gamePresentation.map.revealFullCourse) {
|
||
return true
|
||
}
|
||
|
||
if (control.kind === 'start') {
|
||
return this.gamePresentation.map.activeStart || this.gamePresentation.map.completedStart
|
||
}
|
||
|
||
if (control.kind === 'finish') {
|
||
return this.gamePresentation.map.activeFinish || this.gamePresentation.map.focusedFinish || this.gamePresentation.map.completedFinish
|
||
}
|
||
|
||
if (control.sequence === null) {
|
||
return false
|
||
}
|
||
|
||
const readyControlSequences = this.resolveReadyControlSequences()
|
||
return this.gamePresentation.map.activeControlSequences.includes(control.sequence)
|
||
|| this.gamePresentation.map.completedControlSequences.includes(control.sequence)
|
||
|| this.gamePresentation.map.skippedControlSequences.includes(control.sequence)
|
||
|| this.gamePresentation.map.focusedControlSequences.includes(control.sequence)
|
||
|| readyControlSequences.includes(control.sequence)
|
||
}
|
||
|
||
openControlClickContent(controlId: string): void {
|
||
if (!this.gameRuntime.definition) {
|
||
return
|
||
}
|
||
|
||
const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
|
||
if (!control || !control.displayContent) {
|
||
return
|
||
}
|
||
|
||
const title = control.displayContent.clickTitle || control.displayContent.title || control.label || '内容体验'
|
||
const body = control.displayContent.clickBody || control.displayContent.body || ''
|
||
if (!title && !body) {
|
||
return
|
||
}
|
||
|
||
const entry = this.buildControlContentCardEntry(`${control.id}:click`, {
|
||
title,
|
||
body,
|
||
motionClass: 'game-content-card--fx-pop',
|
||
autoPopup: true,
|
||
once: false,
|
||
priority: control.displayContent.priority,
|
||
})
|
||
if (!entry) {
|
||
return
|
||
}
|
||
|
||
this.replaceVisibleContentCard(entry)
|
||
}
|
||
|
||
getControlHitRadiusPx(): number {
|
||
if (!this.state.tileSizePx) {
|
||
return 28
|
||
}
|
||
|
||
const centerLonLat = worldTileToLonLat({ x: this.state.centerTileX + 0.5, y: this.state.centerTileY + 0.5 }, this.state.zoom)
|
||
const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * 40075016.686 / Math.pow(2, this.state.zoom)
|
||
if (!metersPerTile) {
|
||
return 28
|
||
}
|
||
|
||
const pixelsPerMeter = this.state.tileSizePx / metersPerTile
|
||
return Math.max(28, this.cpRadiusMeters * pixelsPerMeter * 1.6)
|
||
}
|
||
|
||
handleRecenter(): void {
|
||
this.clearInertiaTimer()
|
||
this.clearPreviewResetTimer()
|
||
this.panVelocityX = 0
|
||
this.panVelocityY = 0
|
||
this.renderer.setAnimationPaused(false)
|
||
this.commitViewport(
|
||
{
|
||
zoom: this.defaultZoom,
|
||
centerTileX: this.defaultCenterTileX,
|
||
centerTileY: this.defaultCenterTileY,
|
||
tileTranslateX: 0,
|
||
tileTranslateY: 0,
|
||
},
|
||
`已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`,
|
||
true,
|
||
() => {
|
||
this.resetPreviewState()
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
this.scheduleAutoRotate()
|
||
},
|
||
)
|
||
}
|
||
|
||
handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
|
||
if (this.state.rotationMode === 'auto') {
|
||
this.setState({
|
||
statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
|
||
const nextRotationDeg = normalizeRotationDeg(this.state.rotationDeg + stepDeg)
|
||
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
|
||
|
||
this.clearInertiaTimer()
|
||
this.clearPreviewResetTimer()
|
||
this.panVelocityX = 0
|
||
this.panVelocityY = 0
|
||
this.renderer.setAnimationPaused(false)
|
||
this.commitViewport(
|
||
{
|
||
...resolvedViewport,
|
||
rotationDeg: nextRotationDeg,
|
||
rotationText: formatRotationText(nextRotationDeg),
|
||
},
|
||
`旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
|
||
true,
|
||
() => {
|
||
this.resetPreviewState()
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
},
|
||
)
|
||
}
|
||
|
||
handleRotationReset(): void {
|
||
if (this.state.rotationMode === 'auto') {
|
||
this.setState({
|
||
statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
const targetRotationDeg = MAP_NORTH_OFFSET_DEG
|
||
if (Math.abs(normalizeAngleDeltaDeg(this.state.rotationDeg - targetRotationDeg)) <= 0.01) {
|
||
return
|
||
}
|
||
|
||
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
|
||
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, targetRotationDeg)
|
||
|
||
this.clearInertiaTimer()
|
||
this.clearPreviewResetTimer()
|
||
this.panVelocityX = 0
|
||
this.panVelocityY = 0
|
||
this.renderer.setAnimationPaused(false)
|
||
this.commitViewport(
|
||
{
|
||
...resolvedViewport,
|
||
rotationDeg: targetRotationDeg,
|
||
rotationText: formatRotationText(targetRotationDeg),
|
||
},
|
||
`旋转角度已回到真北参考 (${this.buildVersion})`,
|
||
true,
|
||
() => {
|
||
this.resetPreviewState()
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
},
|
||
)
|
||
}
|
||
|
||
handleToggleRotationMode(): void {
|
||
if (this.state.orientationMode === 'manual') {
|
||
this.setNorthUpMode()
|
||
return
|
||
}
|
||
|
||
if (this.state.orientationMode === 'north-up') {
|
||
this.setHeadingUpMode()
|
||
return
|
||
}
|
||
|
||
this.setManualMode()
|
||
}
|
||
|
||
handleSetManualMode(): void {
|
||
this.setManualMode()
|
||
}
|
||
|
||
handleSetNorthUpMode(): void {
|
||
this.setNorthUpMode()
|
||
}
|
||
|
||
handleSetHeadingUpMode(): void {
|
||
this.setHeadingUpMode()
|
||
}
|
||
|
||
handleCycleNorthReferenceMode(): void {
|
||
this.cycleNorthReferenceMode()
|
||
}
|
||
|
||
handleSetNorthReferenceMode(mode: NorthReferenceMode): void {
|
||
this.setNorthReferenceMode(mode)
|
||
}
|
||
|
||
handleSetAnimationLevel(level: AnimationLevel): void {
|
||
if (this.animationLevel === level) {
|
||
return
|
||
}
|
||
|
||
this.animationLevel = level
|
||
this.feedbackDirector.setAnimationLevel(level)
|
||
this.setState({
|
||
animationLevel: level,
|
||
statusText: `动画性能已切换为${formatAnimationLevelText(level)} (${this.buildVersion})`,
|
||
})
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleSetTrackMode(mode: TrackDisplayMode): void {
|
||
if (this.trackStyleConfig.mode === mode) {
|
||
return
|
||
}
|
||
this.trackStyleConfig = {
|
||
...this.trackStyleConfig,
|
||
mode,
|
||
}
|
||
this.setState({
|
||
trackDisplayMode: mode,
|
||
statusText: `轨迹模式已切换为${formatTrackDisplayModeText(mode)} (${this.buildVersion})`,
|
||
})
|
||
this.syncRenderer()
|
||
}
|
||
|
||
playPunchHintHaptic(): void {
|
||
this.feedbackDirector.playHapticCue('hint:changed')
|
||
}
|
||
|
||
handleSetTrackTailLength(length: TrackTailLengthPreset): void {
|
||
if (this.trackStyleConfig.tailLength === length) {
|
||
return
|
||
}
|
||
this.trackStyleConfig = {
|
||
...this.trackStyleConfig,
|
||
tailLength: length,
|
||
tailMeters: TRACK_TAIL_LENGTH_METERS[length],
|
||
}
|
||
this.setState({
|
||
trackTailLength: length,
|
||
statusText: `拖尾长度已切换为${formatTrackTailLengthText(length)} (${this.buildVersion})`,
|
||
})
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleSetTrackColorPreset(colorPreset: TrackColorPreset): void {
|
||
if (this.trackStyleConfig.colorPreset === colorPreset) {
|
||
return
|
||
}
|
||
const palette = TRACK_COLOR_PRESET_MAP[colorPreset]
|
||
this.trackStyleConfig = {
|
||
...this.trackStyleConfig,
|
||
colorPreset,
|
||
colorHex: palette.colorHex,
|
||
headColorHex: palette.headColorHex,
|
||
}
|
||
this.setState({
|
||
trackColorPreset: colorPreset,
|
||
statusText: `轨迹颜色已切换为${formatTrackColorPresetText(colorPreset)} (${this.buildVersion})`,
|
||
})
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleSetTrackStyleProfile(style: TrackStyleProfile): void {
|
||
if (this.trackStyleConfig.style === style) {
|
||
return
|
||
}
|
||
const nextGlowStrength = style === 'neon'
|
||
? Math.max(this.trackStyleConfig.glowStrength, 0.18)
|
||
: Math.min(this.trackStyleConfig.glowStrength, 0.08)
|
||
this.trackStyleConfig = {
|
||
...this.trackStyleConfig,
|
||
style,
|
||
glowStrength: nextGlowStrength,
|
||
}
|
||
this.setState({
|
||
trackStyleProfile: style,
|
||
statusText: `轨迹风格已切换为${style === 'neon' ? '流光' : '经典'} (${this.buildVersion})`,
|
||
})
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleSetGpsMarkerVisible(visible: boolean): void {
|
||
if (this.gpsMarkerStyleConfig.visible === visible) {
|
||
return
|
||
}
|
||
this.gpsMarkerStyleConfig = {
|
||
...this.gpsMarkerStyleConfig,
|
||
visible,
|
||
}
|
||
this.setState({
|
||
gpsMarkerVisible: visible,
|
||
statusText: `GPS点显示已切换为${visible ? '显示' : '隐藏'} (${this.buildVersion})`,
|
||
})
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleSetGpsMarkerStyle(style: GpsMarkerStyleId): void {
|
||
if (this.gpsMarkerStyleConfig.style === style) {
|
||
return
|
||
}
|
||
this.gpsMarkerStyleConfig = {
|
||
...this.gpsMarkerStyleConfig,
|
||
style,
|
||
}
|
||
this.setState({
|
||
gpsMarkerStyle: style,
|
||
statusText: `GPS点风格已切换为${formatGpsMarkerStyleText(style)} (${this.buildVersion})`,
|
||
})
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleSetGpsMarkerSize(size: GpsMarkerSizePreset): void {
|
||
if (this.gpsMarkerStyleConfig.size === size) {
|
||
return
|
||
}
|
||
this.gpsMarkerStyleConfig = {
|
||
...this.gpsMarkerStyleConfig,
|
||
size,
|
||
}
|
||
this.setState({
|
||
gpsMarkerSize: size,
|
||
statusText: `GPS点大小已切换为${formatGpsMarkerSizeText(size)} (${this.buildVersion})`,
|
||
})
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleSetGpsMarkerColorPreset(colorPreset: GpsMarkerColorPreset): void {
|
||
if (this.gpsMarkerStyleConfig.colorPreset === colorPreset) {
|
||
return
|
||
}
|
||
const palette = GPS_MARKER_COLOR_PRESET_MAP[colorPreset]
|
||
this.gpsMarkerStyleConfig = {
|
||
...this.gpsMarkerStyleConfig,
|
||
colorPreset,
|
||
colorHex: palette.colorHex,
|
||
ringColorHex: palette.ringColorHex,
|
||
indicatorColorHex: palette.indicatorColorHex,
|
||
}
|
||
this.setState({
|
||
gpsMarkerColorPreset: colorPreset,
|
||
statusText: `GPS点颜色已切换为${formatGpsMarkerColorPresetText(colorPreset)} (${this.buildVersion})`,
|
||
})
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleSetCompassTuningProfile(profile: CompassTuningProfile): void {
|
||
if (this.compassTuningProfile === profile) {
|
||
return
|
||
}
|
||
|
||
this.compassTuningProfile = profile
|
||
this.compassController.setTuningProfile(profile)
|
||
this.setState({
|
||
compassTuningProfile: profile,
|
||
compassTuningProfileText: formatCompassTuningProfileText(profile),
|
||
statusText: `指北针响应已切换为${formatCompassTuningProfileText(profile)} (${this.buildVersion})`,
|
||
}, true)
|
||
}
|
||
|
||
handleAutoRotateCalibrate(): void {
|
||
if (this.state.orientationMode !== 'heading-up') {
|
||
this.setState({
|
||
statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
if (!this.calibrateAutoRotateToCurrentOrientation()) {
|
||
this.setState({
|
||
statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
this.setState({
|
||
statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`,
|
||
}, true)
|
||
this.scheduleAutoRotate()
|
||
}
|
||
|
||
setManualMode(): void {
|
||
this.clearAutoRotateTimer()
|
||
this.targetAutoRotationDeg = null
|
||
this.autoRotateCalibrationPending = false
|
||
this.setState({
|
||
rotationMode: 'manual',
|
||
rotationModeText: formatRotationModeText('manual'),
|
||
rotationToggleText: formatRotationToggleText('manual'),
|
||
orientationMode: 'manual',
|
||
orientationModeText: formatOrientationModeText('manual'),
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
||
statusText: `已切回手动地图旋转 (${this.buildVersion})`,
|
||
}, true)
|
||
}
|
||
|
||
setNorthUpMode(): void {
|
||
this.clearAutoRotateTimer()
|
||
this.targetAutoRotationDeg = null
|
||
this.autoRotateCalibrationPending = false
|
||
|
||
const mapNorthOffsetDeg = MAP_NORTH_OFFSET_DEG
|
||
this.autoRotateCalibrationOffsetDeg = mapNorthOffsetDeg
|
||
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
|
||
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, mapNorthOffsetDeg)
|
||
|
||
this.commitViewport(
|
||
{
|
||
...resolvedViewport,
|
||
rotationDeg: mapNorthOffsetDeg,
|
||
rotationText: formatRotationText(mapNorthOffsetDeg),
|
||
rotationMode: 'manual',
|
||
rotationModeText: formatRotationModeText('north-up'),
|
||
rotationToggleText: formatRotationToggleText('north-up'),
|
||
orientationMode: 'north-up',
|
||
orientationModeText: formatOrientationModeText('north-up'),
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
|
||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||
},
|
||
`地图已固定为真北朝上 (${this.buildVersion})`,
|
||
true,
|
||
() => {
|
||
this.resetPreviewState()
|
||
this.syncRenderer()
|
||
},
|
||
)
|
||
}
|
||
|
||
setHeadingUpMode(): void {
|
||
this.autoRotateCalibrationPending = false
|
||
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(this.northReferenceMode)
|
||
this.targetAutoRotationDeg = null
|
||
this.setState({
|
||
rotationMode: 'auto',
|
||
rotationModeText: formatRotationModeText('heading-up'),
|
||
rotationToggleText: formatRotationToggleText('heading-up'),
|
||
orientationMode: 'heading-up',
|
||
orientationModeText: formatOrientationModeText('heading-up'),
|
||
autoRotateSourceText: this.getAutoRotateSourceText(),
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||
statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
|
||
}, true)
|
||
if (this.refreshAutoRotateTarget()) {
|
||
this.scheduleAutoRotate()
|
||
}
|
||
}
|
||
|
||
applyHeadingSample(headingDeg: number, source: 'compass' | 'motion'): void {
|
||
this.compassSource = source
|
||
this.sensorHeadingDeg = normalizeRotationDeg(headingDeg)
|
||
this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null
|
||
? this.sensorHeadingDeg
|
||
: interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING)
|
||
|
||
const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
|
||
if (this.compassDisplayHeadingDeg === null) {
|
||
this.compassDisplayHeadingDeg = compassHeadingDeg
|
||
this.targetCompassDisplayHeadingDeg = compassHeadingDeg
|
||
this.syncCompassDisplayState()
|
||
} else {
|
||
this.targetCompassDisplayHeadingDeg = compassHeadingDeg
|
||
const displayDeltaDeg = Math.abs(normalizeAngleDeltaDeg(compassHeadingDeg - this.compassDisplayHeadingDeg))
|
||
if (displayDeltaDeg >= COMPASS_TUNING_PRESETS[this.compassTuningProfile].displayDeadzoneDeg) {
|
||
this.scheduleCompassNeedleFollow()
|
||
}
|
||
}
|
||
|
||
this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
|
||
this.setState({
|
||
compassSourceText: formatCompassSourceText(this.compassSource),
|
||
...(this.diagnosticUiEnabled
|
||
? {
|
||
...this.getTelemetrySensorViewPatch(),
|
||
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||
autoRotateSourceText: this.getAutoRotateSourceText(),
|
||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||
}
|
||
: {}),
|
||
})
|
||
|
||
if (!this.refreshAutoRotateTarget()) {
|
||
return
|
||
}
|
||
|
||
if (this.state.orientationMode === 'heading-up') {
|
||
this.scheduleAutoRotate()
|
||
}
|
||
}
|
||
|
||
handleCompassHeading(headingDeg: number): void {
|
||
this.lastCompassSampleAt = Date.now()
|
||
this.clearCompassBootstrapRetryTimer()
|
||
this.applyHeadingSample(headingDeg, 'compass')
|
||
}
|
||
|
||
handleCompassError(message: string): void {
|
||
this.clearAutoRotateTimer()
|
||
this.clearCompassNeedleTimer()
|
||
this.targetAutoRotationDeg = null
|
||
this.autoRotateCalibrationPending = false
|
||
this.compassSource = null
|
||
this.targetCompassDisplayHeadingDeg = null
|
||
this.setState({
|
||
compassSourceText: formatCompassSourceText(null),
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
||
statusText: `${message} (${this.buildVersion})`,
|
||
}, true)
|
||
}
|
||
|
||
cycleNorthReferenceMode(): void {
|
||
this.setNorthReferenceMode(getNextNorthReferenceMode(this.northReferenceMode))
|
||
}
|
||
|
||
setNorthReferenceMode(nextMode: NorthReferenceMode): void {
|
||
if (nextMode === this.northReferenceMode) {
|
||
return
|
||
}
|
||
|
||
const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
|
||
const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
|
||
? null
|
||
: getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg)
|
||
|
||
this.northReferenceMode = nextMode
|
||
this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg
|
||
this.compassDisplayHeadingDeg = compassHeadingDeg
|
||
this.targetCompassDisplayHeadingDeg = compassHeadingDeg
|
||
|
||
if (this.state.orientationMode === 'north-up') {
|
||
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
|
||
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, MAP_NORTH_OFFSET_DEG)
|
||
this.commitViewport(
|
||
{
|
||
...resolvedViewport,
|
||
rotationDeg: MAP_NORTH_OFFSET_DEG,
|
||
rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
|
||
northReferenceText: formatNorthReferenceText(nextMode),
|
||
sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
|
||
...this.getTelemetrySensorViewPatch(),
|
||
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
||
northReferenceMode: nextMode,
|
||
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
||
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
|
||
},
|
||
`${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
|
||
true,
|
||
() => {
|
||
this.resetPreviewState()
|
||
this.syncRenderer()
|
||
},
|
||
)
|
||
return
|
||
}
|
||
|
||
this.setState({
|
||
northReferenceText: formatNorthReferenceText(nextMode),
|
||
sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
|
||
...this.getTelemetrySensorViewPatch(),
|
||
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
||
northReferenceMode: nextMode,
|
||
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
||
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
|
||
statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
|
||
}, true)
|
||
|
||
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
||
this.scheduleAutoRotate()
|
||
}
|
||
|
||
if (this.compassDisplayHeadingDeg !== null) {
|
||
this.syncCompassDisplayState()
|
||
}
|
||
}
|
||
|
||
setCourseHeading(headingDeg: number | null): void {
|
||
this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg)
|
||
this.setState({
|
||
autoRotateSourceText: this.getAutoRotateSourceText(),
|
||
})
|
||
|
||
if (this.refreshAutoRotateTarget()) {
|
||
this.scheduleAutoRotate()
|
||
}
|
||
}
|
||
|
||
getRawMovementHeadingDeg(): number | null {
|
||
if (!this.currentGpsInsideMap) {
|
||
return null
|
||
}
|
||
|
||
if (this.currentGpsAccuracyMeters !== null && this.currentGpsAccuracyMeters > SMART_HEADING_MAX_ACCURACY_METERS) {
|
||
return null
|
||
}
|
||
|
||
if (this.currentGpsTrack.length < 2) {
|
||
return null
|
||
}
|
||
|
||
const lastPoint = this.currentGpsTrack[this.currentGpsTrack.length - 1]
|
||
let accumulatedDistanceMeters = 0
|
||
|
||
for (let index = this.currentGpsTrack.length - 2; index >= 0; index -= 1) {
|
||
const nextPoint = this.currentGpsTrack[index + 1]
|
||
const point = this.currentGpsTrack[index]
|
||
accumulatedDistanceMeters += getApproxDistanceMeters(point, nextPoint)
|
||
|
||
if (accumulatedDistanceMeters >= SMART_HEADING_MIN_DISTANCE_METERS) {
|
||
return getInitialBearingDeg(point, lastPoint)
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
updateMovementHeadingDeg(): void {
|
||
const rawMovementHeadingDeg = this.getRawMovementHeadingDeg()
|
||
if (rawMovementHeadingDeg === null) {
|
||
this.smoothedMovementHeadingDeg = null
|
||
return
|
||
}
|
||
|
||
const smoothingFactor = getMovementHeadingSmoothingFactor(this.telemetryRuntime.state.currentSpeedKmh)
|
||
this.smoothedMovementHeadingDeg = this.smoothedMovementHeadingDeg === null
|
||
? rawMovementHeadingDeg
|
||
: interpolateAngleDeg(this.smoothedMovementHeadingDeg, rawMovementHeadingDeg, smoothingFactor)
|
||
}
|
||
|
||
getTrackFadeFactor(now: number): number {
|
||
if (this.trackStyleConfig.mode !== 'tail' || !this.trackStyleConfig.fadeOutWhenStill) {
|
||
return 1
|
||
}
|
||
|
||
const currentSpeedKmh = this.telemetryRuntime.state.currentSpeedKmh || 0
|
||
if (currentSpeedKmh > this.trackStyleConfig.stillSpeedKmh) {
|
||
return 1
|
||
}
|
||
|
||
if (!this.lastTrackMotionAt) {
|
||
return 1
|
||
}
|
||
|
||
const elapsedMs = Math.max(0, now - this.lastTrackMotionAt)
|
||
const fadeDurationMs = Math.max(1, this.trackStyleConfig.fadeOutDurationMs)
|
||
return Math.max(0, 1 - elapsedMs / fadeDurationMs)
|
||
}
|
||
|
||
getDynamicTailMeters(): number {
|
||
const speedKmh = Math.max(0, this.telemetryRuntime.state.currentSpeedKmh || 0)
|
||
const speedFactor = Math.max(0.35, Math.min(1.8, 0.4 + speedKmh / 6))
|
||
return this.trackStyleConfig.tailMeters * speedFactor
|
||
}
|
||
|
||
buildTrackStyleConfigForScene(): TrackVisualizationConfig {
|
||
const base = this.trackStyleConfig
|
||
const speedKmh = Math.max(0, this.telemetryRuntime.state.currentSpeedKmh || 0)
|
||
const speedIntensity = clampNumber(speedKmh / 14, 0, 1)
|
||
const toneBoost = this.state.panelTelemetryTone === 'red'
|
||
? 0.24
|
||
: this.state.panelTelemetryTone === 'orange'
|
||
? 0.16
|
||
: this.state.panelTelemetryTone === 'yellow'
|
||
? 0.08
|
||
: 0
|
||
const brighten = clampNumber(speedIntensity * 0.34 + toneBoost, 0, 0.42)
|
||
const liteGlowFactor = this.state.animationLevel === 'lite' ? 0.58 : 1
|
||
const liteWidthFactor = this.state.animationLevel === 'lite' ? 0.88 : 1
|
||
return {
|
||
...base,
|
||
colorHex: mixHexColor(base.colorHex, '#ffffff', brighten * 0.62),
|
||
headColorHex: mixHexColor(base.headColorHex, '#ffffff', brighten),
|
||
widthPx: Math.max(2.6, base.widthPx * liteWidthFactor),
|
||
headWidthPx: Math.max(4.8, base.headWidthPx * liteWidthFactor),
|
||
glowStrength: clampNumber((base.glowStrength + speedIntensity * 0.18 + toneBoost * 0.9) * liteGlowFactor, 0, 1.2),
|
||
}
|
||
}
|
||
|
||
buildGpsMarkerStyleConfigForScene(): GpsMarkerStyleConfig {
|
||
const headingConfidence = this.telemetryRuntime.state.headingConfidence
|
||
const headingAlpha = headingConfidence === 'high'
|
||
? 1
|
||
: headingConfidence === 'medium'
|
||
? 0.72
|
||
: 0.42
|
||
const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
|
||
const safeSpeedKmh = speedKmh !== null && Number.isFinite(speedKmh)
|
||
? Math.max(0, speedKmh)
|
||
: 0
|
||
const tone = this.state.panelTelemetryTone
|
||
const toneScale = tone === 'red'
|
||
? 1.3
|
||
: tone === 'orange'
|
||
? 1.2
|
||
: tone === 'yellow'
|
||
? 1.1
|
||
: 1
|
||
const tonePulseBoost = tone === 'red'
|
||
? 0.68
|
||
: tone === 'orange'
|
||
? 0.4
|
||
: tone === 'yellow'
|
||
? 0.18
|
||
: 0
|
||
const toneMixTarget = tone === 'red'
|
||
? '#ff3c6a'
|
||
: tone === 'orange'
|
||
? '#ff8a2d'
|
||
: tone === 'yellow'
|
||
? '#ffe15a'
|
||
: '#ffffff'
|
||
const toneMix = tone === 'red'
|
||
? 0.48
|
||
: tone === 'orange'
|
||
? 0.32
|
||
: tone === 'yellow'
|
||
? 0.18
|
||
: 0
|
||
const litePulseFactor = this.animationLevel === 'lite' ? 0.65 : 1
|
||
const movingBlend = Math.max(0, Math.min(1, (safeSpeedKmh - 1.0) / 3.2))
|
||
const fastBlend = Math.max(0, Math.min(1, (safeSpeedKmh - 6.8) / 3.4))
|
||
const warningBlend = tone === 'red'
|
||
? 1
|
||
: tone === 'orange'
|
||
? 0.72
|
||
: tone === 'yellow'
|
||
? 0.28
|
||
: 0
|
||
const motionState = warningBlend >= 0.68
|
||
? 'warning'
|
||
: safeSpeedKmh >= 6.8
|
||
? 'fast-moving'
|
||
: safeSpeedKmh >= 1.0
|
||
? 'moving'
|
||
: 'idle'
|
||
const motionIntensityBase = motionState === 'idle'
|
||
? Math.max(0, Math.min(0.2, safeSpeedKmh / 5))
|
||
: motionState === 'moving'
|
||
? 0.38 + movingBlend * 0.34
|
||
: motionState === 'fast-moving'
|
||
? 0.76 + fastBlend * 0.24
|
||
: 0.58 + Math.max(warningBlend * 0.3, fastBlend * 0.16)
|
||
const profile = this.gpsMarkerStyleConfig.animationProfile
|
||
const profileGain = profile === 'minimal'
|
||
? 0.72
|
||
: profile === 'warning-reactive'
|
||
? 1.08
|
||
: 1
|
||
const motionIntensity = Math.max(0, Math.min(1.2, motionIntensityBase * profileGain))
|
||
const statePulseBoost = motionState === 'idle'
|
||
? 0.06
|
||
: motionState === 'moving'
|
||
? 0.24 + movingBlend * 0.12
|
||
: motionState === 'fast-moving'
|
||
? 0.48 + fastBlend * 0.18
|
||
: 0.42 + warningBlend * 0.24
|
||
const wakeStrength = profile === 'minimal'
|
||
? (motionState === 'idle' ? 0 : motionState === 'moving' ? 0.14 + movingBlend * 0.08 : motionState === 'fast-moving' ? 0.28 + fastBlend * 0.16 : 0.18 + warningBlend * 0.16)
|
||
: motionState === 'idle'
|
||
? 0
|
||
: motionState === 'moving'
|
||
? 0.24 + movingBlend * 0.16
|
||
: motionState === 'fast-moving'
|
||
? 0.52 + fastBlend * 0.24
|
||
: 0.3 + warningBlend * 0.24
|
||
const warningGlowStrength = Math.max(
|
||
0,
|
||
Math.min(
|
||
1,
|
||
(warningBlend * (profile === 'warning-reactive' ? 1.12 : 0.9))
|
||
* (this.animationLevel === 'lite' ? 0.72 : 1),
|
||
),
|
||
)
|
||
const dynamicEffectScale = motionState === 'idle'
|
||
? 0.98 + motionIntensity * 0.04
|
||
: motionState === 'moving'
|
||
? 1.03 + movingBlend * 0.06
|
||
: motionState === 'fast-moving'
|
||
? 1.1 + fastBlend * 0.12
|
||
: 1.08 + warningBlend * 0.08
|
||
const indicatorScale = motionState === 'idle'
|
||
? 0.96
|
||
: motionState === 'moving'
|
||
? 1.08
|
||
: motionState === 'fast-moving'
|
||
? 1.18
|
||
: 1.1
|
||
const logoScale = motionState === 'idle'
|
||
? 0.96
|
||
: motionState === 'moving'
|
||
? 1
|
||
: motionState === 'fast-moving'
|
||
? 1.06
|
||
: 1
|
||
return {
|
||
...this.gpsMarkerStyleConfig,
|
||
colorHex: mixHexColor(this.gpsMarkerStyleConfig.colorHex, toneMixTarget, toneMix),
|
||
indicatorColorHex: mixHexColor(this.gpsMarkerStyleConfig.indicatorColorHex, '#ffffff', Math.min(0.22, toneMix + 0.06)),
|
||
motionState,
|
||
motionIntensity,
|
||
pulseStrength: (this.gpsMarkerStyleConfig.pulseStrength + tonePulseBoost + statePulseBoost) * litePulseFactor,
|
||
headingAlpha: Math.max(
|
||
headingAlpha,
|
||
motionState === 'fast-moving' ? 0.72 : motionState === 'moving' ? 0.56 : motionState === 'warning' ? 0.66 : 0.42,
|
||
),
|
||
effectScale: toneScale * dynamicEffectScale,
|
||
wakeStrength,
|
||
warningGlowStrength,
|
||
indicatorScale,
|
||
logoScale,
|
||
showHeadingIndicator: this.gpsMarkerStyleConfig.showHeadingIndicator && this.compassDisplayHeadingDeg !== null,
|
||
}
|
||
}
|
||
|
||
buildTrackPointsForScene(): LonLatPoint[] {
|
||
if (this.trackStyleConfig.mode === 'none') {
|
||
return []
|
||
}
|
||
|
||
if (this.trackStyleConfig.mode === 'full') {
|
||
return this.currentGpsTrack
|
||
}
|
||
|
||
if (this.currentGpsTrackSamples.length < 2) {
|
||
return this.currentGpsTrack
|
||
}
|
||
|
||
const now = Date.now()
|
||
const fadeFactor = this.getTrackFadeFactor(now)
|
||
if (fadeFactor <= 0.02) {
|
||
return []
|
||
}
|
||
|
||
const effectiveTailMeters = Math.max(4, this.getDynamicTailMeters() * fadeFactor)
|
||
const cutoffAt = this.trackStyleConfig.tailMaxSeconds > 0
|
||
? now - this.trackStyleConfig.tailMaxSeconds * 1000
|
||
: 0
|
||
const samples = this.currentGpsTrackSamples
|
||
const collected: GpsTrackSample[] = [samples[samples.length - 1]]
|
||
let accumulatedDistanceMeters = 0
|
||
|
||
for (let index = samples.length - 2; index >= 0; index -= 1) {
|
||
const nextSample = samples[index + 1]
|
||
const sample = samples[index]
|
||
if (cutoffAt && sample.at < cutoffAt) {
|
||
break
|
||
}
|
||
|
||
accumulatedDistanceMeters += getApproxDistanceMeters(sample.point, nextSample.point)
|
||
collected.unshift(sample)
|
||
if (accumulatedDistanceMeters >= effectiveTailMeters) {
|
||
break
|
||
}
|
||
}
|
||
|
||
return collected.map((sample) => sample.point)
|
||
}
|
||
|
||
getMovementHeadingDeg(): number | null {
|
||
return this.smoothedMovementHeadingDeg
|
||
}
|
||
|
||
getPreferredSensorHeadingDeg(): number | null {
|
||
return this.smoothedSensorHeadingDeg === null
|
||
? null
|
||
: getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
|
||
}
|
||
|
||
getSmartAutoRotateHeadingDeg(): number | null {
|
||
const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
|
||
const movementHeadingDeg = this.getMovementHeadingDeg()
|
||
const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
|
||
const smartSource = resolveSmartHeadingSource(speedKmh, movementHeadingDeg !== null)
|
||
|
||
if (smartSource === 'movement') {
|
||
return movementHeadingDeg === null ? sensorHeadingDeg : movementHeadingDeg
|
||
}
|
||
|
||
if (smartSource === 'blended' && sensorHeadingDeg !== null && movementHeadingDeg !== null && speedKmh !== null) {
|
||
const blend = Math.max(0, Math.min(1, (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH) / (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH)))
|
||
return interpolateAngleDeg(sensorHeadingDeg, movementHeadingDeg, blend)
|
||
}
|
||
|
||
return sensorHeadingDeg === null ? movementHeadingDeg : sensorHeadingDeg
|
||
}
|
||
|
||
getAutoRotateSourceText(): string {
|
||
if (this.autoRotateSourceMode !== 'smart') {
|
||
return formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null)
|
||
}
|
||
|
||
const smartSource = resolveSmartHeadingSource(
|
||
this.telemetryRuntime.state.currentSpeedKmh,
|
||
this.getMovementHeadingDeg() !== null,
|
||
)
|
||
return formatSmartHeadingSourceText(smartSource)
|
||
}
|
||
|
||
resolveAutoRotateInputHeadingDeg(): number | null {
|
||
if (this.autoRotateSourceMode === 'smart') {
|
||
return this.getSmartAutoRotateHeadingDeg()
|
||
}
|
||
|
||
const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
|
||
const courseHeadingDeg = this.courseHeadingDeg === null
|
||
? null
|
||
: getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg)
|
||
|
||
if (this.autoRotateSourceMode === 'sensor') {
|
||
return sensorHeadingDeg
|
||
}
|
||
|
||
if (this.autoRotateSourceMode === 'course') {
|
||
return courseHeadingDeg === null ? sensorHeadingDeg : courseHeadingDeg
|
||
}
|
||
|
||
if (sensorHeadingDeg !== null && courseHeadingDeg !== null) {
|
||
return interpolateAngleDeg(sensorHeadingDeg, courseHeadingDeg, 0.35)
|
||
}
|
||
|
||
return sensorHeadingDeg === null ? courseHeadingDeg : sensorHeadingDeg
|
||
}
|
||
|
||
calibrateAutoRotateToCurrentOrientation(): boolean {
|
||
const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
|
||
if (inputHeadingDeg === null) {
|
||
return false
|
||
}
|
||
|
||
this.autoRotateCalibrationOffsetDeg = normalizeRotationDeg(this.state.rotationDeg + inputHeadingDeg)
|
||
this.autoRotateCalibrationPending = false
|
||
this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
|
||
this.setState({
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
||
})
|
||
return true
|
||
}
|
||
|
||
refreshAutoRotateTarget(): boolean {
|
||
const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
|
||
if (inputHeadingDeg === null) {
|
||
return false
|
||
}
|
||
|
||
if (this.autoRotateCalibrationPending || this.autoRotateCalibrationOffsetDeg === null) {
|
||
if (!this.calibrateAutoRotateToCurrentOrientation()) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
|
||
this.setState({
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
||
})
|
||
return true
|
||
}
|
||
|
||
scheduleAutoRotate(): void {
|
||
if (this.autoRotateTimer || this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
|
||
return
|
||
}
|
||
|
||
const step = () => {
|
||
this.autoRotateTimer = 0
|
||
|
||
if (this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
|
||
return
|
||
}
|
||
|
||
if (this.gestureMode !== 'idle' || this.inertiaTimer || this.previewResetTimer) {
|
||
this.scheduleAutoRotate()
|
||
return
|
||
}
|
||
|
||
const currentRotationDeg = this.state.rotationDeg
|
||
const deltaDeg = normalizeAngleDeltaDeg(this.targetAutoRotationDeg - currentRotationDeg)
|
||
if (Math.abs(deltaDeg) <= AUTO_ROTATE_SNAP_DEG) {
|
||
if (Math.abs(deltaDeg) > 0.01) {
|
||
this.applyAutoRotation(this.targetAutoRotationDeg)
|
||
}
|
||
this.scheduleAutoRotate()
|
||
return
|
||
}
|
||
|
||
if (Math.abs(deltaDeg) <= AUTO_ROTATE_DEADZONE_DEG) {
|
||
this.scheduleAutoRotate()
|
||
return
|
||
}
|
||
|
||
const easedStepDeg = clamp(deltaDeg * AUTO_ROTATE_EASE, -AUTO_ROTATE_MAX_STEP_DEG, AUTO_ROTATE_MAX_STEP_DEG)
|
||
this.applyAutoRotation(normalizeRotationDeg(currentRotationDeg + easedStepDeg))
|
||
this.scheduleAutoRotate()
|
||
}
|
||
|
||
this.autoRotateTimer = setTimeout(step, AUTO_ROTATE_FRAME_MS) as unknown as number
|
||
}
|
||
|
||
applyAutoRotation(nextRotationDeg: number): void {
|
||
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
|
||
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
|
||
|
||
this.setState({
|
||
...resolvedViewport,
|
||
rotationDeg: nextRotationDeg,
|
||
rotationText: formatRotationText(nextRotationDeg),
|
||
centerText: buildCenterText(this.state.zoom, resolvedViewport.centerTileX, resolvedViewport.centerTileY),
|
||
})
|
||
this.syncRenderer()
|
||
}
|
||
|
||
applyStats(stats: MapRendererStats): void {
|
||
const statsPatch = {
|
||
visibleTileCount: stats.visibleTileCount,
|
||
readyTileCount: stats.readyTileCount,
|
||
memoryTileCount: stats.memoryTileCount,
|
||
diskTileCount: stats.diskTileCount,
|
||
memoryHitCount: stats.memoryHitCount,
|
||
diskHitCount: stats.diskHitCount,
|
||
networkFetchCount: stats.networkFetchCount,
|
||
cacheHitRateText: formatCacheHitRate(stats.memoryHitCount, stats.diskHitCount, stats.networkFetchCount),
|
||
}
|
||
|
||
if (!this.diagnosticUiEnabled) {
|
||
this.state = {
|
||
...this.state,
|
||
...statsPatch,
|
||
}
|
||
return
|
||
}
|
||
|
||
const now = Date.now()
|
||
if (now - this.lastStatsUiSyncAt < 500) {
|
||
this.state = {
|
||
...this.state,
|
||
...statsPatch,
|
||
}
|
||
return
|
||
}
|
||
|
||
this.lastStatsUiSyncAt = now
|
||
this.setState(statsPatch)
|
||
}
|
||
|
||
setState(patch: Partial<MapEngineViewState>, immediateUi = false): void {
|
||
this.state = {
|
||
...this.state,
|
||
...patch,
|
||
}
|
||
|
||
const viewPatch = this.pickViewPatch(patch)
|
||
if (!Object.keys(viewPatch).length) {
|
||
return
|
||
}
|
||
|
||
this.pendingViewPatch = {
|
||
...this.pendingViewPatch,
|
||
...viewPatch,
|
||
}
|
||
|
||
if (immediateUi) {
|
||
this.flushViewPatch()
|
||
return
|
||
}
|
||
|
||
if (this.viewSyncTimer) {
|
||
return
|
||
}
|
||
|
||
this.viewSyncTimer = setTimeout(() => {
|
||
this.viewSyncTimer = 0
|
||
this.flushViewPatch()
|
||
}, UI_SYNC_INTERVAL_MS) as unknown as number
|
||
}
|
||
commitViewport(
|
||
patch: Partial<MapEngineViewState>,
|
||
statusText: string,
|
||
immediateUi = false,
|
||
afterUpdate?: () => void,
|
||
): void {
|
||
const nextZoom = typeof patch.zoom === 'number' ? patch.zoom : this.state.zoom
|
||
const nextCenterTileX = typeof patch.centerTileX === 'number' ? patch.centerTileX : this.state.centerTileX
|
||
const nextCenterTileY = typeof patch.centerTileY === 'number' ? patch.centerTileY : this.state.centerTileY
|
||
const nextStageWidth = typeof patch.stageWidth === 'number' ? patch.stageWidth : this.state.stageWidth
|
||
const nextStageHeight = typeof patch.stageHeight === 'number' ? patch.stageHeight : this.state.stageHeight
|
||
const tileSizePx = getTileSizePx({
|
||
centerWorldX: nextCenterTileX,
|
||
centerWorldY: nextCenterTileY,
|
||
viewportWidth: nextStageWidth,
|
||
viewportHeight: nextStageHeight,
|
||
visibleColumns: DESIRED_VISIBLE_COLUMNS,
|
||
})
|
||
|
||
this.setState({
|
||
...patch,
|
||
tileSizePx,
|
||
centerText: buildCenterText(nextZoom, nextCenterTileX, nextCenterTileY),
|
||
statusText,
|
||
}, immediateUi)
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
if (afterUpdate) {
|
||
afterUpdate()
|
||
}
|
||
}
|
||
|
||
buildScene() {
|
||
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
|
||
const readyControlSequences = this.resolveReadyControlSequences()
|
||
const controlScoresBySequence: Record<number, number> = {}
|
||
const controlStyleOverridesBySequence: Record<number, ControlPointStyleEntry> = {}
|
||
const startStyleOverrides: ControlPointStyleEntry[] = []
|
||
const finishStyleOverrides: ControlPointStyleEntry[] = []
|
||
const gpsMarkerStyleConfig = this.buildGpsMarkerStyleConfigForScene()
|
||
if (this.gameRuntime.definition) {
|
||
for (let index = 0; index < this.gameRuntime.definition.controls.length; index += 1) {
|
||
const control = this.gameRuntime.definition.controls[index]
|
||
if (control.sequence !== null && control.score !== null) {
|
||
controlScoresBySequence[control.sequence] = control.score
|
||
}
|
||
const styleOverride = this.controlPointStyleOverrides[control.id]
|
||
if (!styleOverride) {
|
||
continue
|
||
}
|
||
if (control.kind === 'control' && control.sequence !== null) {
|
||
controlStyleOverridesBySequence[control.sequence] = styleOverride
|
||
continue
|
||
}
|
||
if (control.kind === 'start') {
|
||
const startIndexMatch = control.id.match(/^start-(\d+)$/)
|
||
if (startIndexMatch) {
|
||
startStyleOverrides[Math.max(0, Number(startIndexMatch[1]) - 1)] = styleOverride
|
||
}
|
||
continue
|
||
}
|
||
if (control.kind === 'finish') {
|
||
const finishIndexMatch = control.id.match(/^finish-(\d+)$/)
|
||
if (finishIndexMatch) {
|
||
finishStyleOverrides[Math.max(0, Number(finishIndexMatch[1]) - 1)] = styleOverride
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
tileSource: this.state.tileSource,
|
||
osmTileSource: OSM_TILE_SOURCE,
|
||
zoom: this.state.zoom,
|
||
centerTileX: this.state.centerTileX,
|
||
centerTileY: this.state.centerTileY,
|
||
exactCenterWorldX: exactCenter.x,
|
||
exactCenterWorldY: exactCenter.y,
|
||
tileBoundsByZoom: this.tileBoundsByZoom,
|
||
viewportWidth: this.state.stageWidth,
|
||
viewportHeight: this.state.stageHeight,
|
||
visibleColumns: DESIRED_VISIBLE_COLUMNS,
|
||
overdraw: OVERDRAW,
|
||
translateX: this.state.tileTranslateX,
|
||
translateY: this.state.tileTranslateY,
|
||
rotationRad: this.getRotationRad(this.state.rotationDeg),
|
||
animationLevel: this.state.animationLevel,
|
||
previewScale: this.previewScale || 1,
|
||
previewOriginX: this.previewOriginX || this.state.stageWidth / 2,
|
||
previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
|
||
trackMode: this.trackStyleConfig.mode,
|
||
trackStyleConfig: this.buildTrackStyleConfigForScene(),
|
||
track: this.buildTrackPointsForScene(),
|
||
gpsPoint: this.currentGpsPoint,
|
||
gpsMarkerStyleConfig,
|
||
gpsHeadingDeg: this.compassDisplayHeadingDeg,
|
||
gpsHeadingAlpha: gpsMarkerStyleConfig.headingAlpha,
|
||
gpsCalibration: GPS_MAP_CALIBRATION,
|
||
gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
|
||
course: this.courseOverlayVisible ? this.courseData : null,
|
||
cpRadiusMeters: this.cpRadiusMeters,
|
||
gameMode: this.gameMode,
|
||
courseStyleConfig: this.courseStyleConfig,
|
||
controlScoresBySequence,
|
||
defaultControlStyleOverride: this.defaultControlPointStyleOverride,
|
||
controlStyleOverridesBySequence,
|
||
startStyleOverrides,
|
||
finishStyleOverrides,
|
||
defaultLegStyleOverride: this.defaultLegStyleOverride,
|
||
legStyleOverridesByIndex: this.legStyleOverrides,
|
||
controlVisualMode: this.gamePresentation.map.controlVisualMode,
|
||
showCourseLegs: this.gamePresentation.map.showCourseLegs,
|
||
guidanceLegAnimationEnabled: this.gamePresentation.map.guidanceLegAnimationEnabled,
|
||
focusableControlIds: this.gamePresentation.map.focusableControlIds,
|
||
focusedControlId: this.gamePresentation.map.focusedControlId,
|
||
focusedControlSequences: this.gamePresentation.map.focusedControlSequences,
|
||
activeControlSequences: this.gamePresentation.map.activeControlSequences,
|
||
readyControlSequences,
|
||
activeStart: this.gamePresentation.map.activeStart,
|
||
completedStart: this.gamePresentation.map.completedStart,
|
||
activeFinish: this.gamePresentation.map.activeFinish,
|
||
focusedFinish: this.gamePresentation.map.focusedFinish,
|
||
completedFinish: this.gamePresentation.map.completedFinish,
|
||
revealFullCourse: this.gamePresentation.map.revealFullCourse,
|
||
activeLegIndices: this.gamePresentation.map.activeLegIndices,
|
||
completedLegIndices: this.gamePresentation.map.completedLegIndices,
|
||
completedControlSequences: this.gamePresentation.map.completedControlSequences,
|
||
skippedControlIds: this.gamePresentation.map.skippedControlIds,
|
||
skippedControlSequences: this.gamePresentation.map.skippedControlSequences,
|
||
osmReferenceEnabled: this.state.osmReferenceEnabled,
|
||
overlayOpacity: MAP_OVERLAY_OPACITY,
|
||
}
|
||
}
|
||
|
||
resolveReadyControlSequences(): number[] {
|
||
const punchableControlId = this.gamePresentation.hud.punchableControlId
|
||
const definition = this.gameRuntime.definition
|
||
if (!punchableControlId || !definition) {
|
||
return []
|
||
}
|
||
|
||
const control = definition.controls.find((item) => item.id === punchableControlId)
|
||
if (!control || control.sequence === null) {
|
||
return []
|
||
}
|
||
|
||
return [control.sequence]
|
||
}
|
||
|
||
syncRenderer(): void {
|
||
if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) {
|
||
return
|
||
}
|
||
|
||
this.renderer.updateScene(this.buildScene())
|
||
}
|
||
|
||
getCameraState(rotationDeg = this.state.rotationDeg): CameraState {
|
||
return {
|
||
centerWorldX: this.state.centerTileX + 0.5,
|
||
centerWorldY: this.state.centerTileY + 0.5,
|
||
viewportWidth: this.state.stageWidth,
|
||
viewportHeight: this.state.stageHeight,
|
||
visibleColumns: DESIRED_VISIBLE_COLUMNS,
|
||
translateX: this.state.tileTranslateX,
|
||
translateY: this.state.tileTranslateY,
|
||
rotationRad: this.getRotationRad(rotationDeg),
|
||
}
|
||
}
|
||
|
||
getRotationRad(rotationDeg = this.state.rotationDeg): number {
|
||
return normalizeRotationDeg(rotationDeg) * Math.PI / 180
|
||
}
|
||
|
||
getBaseCamera(centerTileX = this.state.centerTileX, centerTileY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState {
|
||
return {
|
||
centerWorldX: centerTileX + 0.5,
|
||
centerWorldY: centerTileY + 0.5,
|
||
viewportWidth: this.state.stageWidth,
|
||
viewportHeight: this.state.stageHeight,
|
||
visibleColumns: DESIRED_VISIBLE_COLUMNS,
|
||
rotationRad: this.getRotationRad(rotationDeg),
|
||
}
|
||
}
|
||
|
||
getWorldOffsetFromScreen(stageX: number, stageY: number, rotationDeg = this.state.rotationDeg): { x: number; y: number } {
|
||
const baseCamera = {
|
||
centerWorldX: 0,
|
||
centerWorldY: 0,
|
||
viewportWidth: this.state.stageWidth,
|
||
viewportHeight: this.state.stageHeight,
|
||
visibleColumns: DESIRED_VISIBLE_COLUMNS,
|
||
rotationRad: this.getRotationRad(rotationDeg),
|
||
}
|
||
|
||
return screenToWorld(baseCamera, { x: stageX, y: stageY }, false)
|
||
}
|
||
|
||
getExactCenterFromTranslate(translateX: number, translateY: number): { x: number; y: number } {
|
||
if (!this.state.stageWidth || !this.state.stageHeight) {
|
||
return {
|
||
x: this.state.centerTileX + 0.5,
|
||
y: this.state.centerTileY + 0.5,
|
||
}
|
||
}
|
||
|
||
const screenCenterX = this.state.stageWidth / 2
|
||
const screenCenterY = this.state.stageHeight / 2
|
||
return screenToWorld(this.getBaseCamera(), {
|
||
x: screenCenterX - translateX,
|
||
y: screenCenterY - translateY,
|
||
}, false)
|
||
}
|
||
|
||
resolveViewportForExactCenter(centerWorldX: number, centerWorldY: number, rotationDeg = this.state.rotationDeg): {
|
||
centerTileX: number
|
||
centerTileY: number
|
||
tileTranslateX: number
|
||
tileTranslateY: number
|
||
} {
|
||
const nextCenterTileX = Math.floor(centerWorldX)
|
||
const nextCenterTileY = Math.floor(centerWorldY)
|
||
|
||
if (!this.state.stageWidth || !this.state.stageHeight) {
|
||
return {
|
||
centerTileX: nextCenterTileX,
|
||
centerTileY: nextCenterTileY,
|
||
tileTranslateX: 0,
|
||
tileTranslateY: 0,
|
||
}
|
||
}
|
||
|
||
const roundedCamera = this.getBaseCamera(nextCenterTileX, nextCenterTileY, rotationDeg)
|
||
const projectedCenter = worldToScreen(roundedCamera, { x: centerWorldX, y: centerWorldY }, false)
|
||
|
||
return {
|
||
centerTileX: nextCenterTileX,
|
||
centerTileY: nextCenterTileY,
|
||
tileTranslateX: this.state.stageWidth / 2 - projectedCenter.x,
|
||
tileTranslateY: this.state.stageHeight / 2 - projectedCenter.y,
|
||
}
|
||
}
|
||
|
||
setPreviewState(scale: number, originX: number, originY: number): void {
|
||
this.previewScale = scale
|
||
this.previewOriginX = originX
|
||
this.previewOriginY = originY
|
||
this.setState({
|
||
previewScale: scale,
|
||
}, true)
|
||
}
|
||
|
||
resetPreviewState(): void {
|
||
this.setPreviewState(1, this.state.stageWidth / 2, this.state.stageHeight / 2)
|
||
}
|
||
|
||
resetPinchState(): void {
|
||
this.pinchStartDistance = 0
|
||
this.pinchStartScale = 1
|
||
this.pinchStartAngle = 0
|
||
this.pinchStartRotationDeg = this.state.rotationDeg
|
||
this.pinchAnchorWorldX = 0
|
||
this.pinchAnchorWorldY = 0
|
||
}
|
||
|
||
clearPreviewResetTimer(): void {
|
||
if (this.previewResetTimer) {
|
||
clearTimeout(this.previewResetTimer)
|
||
this.previewResetTimer = 0
|
||
}
|
||
}
|
||
|
||
clearInertiaTimer(): void {
|
||
if (this.inertiaTimer) {
|
||
clearTimeout(this.inertiaTimer)
|
||
this.inertiaTimer = 0
|
||
}
|
||
}
|
||
|
||
clearViewSyncTimer(): void {
|
||
if (this.viewSyncTimer) {
|
||
clearTimeout(this.viewSyncTimer)
|
||
this.viewSyncTimer = 0
|
||
}
|
||
}
|
||
|
||
clearAutoRotateTimer(): void {
|
||
if (this.autoRotateTimer) {
|
||
clearTimeout(this.autoRotateTimer)
|
||
this.autoRotateTimer = 0
|
||
}
|
||
}
|
||
|
||
clearCompassNeedleTimer(): void {
|
||
if (this.compassNeedleTimer) {
|
||
clearTimeout(this.compassNeedleTimer)
|
||
this.compassNeedleTimer = 0
|
||
}
|
||
}
|
||
|
||
clearCompassBootstrapRetryTimer(): void {
|
||
if (this.compassBootstrapRetryTimer) {
|
||
clearTimeout(this.compassBootstrapRetryTimer)
|
||
this.compassBootstrapRetryTimer = 0
|
||
}
|
||
}
|
||
|
||
scheduleCompassBootstrapRetry(): void {
|
||
this.clearCompassBootstrapRetryTimer()
|
||
if (!this.mounted) {
|
||
return
|
||
}
|
||
|
||
this.compassBootstrapRetryTimer = setTimeout(() => {
|
||
this.compassBootstrapRetryTimer = 0
|
||
if (!this.mounted || this.lastCompassSampleAt > 0) {
|
||
return
|
||
}
|
||
this.compassController.stop()
|
||
this.compassController.start()
|
||
}, COMPASS_BOOTSTRAP_RETRY_DELAY_MS) as unknown as number
|
||
}
|
||
|
||
syncCompassDisplayState(): void {
|
||
this.setState({
|
||
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
|
||
sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
|
||
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
||
...(this.diagnosticUiEnabled
|
||
? {
|
||
...this.getTelemetrySensorViewPatch(),
|
||
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||
autoRotateSourceText: this.getAutoRotateSourceText(),
|
||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||
}
|
||
: {}),
|
||
})
|
||
}
|
||
|
||
applyTelemetryPlayerProfile(profile?: PlayerTelemetryProfile | null): void {
|
||
this.telemetryPlayerProfile = profile ? { ...profile } : null
|
||
this.telemetryRuntime.setPlayerProfile(this.telemetryPlayerProfile)
|
||
this.setState(this.getGameViewPatch(), true)
|
||
}
|
||
|
||
applyCompiledTelemetryProfile(profile: RuntimeTelemetryProfile): void {
|
||
this.telemetryPlayerProfile = profile.playerProfile ? { ...profile.playerProfile } : null
|
||
this.telemetryRuntime.applyCompiledProfile(profile.config, this.telemetryPlayerProfile)
|
||
this.setState(this.getGameViewPatch(), true)
|
||
}
|
||
|
||
applyCompiledSettingsProfile(profile: RuntimeSettingsProfile): void {
|
||
const values = profile.values
|
||
this.handleSetAnimationLevel(values.animationLevel)
|
||
this.handleSetTrackMode(values.trackDisplayMode)
|
||
this.handleSetTrackTailLength(values.trackTailLength)
|
||
this.handleSetTrackColorPreset(values.trackColorPreset)
|
||
this.handleSetTrackStyleProfile(values.trackStyleProfile)
|
||
this.handleSetGpsMarkerVisible(values.gpsMarkerVisible)
|
||
this.handleSetGpsMarkerStyle(values.gpsMarkerStyle)
|
||
this.handleSetGpsMarkerSize(values.gpsMarkerSize)
|
||
this.handleSetGpsMarkerColorPreset(values.gpsMarkerColorPreset)
|
||
if (values.autoRotateEnabled) {
|
||
this.handleSetHeadingUpMode()
|
||
} else {
|
||
this.handleSetManualMode()
|
||
}
|
||
this.handleSetCompassTuningProfile(values.compassTuningProfile)
|
||
this.handleSetNorthReferenceMode(values.northReferenceMode)
|
||
}
|
||
|
||
applyCompiledMapProfile(profile: RuntimeMapProfile): void {
|
||
MAGNETIC_DECLINATION_DEG = profile.magneticDeclinationDeg
|
||
MAGNETIC_DECLINATION_TEXT = normalizeDegreeDisplayText(profile.magneticDeclinationText)
|
||
this.minZoom = profile.minZoom
|
||
this.maxZoom = profile.maxZoom
|
||
this.defaultZoom = profile.initialZoom
|
||
this.defaultCenterTileX = profile.initialCenterTileX
|
||
this.defaultCenterTileY = profile.initialCenterTileY
|
||
this.tileBoundsByZoom = profile.tileBoundsByZoom
|
||
this.cpRadiusMeters = profile.cpRadiusMeters
|
||
|
||
this.setState({
|
||
mapName: profile.title,
|
||
configStatusText: `配置已载入 / ${profile.title} / ${profile.courseStatusText}`,
|
||
projectionMode: profile.projectionModeText,
|
||
tileSource: profile.tileSource,
|
||
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
||
sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
|
||
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
|
||
}, true)
|
||
}
|
||
|
||
applyCompiledGameProfile(profile: RuntimeGameProfile): void {
|
||
this.gameMode = profile.mode
|
||
this.sessionCloseAfterMs = profile.sessionCloseAfterMs
|
||
this.sessionCloseWarningMs = profile.sessionCloseWarningMs
|
||
this.minCompletedControlsBeforeFinish = profile.minCompletedControlsBeforeFinish
|
||
this.punchPolicy = profile.punchPolicy
|
||
this.punchRadiusMeters = profile.punchRadiusMeters
|
||
this.requiresFocusSelection = profile.requiresFocusSelection
|
||
this.skipEnabled = profile.skipEnabled
|
||
this.skipRadiusMeters = profile.skipRadiusMeters
|
||
this.skipRequiresConfirm = profile.skipRequiresConfirm
|
||
this.autoFinishOnLastControl = profile.autoFinishOnLastControl
|
||
this.defaultControlScore = profile.defaultControlScore
|
||
|
||
const gameResult = this.loadGameDefinitionFromCourse()
|
||
const gameStatusText = gameResult ? this.resolveAppliedGameStatusText(gameResult) : null
|
||
this.setState(this.getGameViewPatch(gameStatusText), true)
|
||
}
|
||
|
||
applyCompiledFeedbackProfile(profile: RuntimeFeedbackProfile): void {
|
||
this.feedbackDirector.configure({
|
||
audioConfig: profile.audio,
|
||
hapticsConfig: profile.haptics,
|
||
uiEffectsConfig: profile.uiEffects,
|
||
})
|
||
}
|
||
|
||
applyCompiledPresentationProfile(profile: RuntimePresentationProfile): void {
|
||
this.courseStyleConfig = profile.course
|
||
this.trackStyleConfig = profile.track
|
||
this.gpsMarkerStyleConfig = profile.gpsMarker
|
||
this.setState({
|
||
trackDisplayMode: this.trackStyleConfig.mode,
|
||
trackTailLength: this.trackStyleConfig.tailLength,
|
||
trackColorPreset: this.trackStyleConfig.colorPreset,
|
||
trackStyleProfile: this.trackStyleConfig.style,
|
||
gpsMarkerVisible: this.gpsMarkerStyleConfig.visible,
|
||
gpsMarkerStyle: this.gpsMarkerStyleConfig.style,
|
||
gpsMarkerSize: this.gpsMarkerStyleConfig.size,
|
||
gpsMarkerColorPreset: this.gpsMarkerStyleConfig.colorPreset,
|
||
gpsLogoStatusText: this.gpsMarkerStyleConfig.logoUrl ? '等待渲染' : '未配置',
|
||
gpsLogoSourceText: this.gpsMarkerStyleConfig.logoUrl || '--',
|
||
}, true)
|
||
}
|
||
|
||
scheduleCompassNeedleFollow(): void {
|
||
if (
|
||
this.compassNeedleTimer
|
||
|| this.targetCompassDisplayHeadingDeg === null
|
||
|| this.compassDisplayHeadingDeg === null
|
||
) {
|
||
return
|
||
}
|
||
|
||
const step = () => {
|
||
this.compassNeedleTimer = 0
|
||
|
||
if (
|
||
this.targetCompassDisplayHeadingDeg === null
|
||
|| this.compassDisplayHeadingDeg === null
|
||
) {
|
||
return
|
||
}
|
||
|
||
const deltaDeg = normalizeAngleDeltaDeg(
|
||
this.targetCompassDisplayHeadingDeg - this.compassDisplayHeadingDeg,
|
||
)
|
||
const absDeltaDeg = Math.abs(deltaDeg)
|
||
|
||
if (absDeltaDeg <= COMPASS_NEEDLE_SNAP_DEG) {
|
||
if (absDeltaDeg > 0.001) {
|
||
this.compassDisplayHeadingDeg = this.targetCompassDisplayHeadingDeg
|
||
this.syncCompassDisplayState()
|
||
}
|
||
return
|
||
}
|
||
|
||
this.compassDisplayHeadingDeg = interpolateAngleDeg(
|
||
this.compassDisplayHeadingDeg,
|
||
this.targetCompassDisplayHeadingDeg,
|
||
getCompassNeedleSmoothingFactor(
|
||
this.compassDisplayHeadingDeg,
|
||
this.targetCompassDisplayHeadingDeg,
|
||
this.compassTuningProfile,
|
||
),
|
||
)
|
||
this.syncCompassDisplayState()
|
||
this.scheduleCompassNeedleFollow()
|
||
}
|
||
|
||
this.compassNeedleTimer = setTimeout(step, COMPASS_NEEDLE_FRAME_MS) as unknown as number
|
||
}
|
||
|
||
pickViewPatch(patch: Partial<MapEngineViewState>): Partial<MapEngineViewState> {
|
||
const viewPatch = {} as Partial<MapEngineViewState>
|
||
for (const key of VIEW_SYNC_KEYS) {
|
||
if (Object.prototype.hasOwnProperty.call(patch, key)) {
|
||
;(viewPatch as any)[key] = patch[key]
|
||
}
|
||
}
|
||
return viewPatch
|
||
}
|
||
|
||
flushViewPatch(): void {
|
||
if (!Object.keys(this.pendingViewPatch).length) {
|
||
return
|
||
}
|
||
|
||
const patch = this.pendingViewPatch
|
||
const shouldDeferForInteraction = this.gestureMode !== 'idle' || !!this.inertiaTimer || !!this.previewResetTimer
|
||
const nextPendingPatch = {} as Partial<MapEngineViewState>
|
||
const outputPatch = {} as Partial<MapEngineViewState>
|
||
|
||
for (const [key, value] of Object.entries(patch) as Array<[keyof MapEngineViewState, MapEngineViewState[keyof MapEngineViewState]]>) {
|
||
if (shouldDeferForInteraction && INTERACTION_DEFERRED_VIEW_KEYS.has(key)) {
|
||
;(nextPendingPatch as Record<string, unknown>)[key] = value
|
||
continue
|
||
}
|
||
;(outputPatch as Record<string, unknown>)[key] = value
|
||
}
|
||
|
||
this.pendingViewPatch = nextPendingPatch
|
||
|
||
if (Object.keys(this.pendingViewPatch).length && !this.viewSyncTimer) {
|
||
this.viewSyncTimer = setTimeout(() => {
|
||
this.viewSyncTimer = 0
|
||
this.flushViewPatch()
|
||
}, UI_SYNC_INTERVAL_MS) as unknown as number
|
||
}
|
||
|
||
if (!Object.keys(outputPatch).length) {
|
||
return
|
||
}
|
||
|
||
this.onData(outputPatch)
|
||
}
|
||
|
||
getTouchDistance(touches: TouchPoint[]): number {
|
||
if (touches.length < 2) {
|
||
return 0
|
||
}
|
||
|
||
const first = touches[0]
|
||
const second = touches[1]
|
||
const deltaX = first.pageX - second.pageX
|
||
const deltaY = first.pageY - second.pageY
|
||
return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||
}
|
||
|
||
getTouchAngle(touches: TouchPoint[]): number {
|
||
if (touches.length < 2) {
|
||
return 0
|
||
}
|
||
|
||
const first = touches[0]
|
||
const second = touches[1]
|
||
return Math.atan2(second.pageY - first.pageY, second.pageX - first.pageX)
|
||
}
|
||
|
||
getStagePoint(touches: TouchPoint[]): { x: number; y: number } {
|
||
if (!touches.length) {
|
||
return {
|
||
x: this.state.stageWidth / 2,
|
||
y: this.state.stageHeight / 2,
|
||
}
|
||
}
|
||
|
||
let pageX = 0
|
||
let pageY = 0
|
||
for (const touch of touches) {
|
||
pageX += touch.pageX
|
||
pageY += touch.pageY
|
||
}
|
||
|
||
return {
|
||
x: pageX / touches.length - this.state.stageLeft,
|
||
y: pageY / touches.length - this.state.stageTop,
|
||
}
|
||
}
|
||
|
||
animatePreviewToRest(): void {
|
||
this.clearPreviewResetTimer()
|
||
|
||
const startScale = this.previewScale || 1
|
||
const originX = this.previewOriginX || this.state.stageWidth / 2
|
||
const originY = this.previewOriginY || this.state.stageHeight / 2
|
||
|
||
if (Math.abs(startScale - 1) < 0.01) {
|
||
this.resetPreviewState()
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
this.scheduleAutoRotate()
|
||
return
|
||
}
|
||
|
||
const startAt = Date.now()
|
||
const step = () => {
|
||
const progress = Math.min(1, (Date.now() - startAt) / PREVIEW_RESET_DURATION_MS)
|
||
const eased = 1 - Math.pow(1 - progress, 3)
|
||
const nextScale = startScale + (1 - startScale) * eased
|
||
this.setPreviewState(nextScale, originX, originY)
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
|
||
if (progress >= 1) {
|
||
this.resetPreviewState()
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
this.previewResetTimer = 0
|
||
this.scheduleAutoRotate()
|
||
return
|
||
}
|
||
|
||
this.previewResetTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
|
||
}
|
||
|
||
step()
|
||
}
|
||
|
||
normalizeTranslate(translateX: number, translateY: number, statusText: string): void {
|
||
if (!this.state.stageWidth) {
|
||
this.setState({
|
||
tileTranslateX: translateX,
|
||
tileTranslateY: translateY,
|
||
})
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
return
|
||
}
|
||
|
||
const exactCenter = this.getExactCenterFromTranslate(translateX, translateY)
|
||
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y)
|
||
const centerChanged = resolvedViewport.centerTileX !== this.state.centerTileX || resolvedViewport.centerTileY !== this.state.centerTileY
|
||
|
||
if (centerChanged) {
|
||
this.commitViewport(resolvedViewport, statusText)
|
||
return
|
||
}
|
||
|
||
this.setState({
|
||
tileTranslateX: resolvedViewport.tileTranslateX,
|
||
tileTranslateY: resolvedViewport.tileTranslateY,
|
||
})
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
}
|
||
|
||
zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void {
|
||
const nextZoom = clamp(this.state.zoom + zoomDelta, this.minZoom, this.maxZoom)
|
||
const appliedDelta = nextZoom - this.state.zoom
|
||
if (!appliedDelta) {
|
||
this.animatePreviewToRest()
|
||
return
|
||
}
|
||
|
||
if (this.gpsLockEnabled && this.currentGpsPoint) {
|
||
const nextGpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, nextZoom)
|
||
const resolvedViewport = this.resolveViewportForExactCenter(nextGpsWorldPoint.x, nextGpsWorldPoint.y)
|
||
this.commitViewport(
|
||
{
|
||
zoom: nextZoom,
|
||
...resolvedViewport,
|
||
},
|
||
`缩放级别调整到 ${nextZoom}`,
|
||
true,
|
||
() => {
|
||
this.setPreviewState(residualScale, this.state.stageWidth / 2, this.state.stageHeight / 2)
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
this.animatePreviewToRest()
|
||
},
|
||
)
|
||
return
|
||
}
|
||
|
||
if (!this.state.stageWidth || !this.state.stageHeight) {
|
||
this.commitViewport(
|
||
{
|
||
zoom: nextZoom,
|
||
centerTileX: appliedDelta > 0 ? this.state.centerTileX * 2 : Math.floor(this.state.centerTileX / 2),
|
||
centerTileY: appliedDelta > 0 ? this.state.centerTileY * 2 : Math.floor(this.state.centerTileY / 2),
|
||
tileTranslateX: 0,
|
||
tileTranslateY: 0,
|
||
},
|
||
`缩放级别调整到 ${nextZoom}`,
|
||
true,
|
||
() => {
|
||
this.setPreviewState(residualScale, stageX, stageY)
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
this.animatePreviewToRest()
|
||
},
|
||
)
|
||
return
|
||
}
|
||
|
||
const camera = this.getCameraState()
|
||
const world = screenToWorld(camera, { x: stageX, y: stageY }, true)
|
||
const zoomFactor = Math.pow(2, appliedDelta)
|
||
const nextWorldX = world.x * zoomFactor
|
||
const nextWorldY = world.y * zoomFactor
|
||
const anchorOffset = this.getWorldOffsetFromScreen(stageX, stageY)
|
||
const exactCenterX = nextWorldX - anchorOffset.x
|
||
const exactCenterY = nextWorldY - anchorOffset.y
|
||
const resolvedViewport = this.resolveViewportForExactCenter(exactCenterX, exactCenterY)
|
||
|
||
this.commitViewport(
|
||
{
|
||
zoom: nextZoom,
|
||
...resolvedViewport,
|
||
},
|
||
`缩放级别调整到 ${nextZoom}`,
|
||
true,
|
||
() => {
|
||
this.setPreviewState(residualScale, stageX, stageY)
|
||
this.syncRenderer()
|
||
this.compassController.start()
|
||
this.animatePreviewToRest()
|
||
},
|
||
)
|
||
}
|
||
|
||
startInertia(): void {
|
||
this.clearInertiaTimer()
|
||
|
||
const step = () => {
|
||
this.panVelocityX *= INERTIA_DECAY
|
||
this.panVelocityY *= INERTIA_DECAY
|
||
|
||
if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
|
||
this.setState({
|
||
statusText: `惯性滑动结束 (${this.buildVersion})`,
|
||
})
|
||
this.renderer.setAnimationPaused(false)
|
||
this.inertiaTimer = 0
|
||
this.scheduleAutoRotate()
|
||
return
|
||
}
|
||
|
||
this.normalizeTranslate(
|
||
this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
|
||
this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS,
|
||
`惯性滑动中 (${this.buildVersion})`,
|
||
)
|
||
|
||
this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
|
||
}
|
||
|
||
this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|