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 = { 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) => 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 = [ '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([ '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 = { 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 = { 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) => 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 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 | 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 controlScoreOverrides: Record controlContentOverrides: Record defaultControlContentOverride: GameControlDisplayContentOverride | null defaultControlPointStyleOverride: ControlPointStyleEntry | null controlPointStyleOverrides: Record defaultLegStyleOverride: CourseLegStyleEntry | null legStyleOverrides: Record 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 consumedContentQuizKeys: Record rewardedContentQuizKeys: Record 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.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: '活动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 : 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 { 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 { 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 { const debugState = this.mockSimulatorDebugLogger.getState() return { mockDebugLogBridgeConnected: debugState.connected, mockDebugLogBridgeStatusText: debugState.statusText, mockDebugLogBridgeUrlText: debugState.url, } } getTelemetrySensorViewPatch(): Partial { 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 | 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 { const telemetryPresentation = this.telemetryRuntime.getPresentation() const patch: Partial = { 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 = {}, 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() } 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.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 = { 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, 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, 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 = {} const controlStyleOverridesBySequence: Record = {} 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): Partial { const viewPatch = {} as Partial 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 const outputPatch = {} as Partial 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)[key] = value continue } ;(outputPatch as Record)[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 } }