import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
import { AccelerometerController } from '../sensor/accelerometerController'
import { CompassHeadingController, type CompassTuningProfile } from '../sensor/compassHeadingController'
import { DeviceMotionController } from '../sensor/deviceMotionController'
import { MockSimulatorDebugLogger } from '../debug/mockSimulatorDebugLogger'
import { GyroscopeController } from '../sensor/gyroscopeController'
import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
import { HeartRateInputController } from '../sensor/heartRateInputController'
import { LocationController } from '../sensor/locationController'
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
import { type MapRendererStats } from '../renderer/mapRenderer'
import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel'
import { GameRuntime } from '../../game/core/gameRuntime'
import { type GameControl, type GameControlDisplayContentOverride } from '../../game/core/gameDefinition'
import {
  buildDefaultContentCardCtaLabel,
  buildDefaultContentCardQuizConfig,
  type ContentCardActionViewModel,
  type ContentCardCtaConfig,
  type ContentCardQuizConfig,
  type ContentCardTemplate,
} from '../../game/experience/contentCard'
import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
import { type GameEffect, type GameResult } from '../../game/core/gameResult'
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
import { getDefaultSkipRadiusMeters, getGameModeDefaults } from '../../game/core/gameModeDefaults'
import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
import { DEFAULT_COURSE_STYLE_CONFIG, type ControlPointStyleEntry, type CourseLegStyleEntry, type CourseStyleConfig } from '../../game/presentation/courseStyleConfig'
import {
  DEFAULT_TRACK_VISUALIZATION_CONFIG,
  TRACK_COLOR_PRESET_MAP,
  TRACK_TAIL_LENGTH_METERS,
  type TrackColorPreset,
  type TrackDisplayMode,
  type TrackStyleProfile,
  type TrackTailLengthPreset,
  type TrackVisualizationConfig,
} from '../../game/presentation/trackStyleConfig'
import {
  DEFAULT_GPS_MARKER_STYLE_CONFIG,
  GPS_MARKER_COLOR_PRESET_MAP,
  type GpsMarkerColorPreset,
  type GpsMarkerSizePreset,
  type GpsMarkerStyleId,
  type GpsMarkerStyleConfig,
} from '../../game/presentation/gpsMarkerStyleConfig'
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
import { buildResultSummarySnapshot, type ResultSummarySnapshot } from '../../game/result/resultSummary'
import { TelemetryRuntime } from '../../game/telemetry/telemetryRuntime'
import { getHeartRateToneSampleBpm, type HeartRateTone } from '../../game/telemetry/telemetryConfig'
import { type PlayerTelemetryProfile } from '../../game/telemetry/playerTelemetryProfile'
import {
  type RuntimeMapProfile,
  type RuntimeGameProfile,
  type RuntimeFeedbackProfile,
  type RuntimePresentationProfile,
  type RuntimeSettingsProfile,
  type RuntimeTelemetryProfile,
} from '../../game/core/runtimeProfileCompiler'
import {
  type RecoveryRuntimeSnapshot,
} from '../../game/core/sessionRecovery'

const RENDER_MODE = 'Single WebGL Pipeline'
const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
const MAP_NORTH_OFFSET_DEG = 0
let MAGNETIC_DECLINATION_DEG = -6.91
let MAGNETIC_DECLINATION_TEXT = '6.91˚ W'
const MIN_ZOOM = 15
const MAX_ZOOM = 20
const DEFAULT_ZOOM = 17
const DESIRED_VISIBLE_COLUMNS = 3
const OVERDRAW = 1
const DEFAULT_TOP_LEFT_TILE_X = 108132
const DEFAULT_TOP_LEFT_TILE_Y = 51199
const DEFAULT_CENTER_TILE_X = DEFAULT_TOP_LEFT_TILE_X + 1
const DEFAULT_CENTER_TILE_Y = DEFAULT_TOP_LEFT_TILE_Y + 1
const TILE_SOURCE = 'https://oss-mbh5.colormaprun.com/wxMap/lcx/{z}/{x}/{y}.png'
const OSM_TILE_SOURCE = 'https://tiles.mymarsgo.xyz/{z}/{x}/{y}.png'
const MAP_OVERLAY_OPACITY = 0.72
const GPS_MAP_CALIBRATION: MapCalibration = {
  offsetEastMeters: 0,
  offsetNorthMeters: 0,
  rotationDeg: 0,
  scale: 1,
}
const MIN_PREVIEW_SCALE = 0.55
const MAX_PREVIEW_SCALE = 1.85
const INERTIA_FRAME_MS = 16
const INERTIA_DECAY = 0.92
const INERTIA_MIN_SPEED = 0.02
const PREVIEW_RESET_DURATION_MS = 140
const UI_SYNC_INTERVAL_MS = 80
const ROTATE_STEP_DEG = 15
const AUTO_ROTATE_FRAME_MS = 8
const AUTO_ROTATE_EASE = 0.34
const AUTO_ROTATE_SNAP_DEG = 0.1
const AUTO_ROTATE_DEADZONE_DEG = 4
const AUTO_ROTATE_MAX_STEP_DEG = 0.75
const AUTO_ROTATE_HEADING_SMOOTHING = 0.46
const COMPASS_NEEDLE_FRAME_MS = 16
const COMPASS_NEEDLE_SNAP_DEG = 0.08
const COMPASS_BOOTSTRAP_RETRY_DELAY_MS = 700
const COMPASS_TUNING_PRESETS: Record<CompassTuningProfile, {
  needleMinSmoothing: number
  needleMaxSmoothing: number
  displayDeadzoneDeg: number
}> = {
  smooth: {
    needleMinSmoothing: 0.16,
    needleMaxSmoothing: 0.4,
    displayDeadzoneDeg: 0.75,
  },
  balanced: {
    needleMinSmoothing: 0.22,
    needleMaxSmoothing: 0.52,
    displayDeadzoneDeg: 0.45,
  },
  responsive: {
    needleMinSmoothing: 0.3,
    needleMaxSmoothing: 0.68,
    displayDeadzoneDeg: 0.2,
  },
}
const SMART_HEADING_BLEND_START_SPEED_KMH = 1.2
const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0
const SMART_HEADING_MIN_DISTANCE_METERS = 12
const SMART_HEADING_MAX_ACCURACY_METERS = 25
const SMART_HEADING_MOVEMENT_MIN_SMOOTHING = 0.12
const SMART_HEADING_MOVEMENT_MAX_SMOOTHING = 0.24
const GPS_TRACK_MAX_POINTS = 200
const GPS_TRACK_MIN_STEP_METERS = 3
const MAP_TAP_MOVE_THRESHOLD_PX = 14
const MAP_TAP_DURATION_MS = 280

function clampNumber(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value))
}

function hexToRgb(hex: string): { r: number; g: number; b: number } {
  const normalized = hex.replace('#', '')
  const full = normalized.length === 3
    ? normalized.split('').map((segment) => segment + segment).join('')
    : normalized.padEnd(6, '0').slice(0, 6)
  const parsed = Number.parseInt(full, 16)
  return {
    r: (parsed >> 16) & 0xff,
    g: (parsed >> 8) & 0xff,
    b: parsed & 0xff,
  }
}

function rgbToHex(r: number, g: number, b: number): string {
  const toHex = (value: number) => clampNumber(Math.round(value), 0, 255).toString(16).padStart(2, '0')
  return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}

function mixHexColor(fromHex: string, toHex: string, amount: number): string {
  const from = hexToRgb(fromHex)
  const to = hexToRgb(toHex)
  const factor = clampNumber(amount, 0, 1)
  return rgbToHex(
    from.r + (to.r - from.r) * factor,
    from.g + (to.g - from.g) * factor,
    from.b + (to.b - from.b) * factor,
  )
}

type TouchPoint = WechatMiniprogram.TouchDetail

type GestureMode = 'idle' | 'pan' | 'pinch'
type RotationMode = 'manual' | 'auto'
type OrientationMode = 'manual' | 'north-up' | 'heading-up'
type AutoRotateSourceMode = 'sensor' | 'course' | 'fusion' | 'smart'
type SmartHeadingSource = 'sensor' | 'blended' | 'movement'
type NorthReferenceMode = 'magnetic' | 'true'

const DEFAULT_NORTH_REFERENCE_MODE: NorthReferenceMode = 'magnetic'

export interface MapEngineStageRect {
  width: number
  height: number
  left: number
  top: number
}

export interface MapEngineViewState {
  animationLevel: AnimationLevel
  buildVersion: string
  renderMode: string
  projectionMode: string
  mapReady: boolean
  mapReadyText: string
  mapName: string
  configStatusText: string
  zoom: number
  rotationDeg: number
  rotationText: string
  rotationMode: RotationMode
  rotationModeText: string
  rotationToggleText: string
  orientationMode: OrientationMode
  orientationModeText: string
  sensorHeadingText: string
  deviceHeadingText: string
  devicePoseText: string
  headingConfidenceText: string
  accelerometerText: string
  gyroscopeText: string
  deviceMotionText: string
  compassSourceText: string
  compassTuningProfile: CompassTuningProfile
  compassTuningProfileText: string
  compassDeclinationText: string
  northReferenceMode: NorthReferenceMode
  northReferenceButtonText: string
  autoRotateSourceText: string
  autoRotateCalibrationText: string
  northReferenceText: string
  compassNeedleDeg: number
  centerTileX: number
  centerTileY: number
  centerText: string
  tileSource: string
  visibleColumnCount: number
  visibleTileCount: number
  readyTileCount: number
  memoryTileCount: number
  diskTileCount: number
  memoryHitCount: number
  diskHitCount: number
  networkFetchCount: number
  cacheHitRateText: string
  tileTranslateX: number
  tileTranslateY: number
  tileSizePx: number
  previewScale: number
  stageWidth: number
  stageHeight: number
  stageLeft: number
  stageTop: number
  statusText: string
  gpsTracking: boolean
  gpsTrackingText: string
  gpsLockEnabled: boolean
  gpsLockAvailable: boolean
  locationSourceMode: 'real' | 'mock'
  locationSourceText: string
  mockBridgeConnected: boolean
  mockBridgeStatusText: string
  mockBridgeUrlText: string
  mockChannelIdText: string
  mockCoordText: string
  mockSpeedText: string
  gpsCoordText: string
  heartRateSourceMode: 'real' | 'mock'
  heartRateSourceText: string
  heartRateConnected: boolean
  heartRateStatusText: string
  heartRateDeviceText: string
  heartRateScanText: string
  heartRateDiscoveredDevices: Array<{
    deviceId: string
    name: string
    rssiText: string
    preferred: boolean
    connected: boolean
  }>
  mockHeartRateBridgeConnected: boolean
  mockHeartRateBridgeStatusText: string
  mockHeartRateBridgeUrlText: string
  mockHeartRateText: string
  mockDebugLogBridgeConnected: boolean
  mockDebugLogBridgeStatusText: string
  mockDebugLogBridgeUrlText: string
  gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
  gameModeText: string
  panelTimerText: string
  panelTimerMode: 'elapsed' | 'countdown'
  panelMileageText: string
  panelActionTagText: string
  panelDistanceTagText: string
  panelTargetSummaryText: string
  panelDistanceValueText: string
  panelDistanceUnitText: string
  panelProgressText: string
  panelSpeedValueText: string
  panelTelemetryTone: 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
  trackDisplayMode: TrackDisplayMode
  trackTailLength: TrackTailLengthPreset
  trackColorPreset: TrackColorPreset
  trackStyleProfile: TrackStyleProfile
  gpsMarkerVisible: boolean
  gpsMarkerStyle: GpsMarkerStyleId
  gpsMarkerSize: GpsMarkerSizePreset
  gpsMarkerColorPreset: GpsMarkerColorPreset
  gpsLogoStatusText: string
  gpsLogoSourceText: string
  panelHeartRateZoneNameText: string
  panelHeartRateZoneRangeText: string
  panelHeartRateValueText: string
  panelHeartRateUnitText: string
  panelCaloriesValueText: string
  panelCaloriesUnitText: string
  panelAverageSpeedValueText: string
  panelAverageSpeedUnitText: string
  panelAccuracyValueText: string
  panelAccuracyUnitText: string
  punchButtonText: string
  punchButtonEnabled: boolean
  skipButtonEnabled: boolean
  punchHintText: string
  punchFeedbackVisible: boolean
  punchFeedbackText: string
  punchFeedbackTone: 'neutral' | 'success' | 'warning'
  contentCardVisible: boolean
  contentCardTemplate: ContentCardTemplate
  contentCardTitle: string
  contentCardBody: string
  contentCardActions: ContentCardActionViewModel[]
  contentQuizVisible: boolean
  contentQuizQuestionText: string
  contentQuizCountdownText: string
  contentQuizOptions: ContentCardQuizOptionViewModel[]
  contentQuizFeedbackVisible: boolean
  contentQuizFeedbackText: string
  contentQuizFeedbackTone: 'success' | 'error' | 'neutral'
  pendingContentEntryVisible: boolean
  pendingContentEntryText: string
  punchButtonFxClass: string
  panelProgressFxClass: string
  panelDistanceFxClass: string
  punchFeedbackFxClass: string
  contentCardFxClass: string
  mapPulseVisible: boolean
  mapPulseLeftPx: number
  mapPulseTopPx: number
  mapPulseFxClass: string
  stageFxVisible: boolean
  stageFxClass: string
  osmReferenceEnabled: boolean
  osmReferenceText: string
}

export interface MapEngineCallbacks {
  onData: (patch: Partial<MapEngineViewState>) => void
  onOpenH5Experience?: (request: H5ExperienceRequest) => void
}

interface GpsTrackSample {
  point: LonLatPoint
  at: number
}

interface ContentCardEntry {
  template: ContentCardTemplate
  title: string
  body: string
  motionClass: string
  contentKey: string
  once: boolean
  priority: number
  autoPopup: boolean
  ctas: ContentCardCtaConfig[]
  h5Request: H5ExperienceRequest | null
}

export interface ContentCardQuizOptionViewModel {
  key: string
  label: string
}

export interface MapEngineGameInfoRow {
  label: string
  value: string
}

export interface MapEngineGameInfoSnapshot {
  title: string
  subtitle: string
  localRows: MapEngineGameInfoRow[]
  globalRows: MapEngineGameInfoRow[]
}

export type MapEngineResultSnapshot = ResultSummarySnapshot

export interface MapEngineSessionFinishSummary {
  status: 'finished' | 'failed' | 'cancelled'
  finalDurationSec?: number
  finalScore?: number
  completedControls?: number
  totalControls?: number
  distanceMeters?: number
  averageSpeedKmh?: number
}

const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
  'animationLevel',
  'buildVersion',
  'renderMode',
  'projectionMode',
  'mapReady',
  'mapReadyText',
  'mapName',
  'configStatusText',
  'zoom',
  'centerTileX',
  'centerTileY',
  'rotationDeg',
  'rotationText',
  'rotationMode',
  'rotationModeText',
  'rotationToggleText',
  'orientationMode',
  'orientationModeText',
  'sensorHeadingText',
  'deviceHeadingText',
  'devicePoseText',
  'headingConfidenceText',
  'accelerometerText',
  'gyroscopeText',
  'deviceMotionText',
  'compassSourceText',
  'compassTuningProfile',
  'compassTuningProfileText',
  'compassDeclinationText',
  'northReferenceMode',
  'northReferenceButtonText',
  'autoRotateSourceText',
  'autoRotateCalibrationText',
  'northReferenceText',
  'compassNeedleDeg',
  'centerText',
  'tileSource',
  'visibleTileCount',
  'readyTileCount',
  'memoryTileCount',
  'diskTileCount',
  'memoryHitCount',
  'diskHitCount',
  'networkFetchCount',
  'cacheHitRateText',
  'tileSizePx',
  'previewScale',
  'stageWidth',
  'stageHeight',
  'stageLeft',
  'stageTop',
  'statusText',
  'gpsTracking',
  'gpsTrackingText',
  'gpsLockEnabled',
  'gpsLockAvailable',
  'locationSourceMode',
  'locationSourceText',
  'mockBridgeConnected',
  'mockBridgeStatusText',
  'mockBridgeUrlText',
  'mockChannelIdText',
  'mockCoordText',
  'mockSpeedText',
  'gpsCoordText',
  'heartRateSourceMode',
  'heartRateSourceText',
  'heartRateConnected',
  'heartRateStatusText',
  'heartRateDeviceText',
  'heartRateScanText',
  'heartRateDiscoveredDevices',
  'mockHeartRateBridgeConnected',
  'mockHeartRateBridgeStatusText',
  'mockHeartRateBridgeUrlText',
  'mockHeartRateText',
  'mockDebugLogBridgeConnected',
  'mockDebugLogBridgeStatusText',
  'mockDebugLogBridgeUrlText',
  'gameSessionStatus',
  'gameModeText',
  'panelTimerText',
  'panelTimerMode',
  'panelMileageText',
  'panelActionTagText',
  'panelDistanceTagText',
  'panelTargetSummaryText',
  'panelDistanceValueText',
  'panelDistanceUnitText',
  'panelProgressText',
  'panelSpeedValueText',
  'panelTelemetryTone',
  'trackDisplayMode',
  'trackTailLength',
  'trackColorPreset',
  'trackStyleProfile',
  'gpsMarkerVisible',
  'gpsMarkerStyle',
  'gpsMarkerSize',
  'gpsMarkerColorPreset',
  'gpsLogoStatusText',
  'gpsLogoSourceText',
  'panelHeartRateZoneNameText',
  'panelHeartRateZoneRangeText',
  'panelHeartRateValueText',
  'panelHeartRateUnitText',
  'panelCaloriesValueText',
  'panelCaloriesUnitText',
  'panelAverageSpeedValueText',
  'panelAverageSpeedUnitText',
  'panelAccuracyValueText',
  'panelAccuracyUnitText',
  'punchButtonText',
  'punchButtonEnabled',
  'skipButtonEnabled',
  'punchHintText',
  'punchFeedbackVisible',
  'punchFeedbackText',
  'punchFeedbackTone',
  'contentCardVisible',
  'contentCardTemplate',
  'contentCardTitle',
  'contentCardBody',
  'contentCardActions',
  'contentQuizVisible',
  'contentQuizQuestionText',
  'contentQuizCountdownText',
  'contentQuizOptions',
  'contentQuizFeedbackVisible',
  'contentQuizFeedbackText',
  'contentQuizFeedbackTone',
  'pendingContentEntryVisible',
  'pendingContentEntryText',
  'punchButtonFxClass',
  'panelProgressFxClass',
  'panelDistanceFxClass',
  'punchFeedbackFxClass',
  'contentCardFxClass',
  'mapPulseVisible',
  'mapPulseLeftPx',
  'mapPulseTopPx',
  'mapPulseFxClass',
  'stageFxVisible',
  'stageFxClass',
  'osmReferenceEnabled',
  'osmReferenceText',
]

const INTERACTION_DEFERRED_VIEW_KEYS = new Set<keyof MapEngineViewState>([
  'rotationText',
  'sensorHeadingText',
  'deviceHeadingText',
  'devicePoseText',
  'headingConfidenceText',
  'accelerometerText',
  'gyroscopeText',
  'deviceMotionText',
  'compassSourceText',
  'compassTuningProfile',
  'compassTuningProfileText',
  'compassDeclinationText',
  'autoRotateSourceText',
  'autoRotateCalibrationText',
  'northReferenceText',
  'centerText',
  'gpsCoordText',
  'visibleTileCount',
  'readyTileCount',
  'memoryTileCount',
  'diskTileCount',
  'memoryHitCount',
  'diskHitCount',
  'networkFetchCount',
  'cacheHitRateText',
  'heartRateDiscoveredDevices',
  'mockCoordText',
  'mockSpeedText',
  'mockHeartRateText',
])

function buildCenterText(zoom: number, x: number, y: number): string {
  return `z${zoom} / x${x} / y${y}`
}

function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value))
}

function normalizeRotationDeg(rotationDeg: number): number {
  const normalized = rotationDeg % 360
  return normalized < 0 ? normalized + 360 : normalized
}

function normalizeAngleDeltaRad(angleDeltaRad: number): number {
  let normalized = angleDeltaRad

  while (normalized > Math.PI) {
    normalized -= Math.PI * 2
  }

  while (normalized < -Math.PI) {
    normalized += Math.PI * 2
  }

  return normalized
}

function normalizeAngleDeltaDeg(angleDeltaDeg: number): number {
  let normalized = angleDeltaDeg

  while (normalized > 180) {
    normalized -= 360
  }

  while (normalized < -180) {
    normalized += 360
  }

  return normalized
}

function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: number): number {
  return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
}

function getCompassNeedleSmoothingFactor(
  currentDeg: number,
  targetDeg: number,
  profile: CompassTuningProfile,
): number {
  const preset = COMPASS_TUNING_PRESETS[profile]
  const deltaDeg = Math.abs(normalizeAngleDeltaDeg(targetDeg - currentDeg))
  if (deltaDeg <= 4) {
    return preset.needleMinSmoothing
  }
  if (deltaDeg >= 36) {
    return preset.needleMaxSmoothing
  }

  const progress = (deltaDeg - 4) / (36 - 4)
  return preset.needleMinSmoothing
    + (preset.needleMaxSmoothing - preset.needleMinSmoothing) * progress
}

function getMovementHeadingSmoothingFactor(speedKmh: number | null): number {
  if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
    return SMART_HEADING_MOVEMENT_MIN_SMOOTHING
  }

  if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
    return SMART_HEADING_MOVEMENT_MAX_SMOOTHING
  }

  const progress = (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH)
    / (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH)
  return SMART_HEADING_MOVEMENT_MIN_SMOOTHING
    + (SMART_HEADING_MOVEMENT_MAX_SMOOTHING - SMART_HEADING_MOVEMENT_MIN_SMOOTHING) * progress
}

function formatGameSessionStatusText(status: 'idle' | 'running' | 'finished' | 'failed'): string {
  if (status === 'running') {
    return '进行中'
  }
  if (status === 'finished') {
    return '已结束'
  }
  if (status === 'failed') {
    return '已失败'
  }
  return '未开始'
}

function formatRotationText(rotationDeg: number): string {
  return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
}

function normalizeDegreeDisplayText(text: string): string {
  return text.replace(/[掳•˚]/g, '°')
}

function formatHeadingText(headingDeg: number | null): string {
  if (headingDeg === null) {
    return '--'
  }

  return `${Math.round(normalizeRotationDeg(headingDeg))}°`
}

function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string {
  if (pose === 'flat') {
    return '平放'
  }

  if (pose === 'tilted') {
    return '倾斜'
  }

  return '竖持'
}

function formatHeadingConfidenceText(confidence: 'low' | 'medium' | 'high'): string {
  if (confidence === 'high') {
    return '高'
  }

  if (confidence === 'medium') {
    return '中'
  }

  return '低'
}

function formatClockTime(timestamp: number | null): string {
  if (!timestamp || !Number.isFinite(timestamp)) {
    return '--:--:--'
  }

  const date = new Date(timestamp)
  const hh = String(date.getHours()).padStart(2, '0')
  const mm = String(date.getMinutes()).padStart(2, '0')
  const ss = String(date.getSeconds()).padStart(2, '0')
  return `${hh}:${mm}:${ss}`
}

function formatGyroscopeText(gyroscope: { x: number; y: number; z: number } | null): string {
  if (!gyroscope) {
    return '--'
  }

  return `x:${gyroscope.x.toFixed(2)} y:${gyroscope.y.toFixed(2)} z:${gyroscope.z.toFixed(2)}`
}

function formatDeviceMotionText(motion: { alpha: number | null; beta: number | null; gamma: number | null } | null): string {
  if (!motion) {
    return '--'
  }

  const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha))
  const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta)
  const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma)
  return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}`
}

function formatOrientationModeText(mode: OrientationMode): string {
  if (mode === 'north-up') {
    return 'North Up'
  }

  if (mode === 'heading-up') {
    return 'Heading Up'
  }

  return 'Manual Gesture'
}

function formatRotationModeText(mode: OrientationMode): string {
  return formatOrientationModeText(mode)
}

function formatRotationToggleText(mode: OrientationMode): string {
  if (mode === 'manual') {
    return '切到北朝上'
  }

  if (mode === 'north-up') {
    return '切到朝向朝上'
  }

  return '切到手动旋转'
}

function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
  if (mode === 'smart') {
    return 'Smart / 手机朝向'
  }

  if (mode === 'sensor') {
    return 'Sensor Only'
  }

  if (mode === 'course') {
    return hasCourseHeading ? 'Course Only' : 'Course Pending'
  }

  return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
}

function formatSmartHeadingSourceText(source: SmartHeadingSource): string {
  if (source === 'movement') {
    return 'Smart / 前进方向'
  }

  if (source === 'blended') {
    return 'Smart / 融合'
  }

  return 'Smart / 手机朝向'
}

function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string {
  if (pending) {
    return 'Pending'
  }

  if (offsetDeg === null) {
    return '--'
  }

  return `Offset ${Math.round(normalizeRotationDeg(offsetDeg))}deg`
}

function getTrueHeadingDeg(magneticHeadingDeg: number): number {
  return normalizeRotationDeg(magneticHeadingDeg + MAGNETIC_DECLINATION_DEG)
}

function getMagneticHeadingDeg(trueHeadingDeg: number): number {
  return normalizeRotationDeg(trueHeadingDeg - MAGNETIC_DECLINATION_DEG)
}

function getMapNorthOffsetDeg(_mode: NorthReferenceMode): number {
  return MAP_NORTH_OFFSET_DEG
}

function getCompassReferenceHeadingDeg(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
  if (mode === 'true') {
    return getTrueHeadingDeg(magneticHeadingDeg)
  }

  return normalizeRotationDeg(magneticHeadingDeg)
}

function getMapReferenceHeadingDegFromSensor(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
  if (mode === 'magnetic') {
    return normalizeRotationDeg(magneticHeadingDeg)
  }

  return getTrueHeadingDeg(magneticHeadingDeg)
}

function getMapReferenceHeadingDegFromCourse(mode: NorthReferenceMode, trueHeadingDeg: number): number {
  if (mode === 'magnetic') {
    return getMagneticHeadingDeg(trueHeadingDeg)
  }

  return normalizeRotationDeg(trueHeadingDeg)
}

function formatNorthReferenceText(mode: NorthReferenceMode): string {
  if (mode === 'magnetic') {
    return `Compass Magnetic / Heading-Up Magnetic (${MAGNETIC_DECLINATION_TEXT})`
  }

  return `Compass True / Heading-Up True (${MAGNETIC_DECLINATION_TEXT})`
}

function formatCompassDeclinationText(mode: NorthReferenceMode): string {
  if (mode === 'true') {
    return MAGNETIC_DECLINATION_TEXT
  }

  return ''
}

function formatCompassSourceText(source: 'compass' | 'motion' | null): string {
  if (source === 'compass') {
    return '罗盘'
  }
  if (source === 'motion') {
    return '设备方向兜底'
  }
  return '无数据'
}

function formatCompassTuningProfileText(profile: CompassTuningProfile): string {
  if (profile === 'smooth') {
    return '顺滑'
  }
  if (profile === 'responsive') {
    return '跟手'
  }
  return '平衡'
}

function formatTrackDisplayModeText(mode: TrackDisplayMode): string {
  if (mode === 'none') {
    return '无'
  }
  if (mode === 'tail') {
    return '彗尾'
  }
  return '全轨迹'
}

function formatTrackTailLengthText(length: TrackTailLengthPreset): string {
  if (length === 'short') {
    return '短'
  }
  if (length === 'long') {
    return '长'
  }
  return '中'
}

function formatTrackColorPresetText(colorPreset: TrackColorPreset): string {
  const labels: Record<TrackColorPreset, string> = {
    mint: '薄荷',
    cyan: '青绿',
    sky: '天蓝',
    blue: '深蓝',
    violet: '紫罗兰',
    pink: '玫红',
    orange: '橙色',
    yellow: '亮黄',
  }
  return labels[colorPreset]
}

function formatGpsMarkerSizeText(size: GpsMarkerSizePreset): string {
  if (size === 'small') {
    return '小'
  }
  if (size === 'large') {
    return '大'
  }
  return '中'
}

function formatGpsMarkerStyleText(style: GpsMarkerStyleId): string {
  if (style === 'dot') {
    return '圆点'
  }
  if (style === 'disc') {
    return '圆盘'
  }
  if (style === 'badge') {
    return '徽章'
  }
  return '信标'
}

function formatGpsMarkerColorPresetText(colorPreset: GpsMarkerColorPreset): string {
  const labels: Record<GpsMarkerColorPreset, string> = {
    mint: '薄荷',
    cyan: '青绿',
    sky: '天蓝',
    blue: '深蓝',
    violet: '紫罗兰',
    pink: '玫红',
    orange: '橙色',
    yellow: '亮黄',
  }
  return labels[colorPreset]
}

function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
  return mode === 'magnetic' ? '北参照：磁北' : '北参照：真北'
}

function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
  if (mode === 'magnetic') {
    return '已切到磁北模式'
  }

  return '已切到真北模式'
}

function getNextNorthReferenceMode(mode: NorthReferenceMode): NorthReferenceMode {
  return mode === 'magnetic' ? 'true' : 'magnetic'
}

function formatCompassNeedleDegForMode(mode: NorthReferenceMode, magneticHeadingDeg: number | null): number {
  if (magneticHeadingDeg === null) {
    return 0
  }

  const referenceHeadingDeg = mode === 'true'
    ? getTrueHeadingDeg(magneticHeadingDeg)
    : normalizeRotationDeg(magneticHeadingDeg)

  return normalizeRotationDeg(360 - referenceHeadingDeg)
}

function formatCacheHitRate(memoryHitCount: number, diskHitCount: number, networkFetchCount: number): string {
  const total = memoryHitCount + diskHitCount + networkFetchCount
  if (!total) {
    return '--'
  }

  const hitRate = ((memoryHitCount + diskHitCount) / total) * 100
  return `${Math.round(hitRate)}%`
}

function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number | null): string {
  if (!point) {
    return '--'
  }

  const base = `${point.lat.toFixed(6)}, ${point.lon.toFixed(6)}`
  if (accuracyMeters === null || !Number.isFinite(accuracyMeters)) {
    return base
  }

  return `${base} / 卤${Math.round(accuracyMeters)}m`
}

function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
  const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
  const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
  const dy = (b.lat - a.lat) * 110540
  return Math.sqrt(dx * dx + dy * dy)
}

function resolveSmartHeadingSource(speedKmh: number | null, movementReliable: boolean): SmartHeadingSource {
  if (!movementReliable || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
    return 'sensor'
  }

  if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
    return 'movement'
  }

  return 'blended'
}

function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
  const fromLatRad = from.lat * Math.PI / 180
  const toLatRad = to.lat * Math.PI / 180
  const deltaLonRad = (to.lon - from.lon) * Math.PI / 180
  const y = Math.sin(deltaLonRad) * Math.cos(toLatRad)
  const x = Math.cos(fromLatRad) * Math.sin(toLatRad) - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad)
  const bearingDeg = Math.atan2(y, x) * 180 / Math.PI
  return normalizeRotationDeg(bearingDeg)
}

export class MapEngine {
  buildVersion: string
  animationLevel: AnimationLevel
  renderer: WebGLMapRenderer
  accelerometerController: AccelerometerController
  compassController: CompassHeadingController
  gyroscopeController: GyroscopeController
  deviceMotionController: DeviceMotionController
  locationController: LocationController
  heartRateController: HeartRateInputController
  feedbackDirector: FeedbackDirector
  mockSimulatorDebugLogger: MockSimulatorDebugLogger
  onData: (patch: Partial<MapEngineViewState>) => void
  state: MapEngineViewState
  accelerometerErrorText: string | null
  previewScale: number
  previewOriginX: number
  previewOriginY: number
  panLastX: number
  panLastY: number
  panLastTimestamp: number
  tapStartX: number
  tapStartY: number
  tapStartAt: number
  panVelocityX: number
  panVelocityY: number
  pinchStartDistance: number
  pinchStartScale: number
  pinchStartAngle: number
  pinchStartRotationDeg: number
  pinchAnchorWorldX: number
  pinchAnchorWorldY: number
  gestureMode: GestureMode
  inertiaTimer: number
  previewResetTimer: number
  viewSyncTimer: number
  autoRotateTimer: number
  compassNeedleTimer: number
  compassBootstrapRetryTimer: number
  pendingViewPatch: Partial<MapEngineViewState>
  mounted: boolean
  diagnosticUiEnabled: boolean
  northReferenceMode: NorthReferenceMode
  sensorHeadingDeg: number | null
  smoothedSensorHeadingDeg: number | null
  compassDisplayHeadingDeg: number | null
  targetCompassDisplayHeadingDeg: number | null
  lastCompassSampleAt: number
  compassSource: 'compass' | 'motion' | null
  compassTuningProfile: CompassTuningProfile
  smoothedMovementHeadingDeg: number | null
  autoRotateHeadingDeg: number | null
  courseHeadingDeg: number | null
  targetAutoRotationDeg: number | null
  autoRotateSourceMode: AutoRotateSourceMode
  autoRotateCalibrationOffsetDeg: number | null
  autoRotateCalibrationPending: boolean
  lastStatsUiSyncAt: number
  minZoom: number
  maxZoom: number
  defaultZoom: number
  defaultCenterTileX: number
  defaultCenterTileY: number
  tileBoundsByZoom: Record<number, TileZoomBounds> | null
  currentGpsPoint: LonLatPoint | null
  currentGpsTrack: LonLatPoint[]
  currentGpsTrackSamples: GpsTrackSample[]
  currentGpsAccuracyMeters: number | null
  currentGpsInsideMap: boolean
  lastTrackMotionAt: number
  courseData: OrienteeringCourseData | null
  courseOverlayVisible: boolean
  cpRadiusMeters: number
  configAppId: string
  configSchemaVersion: string
  configVersion: string
  controlScoreOverrides: Record<string, number>
  controlContentOverrides: Record<string, GameControlDisplayContentOverride>
  defaultControlContentOverride: GameControlDisplayContentOverride | null
  defaultControlPointStyleOverride: ControlPointStyleEntry | null
  controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
  defaultLegStyleOverride: CourseLegStyleEntry | null
  legStyleOverrides: Record<number, CourseLegStyleEntry>
  defaultControlScore: number | null
  courseStyleConfig: CourseStyleConfig
  trackStyleConfig: TrackVisualizationConfig
  gpsMarkerStyleConfig: GpsMarkerStyleConfig
  gameRuntime: GameRuntime
  telemetryRuntime: TelemetryRuntime
  telemetryPlayerProfile: PlayerTelemetryProfile | null
  gamePresentation: GamePresentationState
  gameMode: 'classic-sequential' | 'score-o'
  sessionCloseAfterMs: number
  sessionCloseWarningMs: number
  minCompletedControlsBeforeFinish: number
  punchPolicy: 'enter' | 'enter-confirm'
  punchRadiusMeters: number
  requiresFocusSelection: boolean
  skipEnabled: boolean
  skipRadiusMeters: number
  skipRequiresConfirm: boolean
  autoFinishOnLastControl: boolean
  punchFeedbackTimer: number
  contentCardTimer: number
  contentQuizTimer: number
  contentQuizFeedbackTimer: number
  currentContentCardPriority: number
  shownContentCardKeys: Record<string, true>
  consumedContentQuizKeys: Record<string, true>
  rewardedContentQuizKeys: Record<string, true>
  sessionBonusScore: number
  currentContentCard: ContentCardEntry | null
  pendingContentCards: ContentCardEntry[]
  currentContentCardH5Request: H5ExperienceRequest | null
  currentH5ExperienceOpen: boolean
  currentContentQuizKey: string
  currentContentQuizAnswer: number
  currentContentQuizBonusScore: number
  sessionQuizCorrectCount: number
  sessionQuizWrongCount: number
  sessionQuizTimeoutCount: number
  mapPulseTimer: number
  stageFxTimer: number
  sessionTimerInterval: number
  hasGpsCenteredOnce: boolean
  gpsLockEnabled: boolean
  onOpenH5Experience?: (request: H5ExperienceRequest) => void

  constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
    this.buildVersion = buildVersion
    this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync())
    this.compassTuningProfile = 'balanced'
    this.onData = callbacks.onData
    this.onOpenH5Experience = callbacks.onOpenH5Experience
    this.accelerometerErrorText = null
    this.mockSimulatorDebugLogger = new MockSimulatorDebugLogger((debugState) => {
      this.setState({
        mockDebugLogBridgeConnected: debugState.connected,
        mockDebugLogBridgeStatusText: debugState.statusText,
        mockDebugLogBridgeUrlText: debugState.url,
      })
    })
      this.renderer = new WebGLMapRenderer(
        (stats) => {
          this.applyStats(stats)
        },
        (message) => {
          this.setState({
            statusText: `${message} (${this.buildVersion})`,
          })
        },
        (info) => {
          const statusText = !info.url
            ? '未配置'
            : info.status === 'ready'
              ? '已就绪'
              : info.status === 'loading'
                ? '加载中'
                : info.status === 'error'
                  ? '加载失败'
                  : '空闲'
          this.setState({
            gpsLogoStatusText: statusText,
            gpsLogoSourceText: info.resolvedSrc || info.url || '--',
          })
        },
        (scope, level, message, payload) => {
          this.mockSimulatorDebugLogger.log(scope, level, message, payload)
        },
      )
      this.accelerometerController = new AccelerometerController({
        onSample: (x, y, z) => {
          this.accelerometerErrorText = null
          this.telemetryRuntime.dispatch({
            type: 'accelerometer_updated',
            at: Date.now(),
            x,
            y,
            z,
          })
          if (this.diagnosticUiEnabled) {
            this.setState(this.getTelemetrySensorViewPatch())
          }
        },
        onError: (message) => {
          this.accelerometerErrorText = `不可用: ${message}`
          if (this.diagnosticUiEnabled) {
            this.setState({
              ...this.getTelemetrySensorViewPatch(),
              statusText: `加速度计启动失败 (${this.buildVersion})`,
            })
          }
        },
      })
    this.compassController = new CompassHeadingController({
      onHeading: (headingDeg) => {
        this.handleCompassHeading(headingDeg)
      },
      onError: (message) => {
        this.handleCompassError(message)
      },
    })
    this.compassController.setTuningProfile(this.compassTuningProfile)
    this.gyroscopeController = new GyroscopeController({
      onSample: (x, y, z) => {
        this.telemetryRuntime.dispatch({
          type: 'gyroscope_updated',
          at: Date.now(),
          x,
          y,
          z,
        })
        if (this.diagnosticUiEnabled) {
          this.setState(this.getTelemetrySensorViewPatch())
        }
      },
      onError: () => {
        if (this.diagnosticUiEnabled) {
          this.setState(this.getTelemetrySensorViewPatch())
        }
      },
    })
    this.deviceMotionController = new DeviceMotionController({
      onSample: (alpha, beta, gamma) => {
        this.telemetryRuntime.dispatch({
          type: 'device_motion_updated',
          at: Date.now(),
          alpha,
          beta,
          gamma,
        })
        if (this.diagnosticUiEnabled) {
          this.setState({
            ...this.getTelemetrySensorViewPatch(),
            autoRotateSourceText: this.getAutoRotateSourceText(),
          })
        }
      },
      onError: () => {
        if (this.diagnosticUiEnabled) {
          this.setState(this.getTelemetrySensorViewPatch())
        }
      },
    })
    this.locationController = new LocationController({
      onLocation: (update) => {
        this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
      },
      onStatus: (message) => {
        this.setState({
          gpsTracking: this.locationController.listening,
          gpsTrackingText: message,
          ...this.getLocationControllerViewPatch(),
        })
      },
      onError: (message) => {
        this.setState({
          gpsTracking: this.locationController.listening,
          gpsTrackingText: message,
          ...this.getLocationControllerViewPatch(),
          statusText: `${message} (${this.buildVersion})`,
        })
      },
      onDebugStateChange: () => {
        if (this.diagnosticUiEnabled) {
          this.setState(this.getLocationControllerViewPatch())
        }
      },
    })
      this.heartRateController = new HeartRateInputController({
        onHeartRate: (bpm) => {
          this.telemetryRuntime.dispatch({
            type: 'heart_rate_updated',
            at: Date.now(),
          bpm,
        })
        this.syncSessionTimerText()
        },
        onStatus: (message) => {
          const deviceName = this.heartRateController.currentDeviceName
            || (this.heartRateController.reconnecting ? this.heartRateController.lastDeviceName : null)
            || '--'
          this.setState({
            heartRateStatusText: message,
            heartRateDeviceText: deviceName,
            heartRateScanText: this.getHeartRateScanText(),
            ...this.getHeartRateControllerViewPatch(),
          })
        },
        onError: (message) => {
          this.clearHeartRateSignal()
          const deviceName = this.heartRateController.reconnecting
            ? (this.heartRateController.lastDeviceName || '--')
            : '--'
          this.setState({
            heartRateConnected: false,
            heartRateStatusText: message,
            heartRateDeviceText: deviceName,
            heartRateScanText: this.getHeartRateScanText(),
            ...this.getHeartRateControllerViewPatch(),
            statusText: `${message} (${this.buildVersion})`,
          })
        },
        onConnectionChange: (connected, deviceName) => {
          if (!connected) {
            this.clearHeartRateSignal()
          }
          const resolvedDeviceName = connected
            ? (deviceName || '--')
            : (this.heartRateController.reconnecting
              ? (this.heartRateController.lastDeviceName || '--')
              : '--')
          this.setState({
            heartRateConnected: connected,
            heartRateDeviceText: resolvedDeviceName,
            heartRateStatusText: connected
              ? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
              : (this.heartRateController.reconnecting ? '心率带自动重连中' : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接')),
            heartRateScanText: this.getHeartRateScanText(),
            heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
            ...this.getHeartRateControllerViewPatch(),
          })
        },
        onDeviceListChange: (devices) => {
          if (this.diagnosticUiEnabled) {
            this.setState({
              heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
              heartRateScanText: this.getHeartRateScanText(),
              ...this.getHeartRateControllerViewPatch(),
            })
          }
        },
        onDebugStateChange: () => {
          if (this.diagnosticUiEnabled) {
            this.setState(this.getHeartRateControllerViewPatch())
          }
        },
      })
    this.feedbackDirector = new FeedbackDirector({
      showPunchFeedback: (text, tone, motionClass) => {
        this.showPunchFeedback(text, tone, motionClass)
      },
      showContentCard: (title, body, motionClass, options) => {
        this.showContentCard(title, body, motionClass, options)
      },
      setPunchButtonFxClass: (className) => {
        this.setPunchButtonFxClass(className)
      },
      setHudProgressFxClass: (className) => {
        this.setHudProgressFxClass(className)
      },
      setHudDistanceFxClass: (className) => {
        this.setHudDistanceFxClass(className)
      },
      showMapPulse: (controlId, motionClass) => {
        this.showMapPulse(controlId, motionClass)
      },
      showStageFx: (className) => {
        this.showStageFx(className)
      },
      stopLocationTracking: () => {
        if (this.locationController.listening) {
          this.locationController.stop()
        }
      },
    })
    this.feedbackDirector.setAnimationLevel(this.animationLevel)
    this.minZoom = MIN_ZOOM
    this.maxZoom = MAX_ZOOM
    this.defaultZoom = DEFAULT_ZOOM
    this.defaultCenterTileX = DEFAULT_CENTER_TILE_X
    this.defaultCenterTileY = DEFAULT_CENTER_TILE_Y
    this.tileBoundsByZoom = null
    this.currentGpsPoint = null
    this.currentGpsTrack = []
    this.currentGpsTrackSamples = []
    this.currentGpsAccuracyMeters = null
    this.currentGpsInsideMap = false
    this.lastTrackMotionAt = 0
    this.courseData = null
    this.courseOverlayVisible = false
    this.cpRadiusMeters = 5
    this.configAppId = ''
    this.configSchemaVersion = '1'
    this.configVersion = ''
    this.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<string, unknown>
          : null,
      },
      telemetry: this.telemetryRuntime.exportRecoveryState(),
      viewport: {
        zoom: this.state.zoom,
        centerTileX: this.state.centerTileX,
        centerTileY: this.state.centerTileY,
        rotationDeg: this.state.rotationDeg,
        gpsLockEnabled: this.gpsLockEnabled,
        hasGpsCenteredOnce: this.hasGpsCenteredOnce,
      },
      currentGpsPoint: this.currentGpsPoint
        ? {
          lon: this.currentGpsPoint.lon,
          lat: this.currentGpsPoint.lat,
        }
        : null,
      currentGpsAccuracyMeters: this.currentGpsAccuracyMeters,
      currentGpsInsideMap: this.currentGpsInsideMap,
      bonusScore: this.sessionBonusScore,
      quizCorrectCount: this.sessionQuizCorrectCount,
      quizWrongCount: this.sessionQuizWrongCount,
      quizTimeoutCount: this.sessionQuizTimeoutCount,
    }
  }

  restoreSessionRecoveryRuntimeSnapshot(snapshot: RecoveryRuntimeSnapshot): boolean {
    const definition = this.buildCurrentGameDefinition()
    if (!definition) {
      return false
    }

    this.feedbackDirector.reset()
    this.resetTransientGameUiState()
    const result = this.gameRuntime.restoreDefinition(definition, snapshot.gameState)
    this.telemetryRuntime.restoreRecoveryState(
      definition,
      snapshot.gameState,
      snapshot.telemetry,
      result.presentation.hud.hudTargetControlId,
    )
    this.syncGameResultState(result)
    this.currentGpsPoint = snapshot.currentGpsPoint
      ? {
        lon: snapshot.currentGpsPoint.lon,
        lat: snapshot.currentGpsPoint.lat,
      }
      : null
    this.currentGpsAccuracyMeters = snapshot.currentGpsAccuracyMeters
    this.currentGpsInsideMap = snapshot.currentGpsInsideMap
    this.gpsLockEnabled = snapshot.viewport.gpsLockEnabled && !!this.currentGpsPoint && snapshot.currentGpsInsideMap
    this.hasGpsCenteredOnce = snapshot.viewport.hasGpsCenteredOnce || !!this.currentGpsPoint
    this.sessionBonusScore = snapshot.bonusScore
    this.sessionQuizCorrectCount = snapshot.quizCorrectCount
    this.sessionQuizWrongCount = snapshot.quizWrongCount
    this.sessionQuizTimeoutCount = snapshot.quizTimeoutCount
    this.courseOverlayVisible = true
    if (!this.locationController.listening) {
      this.locationController.start()
    }
    this.updateSessionTimerLoop()

    this.commitViewport({
      zoom: snapshot.viewport.zoom,
      centerTileX: snapshot.viewport.centerTileX,
      centerTileY: snapshot.viewport.centerTileY,
      rotationDeg: snapshot.viewport.rotationDeg,
      rotationText: formatRotationText(snapshot.viewport.rotationDeg),
      gpsTracking: !!this.currentGpsPoint,
      gpsTrackingText: this.currentGpsPoint ? '已恢复上一局定位状态' : '已恢复上一局',
      gpsCoordText: formatGpsCoordText(this.currentGpsPoint, this.currentGpsAccuracyMeters),
      gpsLockEnabled: this.gpsLockEnabled,
      gpsLockAvailable: !!this.currentGpsPoint && snapshot.currentGpsInsideMap,
      autoRotateSourceText: this.getAutoRotateSourceText(),
      ...this.getGameViewPatch(`已恢复上一局 (${this.buildVersion})`),
    }, `已恢复上一局 (${this.buildVersion})`, true, () => {
      this.syncRenderer()
      if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
        this.scheduleAutoRotate()
      }
    })
    return true
  }

  destroy(): void {
    this.clearInertiaTimer()
    this.clearPreviewResetTimer()
    this.clearViewSyncTimer()
    this.clearAutoRotateTimer()
    this.clearCompassNeedleTimer()
    this.clearCompassBootstrapRetryTimer()
    this.clearPunchFeedbackTimer()
    this.clearContentCardTimer()
    this.clearMapPulseTimer()
    this.clearStageFxTimer()
    this.clearSessionTimerInterval()
    this.accelerometerController.destroy()
    this.compassController.destroy()
    this.gyroscopeController.destroy()
    this.deviceMotionController.destroy()
    this.locationController.destroy()
    this.heartRateController.destroy()
    this.feedbackDirector.destroy()
    this.mockSimulatorDebugLogger.destroy()
    this.renderer.destroy()
    this.mounted = false
  }

  handleAppShow(): void {
    this.feedbackDirector.setAppAudioMode('foreground')
    if (this.mounted) {
      this.lastCompassSampleAt = 0
      this.compassController.start()
      this.scheduleCompassBootstrapRetry()
    }
  }

  handleAppHide(): void {
    this.feedbackDirector.setAppAudioMode('foreground')
  }


  clearGameRuntime(): void {
    this.gameRuntime.clear()
    this.telemetryRuntime.reset()
    this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
    this.courseOverlayVisible = !!this.courseData
    this.clearSessionTimerInterval()
    this.setCourseHeading(null)
  }

  clearHeartRateSignal(): void {
    this.telemetryRuntime.dispatch({
      type: 'heart_rate_updated',
      at: Date.now(),
      bpm: null,
    })
    this.syncSessionTimerText()
  }

  clearFinishedTestOverlay(): void {
    this.currentGpsPoint = null
    this.currentGpsTrack = []
    this.currentGpsTrackSamples = []
    this.currentGpsAccuracyMeters = null
    this.currentGpsInsideMap = false
    this.smoothedMovementHeadingDeg = null
    this.lastTrackMotionAt = 0
    this.courseOverlayVisible = false
    this.setCourseHeading(null)
  }

  clearStartSessionResidue(): void {
    this.currentGpsTrack = []
    this.currentGpsTrackSamples = []
    this.smoothedMovementHeadingDeg = null
    this.lastTrackMotionAt = 0
    this.courseOverlayVisible = false
    this.setCourseHeading(null)
  }

  handleClearMapTestArtifacts(): void {
    this.clearFinishedTestOverlay()
    this.setState({
      gpsTracking: false,
      gpsTrackingText: '测试痕迹已清空',
      gpsCoordText: '--',
      statusText: `已清空地图点位与轨迹 (${this.buildVersion})`,
    }, true)
    this.syncRenderer()
  }

  getHudTargetControlId(): string | null {
    return this.gamePresentation.hud.hudTargetControlId
  }

  isSkipAvailable(): boolean {
    const definition = this.gameRuntime.definition
    const state = this.gameRuntime.state
    if (!definition || !state || state.status !== 'running' || !definition.skipEnabled) {
      return false
    }

    const currentTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null
    if (!currentTarget || currentTarget.kind !== 'control' || !this.currentGpsPoint) {
      return false
    }

    const avgLatRad = ((currentTarget.point.lat + this.currentGpsPoint.lat) / 2) * Math.PI / 180
    const dx = (this.currentGpsPoint.lon - currentTarget.point.lon) * 111320 * Math.cos(avgLatRad)
    const dy = (this.currentGpsPoint.lat - currentTarget.point.lat) * 110540
    const distanceMeters = Math.sqrt(dx * dx + dy * dy)
    return distanceMeters <= definition.skipRadiusMeters
  }

  shouldConfirmSkipAction(): boolean {
    return !!(this.gameRuntime.definition && this.gameRuntime.definition.skipRequiresConfirm)
  }

  getLocationControllerViewPatch(): Partial<MapEngineViewState> {
    const debugState = this.locationController.getDebugState()
    return {
      gpsTracking: debugState.listening,
      gpsLockEnabled: this.gpsLockEnabled,
      gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
      locationSourceMode: debugState.sourceMode,
      locationSourceText: debugState.sourceModeText,
      mockBridgeConnected: debugState.mockBridgeConnected,
      mockBridgeStatusText: debugState.mockBridgeStatusText,
      mockBridgeUrlText: debugState.mockBridgeUrlText,
      mockChannelIdText: debugState.mockChannelIdText,
      mockCoordText: debugState.mockCoordText,
      mockSpeedText: debugState.mockSpeedText,
    }
  }

  getHeartRateControllerViewPatch(): Partial<MapEngineViewState> {
    const debugState = this.heartRateController.getDebugState()
    return {
      heartRateSourceMode: debugState.sourceMode,
      heartRateSourceText: debugState.sourceModeText,
      mockHeartRateBridgeConnected: debugState.mockBridgeConnected,
      mockHeartRateBridgeStatusText: debugState.mockBridgeStatusText,
      mockHeartRateBridgeUrlText: debugState.mockBridgeUrlText,
      mockHeartRateText: debugState.mockHeartRateText,
    }
  }

  getMockDebugLogViewPatch(): Partial<MapEngineViewState> {
    const debugState = this.mockSimulatorDebugLogger.getState()
    return {
      mockDebugLogBridgeConnected: debugState.connected,
      mockDebugLogBridgeStatusText: debugState.statusText,
      mockDebugLogBridgeUrlText: debugState.url,
    }
  }

  getTelemetrySensorViewPatch(): Partial<MapEngineViewState> {
    const telemetryState = this.telemetryRuntime.state
    return {
      deviceHeadingText: formatHeadingText(
        telemetryState.deviceHeadingDeg === null
          ? null
          : getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg),
      ),
      devicePoseText: formatDevicePoseText(telemetryState.devicePose),
      headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence),
      accelerometerText: telemetryState.accelerometer
        ? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}`
        : '未启用',
      gyroscopeText: formatGyroscopeText(telemetryState.gyroscope),
      deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion),
      compassSourceText: formatCompassSourceText(this.compassSource),
      compassTuningProfile: this.compassTuningProfile,
      compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
    }
  }

  getGameModeText(): string {
    return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
  }

  buildCurrentGameDefinition(): ReturnType<typeof buildGameDefinitionFromCourse> | null {
    if (!this.courseData) {
      return null
    }

    return buildGameDefinitionFromCourse(
      this.courseData,
      this.cpRadiusMeters,
      this.gameMode,
      this.sessionCloseAfterMs,
      this.sessionCloseWarningMs,
      this.minCompletedControlsBeforeFinish,
      this.autoFinishOnLastControl,
      this.punchPolicy,
      this.punchRadiusMeters,
      this.requiresFocusSelection,
      this.skipEnabled,
      this.skipRadiusMeters,
      this.skipRequiresConfirm,
      this.controlScoreOverrides,
      this.defaultControlContentOverride,
      this.controlContentOverrides,
      this.defaultControlScore,
    )
  }

  loadGameDefinitionFromCourse(): GameResult | null {
    const definition = this.buildCurrentGameDefinition()
    if (!definition) {
      this.clearGameRuntime()
      return null
    }
    const result = this.gameRuntime.loadDefinition(definition)
    this.telemetryRuntime.loadDefinition(definition)
    this.courseOverlayVisible = true
    this.syncGameResultState(result)
    this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, result.presentation.hud.hudTargetControlId)
    this.updateSessionTimerLoop()
    return result
  }

  refreshCourseHeadingFromPresentation(): void {
    if (!this.courseData || !this.gamePresentation.map.activeLegIndices.length) {
      this.setCourseHeading(null)
      return
    }

    const activeLegIndex = this.gamePresentation.map.activeLegIndices[0]
    const activeLeg = this.courseData.layers.legs[activeLegIndex]
    if (!activeLeg) {
      this.setCourseHeading(null)
      return
    }

    this.setCourseHeading(getInitialBearingDeg(activeLeg.fromPoint, activeLeg.toPoint))
  }

  resolveGameStatusText(effects: GameEffect[]): string | null {
    const lastEffect = effects.length ? effects[effects.length - 1] : null
    if (!lastEffect) {
      return null
    }

    if (lastEffect.type === 'control_completed') {
      const sequenceText = typeof lastEffect.sequence === 'number' ? String(lastEffect.sequence) : lastEffect.controlId
      return `宸插畬鎴愭鏌ョ偣 ${sequenceText} (${this.buildVersion})`
    }

    if (lastEffect.type === 'session_finished') {
      return `璺嚎宸插畬鎴?(${this.buildVersion})`
    }

    if (lastEffect.type === 'session_timed_out') {
      return `已到关门时间，超时结束 (${this.buildVersion})`
    }

    if (lastEffect.type === 'session_started') {
      return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})`
    }

    return null
  }
  getGameViewPatch(statusText?: string | null): Partial<MapEngineViewState> {
    const telemetryPresentation = this.telemetryRuntime.getPresentation()
    const patch: Partial<MapEngineViewState> = {
      gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
      gameModeText: this.getGameModeText(),
      panelTimerText: telemetryPresentation.timerText,
      panelTimerMode: telemetryPresentation.timerMode,
      panelMileageText: telemetryPresentation.mileageText,
      panelActionTagText: this.gamePresentation.hud.actionTagText,
      panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
      panelTargetSummaryText: this.gamePresentation.hud.targetSummaryText,
      panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
      panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
      panelSpeedValueText: telemetryPresentation.speedText,
      panelTelemetryTone: telemetryPresentation.heartRateTone,
      panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText,
      panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText,
      panelHeartRateValueText: telemetryPresentation.heartRateValueText,
      panelHeartRateUnitText: telemetryPresentation.heartRateUnitText,
      panelCaloriesValueText: telemetryPresentation.caloriesValueText,
      panelCaloriesUnitText: telemetryPresentation.caloriesUnitText,
      panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText,
      panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
      panelAccuracyValueText: telemetryPresentation.accuracyValueText,
      panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
      panelProgressText: this.resolveHudProgressText(),
      punchButtonText: this.gamePresentation.hud.punchButtonText,
      punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
      skipButtonEnabled: this.isSkipAvailable(),
      punchHintText: this.gamePresentation.hud.punchHintText,
      gpsLockEnabled: this.gpsLockEnabled,
      gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
    }

    if (statusText) {
      patch.statusText = statusText
    }

    return patch
  }

  clearPunchFeedbackTimer(): void {
    if (this.punchFeedbackTimer) {
      clearTimeout(this.punchFeedbackTimer)
      this.punchFeedbackTimer = 0
    }
  }

  clearContentCardTimer(): void {
    if (this.contentCardTimer) {
      clearTimeout(this.contentCardTimer)
      this.contentCardTimer = 0
    }
  }

  getPendingManualContentCount(): number {
    return this.pendingContentCards.filter((item) => !item.autoPopup).length
  }

  buildPendingContentEntryText(): string {
    const count = this.getPendingManualContentCount()
    if (count <= 1) {
      return count === 1 ? '查看内容' : ''
    }
    return `查看内容(${count})`
  }

  syncPendingContentEntryState(immediate = true): void {
    const count = this.getPendingManualContentCount()
    this.setState({
      pendingContentEntryVisible: count > 0,
      pendingContentEntryText: this.buildPendingContentEntryText(),
    }, immediate)
  }

  getBaseSessionScore(): number {
    return this.gameRuntime.state && typeof this.gameRuntime.state.score === 'number'
      ? this.gameRuntime.state.score
      : 0
  }

  getTotalSessionScore(): number {
    return this.getBaseSessionScore() + this.sessionBonusScore
  }

  resolveHudProgressText(): string {
    const definition = this.gameRuntime.definition
    const sessionState = this.gameRuntime.state
    if (!definition || !sessionState) {
      return this.gamePresentation.hud.progressText
    }

    const scoringControls = definition.controls.filter((control) => control.kind === 'control')
    const scoringControlIdSet = new Set(scoringControls.map((control) => control.id))
    const completedCount = sessionState.completedControlIds.filter((controlId) => scoringControlIdSet.has(controlId)).length
    const skippedCount = sessionState.skippedControlIds.filter((controlId) => scoringControlIdSet.has(controlId)).length
    const totalCount = scoringControls.length

    if (definition.mode === 'score-o') {
      return `${this.getTotalSessionScore()}分 ${completedCount}/${totalCount}`
    }

    return skippedCount > 0
      ? `${completedCount}/${totalCount} 跳${skippedCount}`
      : `${completedCount}/${totalCount}`
  }

  buildContentCardActions(
    ctas: ContentCardCtaConfig[],
    h5Request: H5ExperienceRequest | null,
    contentKey = '',
  ): ContentCardActionViewModel[] {
    const resolved = this.resolveContentControlByKey(contentKey)
    if (resolved && resolved.displayMode === 'click') {
      return []
    }

    const actions = ctas
      .filter((item) => item.type !== 'detail' || !!h5Request)
      .map((item, index) => ({
      key: `cta-${index + 1}`,
      type: item.type,
      label: item.label || buildDefaultContentCardCtaLabel(item.type),
    })) as ContentCardActionViewModel[]

    if (h5Request && !actions.some((item) => item.type === 'detail')) {
      actions.unshift({
        key: 'cta-detail',
        type: 'detail',
        label: '查看详情',
      })
    }

    return actions.slice(0, 3)
  }

  isClickContentCardEntry(item: ContentCardEntry | null): boolean {
    if (!item) {
      return false
    }

    const resolved = this.resolveContentControlByKey(item.contentKey)
    return !!resolved && resolved.displayMode === 'click'
  }

  resolveContentCardAutoDismissMs(
    item: ContentCardEntry,
    actions: ContentCardActionViewModel[],
  ): number {
    if (this.isClickContentCardEntry(item)) {
      return 4000
    }

    return actions.length ? 0 : 2600
  }

  clearContentQuizTimer(): void {
    if (this.contentQuizTimer) {
      clearInterval(this.contentQuizTimer)
      this.contentQuizTimer = 0
    }
  }

  clearContentQuizFeedbackTimer(): void {
    if (this.contentQuizFeedbackTimer) {
      clearTimeout(this.contentQuizFeedbackTimer)
      this.contentQuizFeedbackTimer = 0
    }
  }

  closeContentQuiz(immediate = true): void {
    this.clearContentQuizTimer()
    this.clearContentQuizFeedbackTimer()
    this.currentContentQuizKey = ''
    this.currentContentQuizAnswer = 0
    this.currentContentQuizBonusScore = 0
    this.setState({
      contentQuizVisible: false,
      contentQuizQuestionText: '',
      contentQuizCountdownText: '',
      contentQuizOptions: [],
      contentQuizFeedbackVisible: false,
      contentQuizFeedbackText: '',
      contentQuizFeedbackTone: 'neutral',
    }, immediate)
  }

  buildContentQuizSession(quizConfig: ContentCardQuizConfig): {
    questionText: string
    correctAnswer: number
    options: ContentCardQuizOptionViewModel[]
  } {
    const minValue = Math.max(10, Math.round(quizConfig.minValue))
    const maxValue = Math.max(minValue + 10, Math.round(quizConfig.maxValue))
    const leftValue = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue
    const rightValue = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue
    const allowSubtraction = quizConfig.allowSubtraction !== false
    const useSubtraction = allowSubtraction && Math.random() < 0.45
    const safeLeft = useSubtraction && leftValue < rightValue ? rightValue : leftValue
    const safeRight = useSubtraction && leftValue < rightValue ? leftValue : rightValue
    const correctAnswer = useSubtraction ? safeLeft - safeRight : leftValue + rightValue
    const questionText = useSubtraction
      ? `${safeLeft} - ${safeRight} = ?`
      : `${leftValue} + ${rightValue} = ?`
    const distractorA = correctAnswer + (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 8) + 2)
    const distractorB = correctAnswer + (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 15) + 9)
    const values = [correctAnswer, distractorA, distractorB]
      .map((item) => Math.max(0, Math.round(item)))
    while (new Set(values).size < 3) {
      values[2] += 7
    }
    const shuffled = values
      .map((value) => ({ sort: Math.random(), value }))
      .sort((a, b) => a.sort - b.sort)
      .map((item) => item.value)
    return {
      questionText,
      correctAnswer,
      options: shuffled.map((value, index) => ({
        key: `quiz-${index + 1}`,
        label: `${value}`,
      })),
    }
  }

  openContentQuizFromEntry(entry: ContentCardEntry): void {
    if (!entry.contentKey) {
      return
    }
    if (this.consumedContentQuizKeys[entry.contentKey]) {
      return
    }
    const quizCta = entry.ctas.find((item) => item.type === 'quiz')
    if (!quizCta) {
      return
    }
    const quizConfig = buildDefaultContentCardQuizConfig(quizCta.quiz)
    const session = this.buildContentQuizSession(quizConfig)
    this.closeContentQuiz(false)
    this.currentContentQuizKey = entry.contentKey
    this.consumedContentQuizKeys[entry.contentKey] = true
    this.currentContentQuizAnswer = session.correctAnswer
    this.currentContentQuizBonusScore = Math.max(0, Math.round(quizConfig.bonusScore))
    const expiresAt = Date.now() + (Math.max(3, quizConfig.countdownSeconds) * 1000)
    const syncCountdown = () => {
      const remainingMs = Math.max(0, expiresAt - Date.now())
      const remainingSeconds = Math.ceil(remainingMs / 1000)
      this.setState({
        contentQuizCountdownText: `${remainingSeconds}s`,
      })
      if (remainingMs <= 0) {
        this.handleContentCardQuizTimeout()
      }
    }

    this.setState({
      contentQuizVisible: true,
      contentQuizQuestionText: session.questionText,
      contentQuizCountdownText: `${Math.max(3, quizConfig.countdownSeconds)}s`,
      contentQuizOptions: session.options,
      contentQuizFeedbackVisible: false,
      contentQuizFeedbackText: '',
      contentQuizFeedbackTone: 'neutral',
    }, true)
    this.contentQuizTimer = setInterval(syncCountdown, 250) as unknown as number
  }

  openCurrentContentCardQuiz(): void {
    if (!this.currentContentCard || !this.currentContentCard.contentKey) {
      return
    }
    this.openContentQuizFromEntry(this.currentContentCard)
  }

  finishContentQuizFeedback(text: string, tone: 'success' | 'error'): void {
    this.clearContentQuizTimer()
    this.clearContentQuizFeedbackTimer()
    this.setState({
      contentQuizFeedbackVisible: true,
      contentQuizFeedbackText: text,
      contentQuizFeedbackTone: tone,
    }, true)
    this.contentQuizFeedbackTimer = setTimeout(() => {
      this.contentQuizFeedbackTimer = 0
      this.closeContentQuiz(true)
    }, 1200) as unknown as number
  }

  handleContentCardQuizAnswer(optionKey: string): void {
    if (!this.state.contentQuizVisible) {
      return
    }
    const option = this.state.contentQuizOptions.find((item) => item.key === optionKey)
    if (!option) {
      return
    }
    const selectedValue = Number(option.label)
    const quizKey = this.currentContentQuizKey
    const isCorrect = selectedValue === this.currentContentQuizAnswer
    if (isCorrect && quizKey && !this.rewardedContentQuizKeys[quizKey]) {
      this.rewardedContentQuizKeys[quizKey] = true
      this.sessionBonusScore += this.currentContentQuizBonusScore
    }
    if (isCorrect) {
      this.sessionQuizCorrectCount += 1
    } else {
      this.sessionQuizWrongCount += 1
    }
    this.feedbackDirector.playAudioCue(isCorrect ? 'control_completed:control' : 'punch_feedback:warning')
    this.finishContentQuizFeedback(isCorrect ? `回答正确 +${this.currentContentQuizBonusScore}分` : '回答错误 未获得加分', isCorrect ? 'success' : 'error')
  }

  handleContentCardQuizTimeout(): void {
    if (!this.state.contentQuizVisible) {
      return
    }
    this.sessionQuizTimeoutCount += 1
    this.feedbackDirector.playAudioCue('punch_feedback:warning')
    this.finishContentQuizFeedback('答题超时 未获得加分', 'error')
  }

  resolveContentControlByKey(contentKey: string): { control: GameControl; displayMode: 'auto' | 'click' } | null {
    if (!contentKey || !this.gameRuntime.definition) {
      return null
    }

    const isClickContent = contentKey.indexOf(':click') >= 0
    const controlId = isClickContent ? contentKey.replace(/:click$/, '') : contentKey
    const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
    if (!control || !control.displayContent) {
      return null
    }

    return {
      control,
      displayMode: isClickContent ? 'click' : 'auto',
    }
  }

  buildContentH5Request(
    contentKey: string,
    title: string,
    body: string,
    motionClass: string,
    once: boolean,
    priority: number,
    autoPopup: boolean,
  ): H5ExperienceRequest | null {
    const resolved = this.resolveContentControlByKey(contentKey)
    if (!resolved) {
      return null
    }

    const displayContent = resolved.control.displayContent
    if (!displayContent) {
      return null
    }

    const experienceConfig = resolved.displayMode === 'click'
      ? displayContent.clickExperience
      : displayContent.contentExperience
    if (!experienceConfig || experienceConfig.type !== 'h5' || !experienceConfig.url) {
      return null
    }

    return {
      kind: 'content',
      title: title || resolved.control.label || '内容体验',
      subtitle: resolved.displayMode === 'click' ? '点击查看内容' : '打点内容体验',
      url: experienceConfig.url,
      bridgeVersion: experienceConfig.bridge || 'content-v1',
      presentation: experienceConfig.presentation || 'sheet',
      context: {
        eventId: this.configAppId || '',
        configTitle: this.state.mapName || '',
        configVersion: this.configVersion || '',
        mode: this.gameMode,
        sessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
        controlId: resolved.control.id,
        controlKind: resolved.control.kind,
        controlCode: resolved.control.code,
        controlLabel: resolved.control.label,
        controlSequence: resolved.control.sequence,
        displayMode: resolved.displayMode,
        title,
        body,
      },
      fallback: {
        title,
        body,
        motionClass,
        contentKey,
        once,
        priority,
        autoPopup,
      },
    }
  }

  buildControlContentCardEntry(
    contentKey: string,
    options: {
      title?: string
      body?: string
      motionClass?: string
      autoPopup?: boolean
      once?: boolean
      priority?: number
    } = {},
  ): ContentCardEntry | null {
    const resolved = this.resolveContentControlByKey(contentKey)
    if (!resolved || !resolved.control.displayContent) {
      return null
    }

    const displayContent = resolved.control.displayContent
    const motionClass = options.motionClass || ''
    const autoPopup = options.autoPopup !== false
    const once = options.once !== undefined ? options.once : displayContent.once
    const priority = typeof options.priority === 'number' ? options.priority : displayContent.priority
    const title = options.title !== undefined ? options.title : displayContent.title
    const body = options.body !== undefined ? options.body : displayContent.body

    return {
      template: displayContent.template,
      title,
      body,
      motionClass,
      contentKey,
      once,
      priority,
      autoPopup,
      ctas: displayContent.ctas,
      h5Request: this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup),
    }
  }

  removePendingContentCardsByKey(contentKey: string): void {
    if (!contentKey || !this.pendingContentCards.length) {
      return
    }

    const nextPendingCards = this.pendingContentCards.filter((item) => item.contentKey !== contentKey)
    if (nextPendingCards.length === this.pendingContentCards.length) {
      return
    }

    this.pendingContentCards = nextPendingCards
    this.syncPendingContentEntryState()
  }

  removePendingClickContentCards(): void {
    if (!this.pendingContentCards.length) {
      return
    }

    const nextPendingCards = this.pendingContentCards.filter((item) => item.contentKey.indexOf(':click') < 0)
    if (nextPendingCards.length === this.pendingContentCards.length) {
      return
    }

    this.pendingContentCards = nextPendingCards
    this.syncPendingContentEntryState()
  }

  replaceVisibleContentCard(item: ContentCardEntry): void {
    this.clearContentCardTimer()
    this.closeContentQuiz(false)
    this.removePendingClickContentCards()
    this.currentContentCardPriority = 0
    this.currentContentCard = null
    this.currentContentCardH5Request = null
    this.currentH5ExperienceOpen = false
    this.setState({
      contentCardVisible: false,
      contentCardTemplate: 'story',
      contentCardTitle: '',
      contentCardBody: '',
      contentCardFxClass: '',
      contentCardActions: [],
    }, true)
    this.openContentCardEntry(item)
  }

  applyAutoContentQuizEffects(effects: GameEffect[]): void {
    for (let index = effects.length - 1; index >= 0; index -= 1) {
      const effect = effects[index]
      if (effect.type !== 'control_completed' || !effect.autoOpenQuiz) {
        continue
      }

        let readyForQuiz = !!this.currentContentCard && this.currentContentCard.contentKey === effect.controlId
        if (!readyForQuiz) {
          const entry = this.buildControlContentCardEntry(effect.controlId, {
            title: effect.displayTitle,
            body: effect.displayBody,
            autoPopup: effect.displayAutoPopup,
            once: effect.displayOnce,
            priority: effect.displayPriority + 100,
          })
        if (!entry) {
          continue
        }
        this.removePendingContentCardsByKey(effect.controlId)
        if (effect.displayAutoPopup) {
          this.openContentCardEntry(entry)
          readyForQuiz = true
        } else {
          this.currentContentCardPriority = entry.priority
          this.currentContentCard = entry
          this.currentContentCardH5Request = entry.h5Request
          readyForQuiz = true
        }
      }

      if (readyForQuiz && this.currentContentCard && this.currentContentCard.ctas.some((item) => item.type === 'quiz')) {
        this.openContentQuizFromEntry(this.currentContentCard)
      }
      return
    }
  }

  hasActiveContentExperience(): boolean {
    return this.state.contentCardVisible || this.currentH5ExperienceOpen
  }

  enqueueContentCard(item: ContentCardEntry): void {
    if (item.once && item.contentKey && this.shownContentCardKeys[item.contentKey]) {
      return
    }
    if (item.contentKey && this.pendingContentCards.some((pending) => pending.contentKey === item.contentKey && pending.autoPopup === item.autoPopup)) {
      return
    }
    this.pendingContentCards.push(item)
    this.syncPendingContentEntryState()
  }

  openContentCardEntry(item: ContentCardEntry): void {
    this.clearContentCardTimer()
    this.closeContentQuiz(false)
    const actions = this.buildContentCardActions(item.ctas, item.h5Request, item.contentKey)
    const autoDismissMs = this.resolveContentCardAutoDismissMs(item, actions)
    this.setState({
      contentCardVisible: true,
      contentCardTemplate: item.template,
      contentCardTitle: item.title,
      contentCardBody: item.body,
      contentCardActions: actions,
      contentCardFxClass: item.motionClass,
      pendingContentEntryVisible: false,
      pendingContentEntryText: '',
    }, true)
    this.currentContentCardPriority = item.priority
    this.currentContentCard = item
    this.currentContentCardH5Request = item.h5Request
    if (item.once && item.contentKey) {
      this.shownContentCardKeys[item.contentKey] = true
    }
    if (autoDismissMs <= 0) {
      return
    }
    this.contentCardTimer = setTimeout(() => {
      this.contentCardTimer = 0
      this.currentContentCardPriority = 0
      this.currentContentCard = null
      this.currentContentCardH5Request = null
      this.setState({
        contentCardVisible: false,
        contentCardTemplate: 'story',
        contentCardFxClass: '',
        contentCardActions: [],
      }, true)
      this.flushQueuedContentCards()
    }, autoDismissMs) as unknown as number
  }

  openCurrentContentCardDetail(): void {
    if (!this.currentContentCard) {
      this.setState({
        statusText: `当前没有可打开的内容详情 (${this.buildVersion})`,
      }, true)
      return
    }

    if (!this.currentContentCardH5Request) {
      this.setState({
        statusText: `当前内容未配置 H5 详情 (${this.buildVersion})`,
      }, true)
      return
    }

    if (!this.onOpenH5Experience) {
      this.setState({
        statusText: `H5 详情入口未就绪 (${this.buildVersion})`,
      }, true)
      return
    }

    if (this.currentH5ExperienceOpen) {
      this.setState({
        statusText: `H5 详情页已在打开中 (${this.buildVersion})`,
      }, true)
      return
    }

    const request = this.currentContentCardH5Request
    this.clearContentCardTimer()
    this.closeContentQuiz(false)
    this.setState({
      contentCardVisible: false,
      contentCardTemplate: 'story',
      contentCardTitle: '',
      contentCardBody: '',
      contentCardFxClass: '',
      contentCardActions: [],
    }, true)
    this.currentH5ExperienceOpen = true

    try {
      this.onOpenH5Experience(request)
    } catch {
      this.currentH5ExperienceOpen = false
      this.openContentCardEntry({
        ...this.currentContentCard,
        h5Request: null,
      })
    }
  }

  openCurrentContentCardAction(actionType: string): 'detail' | 'photo' | 'audio' | 'quiz' | null {
    if (!this.currentContentCard) {
      return null
    }
    if (actionType === 'detail') {
      this.openCurrentContentCardDetail()
      return 'detail'
    }
    if (actionType === 'quiz') {
      this.openCurrentContentCardQuiz()
      return 'quiz'
    }
    if (actionType === 'photo') {
      return 'photo'
    }
    if (actionType === 'audio') {
      return 'audio'
    }
    return null
  }

  handleContentCardPhotoCaptured(): void {
    this.setState({
      statusText: `已完成拍照，照片待接入上传 (${this.buildVersion})`,
    }, true)
  }

  handleContentCardAudioRecorded(): void {
    this.setState({
      statusText: `已完成录音，音频待接入上传 (${this.buildVersion})`,
    }, true)
  }

  flushQueuedContentCards(): void {
    if (this.state.contentCardVisible || !this.pendingContentCards.length) {
      this.syncPendingContentEntryState()
      return
    }

    let candidateIndex = -1
    let candidatePriority = Number.NEGATIVE_INFINITY

    for (let index = 0; index < this.pendingContentCards.length; index += 1) {
      const item = this.pendingContentCards[index]
      if (!item.autoPopup) {
        continue
      }
      if (item.priority > candidatePriority) {
        candidatePriority = item.priority
        candidateIndex = index
      }
    }

    if (candidateIndex < 0) {
      this.syncPendingContentEntryState()
      return
    }

    const nextItem = this.pendingContentCards.splice(candidateIndex, 1)[0]
    this.openContentCardEntry(nextItem)
  }

  clearMapPulseTimer(): void {
    if (this.mapPulseTimer) {
      clearTimeout(this.mapPulseTimer)
      this.mapPulseTimer = 0
    }
  }

  clearStageFxTimer(): void {
    if (this.stageFxTimer) {
      clearTimeout(this.stageFxTimer)
      this.stageFxTimer = 0
    }
  }

  resetTransientGameUiState(): void {
    this.clearPunchFeedbackTimer()
    this.clearContentCardTimer()
    this.closeContentQuiz(false)
    this.clearMapPulseTimer()
    this.clearStageFxTimer()
    this.setState({
      punchFeedbackVisible: false,
      punchFeedbackText: '',
      punchFeedbackTone: 'neutral',
      punchFeedbackFxClass: '',
      contentCardVisible: false,
      contentCardTemplate: 'story',
      contentCardTitle: '',
      contentCardBody: '',
      contentCardActions: [],
      pendingContentEntryVisible: this.getPendingManualContentCount() > 0,
      pendingContentEntryText: this.buildPendingContentEntryText(),
      contentCardFxClass: '',
      mapPulseVisible: false,
      mapPulseFxClass: '',
      stageFxVisible: false,
      stageFxClass: '',
      punchButtonFxClass: '',
      panelProgressFxClass: '',
      panelDistanceFxClass: '',
    }, true)
    this.currentContentCardPriority = 0
    this.currentContentCard = null
    this.currentContentCardH5Request = null
    this.currentH5ExperienceOpen = false
  }

  resetSessionContentExperienceState(): void {
    this.shownContentCardKeys = {}
    this.consumedContentQuizKeys = {}
    this.rewardedContentQuizKeys = {}
    this.sessionBonusScore = 0
    this.sessionQuizCorrectCount = 0
    this.sessionQuizWrongCount = 0
    this.sessionQuizTimeoutCount = 0
    this.currentContentCardPriority = 0
    this.currentContentCard = null
    this.currentContentCardH5Request = null
    this.pendingContentCards = []
    this.currentH5ExperienceOpen = false
    this.closeContentQuiz(false)
    this.setState({
      pendingContentEntryVisible: false,
      pendingContentEntryText: '',
    })
  }

  clearSessionTimerInterval(): void {
    if (this.sessionTimerInterval) {
      clearInterval(this.sessionTimerInterval)
      this.sessionTimerInterval = 0
    }
  }

  syncSessionTimerText(): void {
    const telemetryPresentation = this.telemetryRuntime.getPresentation()
    this.setState({
      panelTimerText: telemetryPresentation.timerText,
      panelTimerMode: telemetryPresentation.timerMode,
      panelMileageText: telemetryPresentation.mileageText,
      panelActionTagText: this.gamePresentation.hud.actionTagText,
      panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
      panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
      panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
      panelSpeedValueText: telemetryPresentation.speedText,
      panelTelemetryTone: telemetryPresentation.heartRateTone,
      panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText,
      panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText,
      panelHeartRateValueText: telemetryPresentation.heartRateValueText,
      panelHeartRateUnitText: telemetryPresentation.heartRateUnitText,
      panelCaloriesValueText: telemetryPresentation.caloriesValueText,
      panelCaloriesUnitText: telemetryPresentation.caloriesUnitText,
      panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText,
      panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
      panelAccuracyValueText: telemetryPresentation.accuracyValueText,
      panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
    })
  }

  shouldAutoCloseSession(now = Date.now()): boolean {
    const definition = this.gameRuntime.definition
    const state = this.gameRuntime.state
    if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) {
      return false
    }

    return now - state.startedAt >= definition.sessionCloseAfterMs
  }

  handleSessionCloseTimeout(now = Date.now()): void {
    if (!this.shouldAutoCloseSession(now)) {
      return
    }

    this.clearSessionTimerInterval()
    const result = this.gameRuntime.dispatch({
      type: 'session_timed_out',
      at: now,
    })
    this.commitGameResult(result, `已到关门时间，超时结束 (${this.buildVersion})`)
  }

  applyDebugSessionElapsedMs(elapsedMs: number, labelText: string): void {
    const definition = this.gameRuntime.definition
    const state = this.gameRuntime.state
    if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) {
      this.setState({
        statusText: `当前对局未在进行中，无法调整${labelText} (${this.buildVersion})`,
      }, true)
      return
    }

    const boundedElapsedMs = Math.max(0, Math.min(elapsedMs, Math.max(0, definition.sessionCloseAfterMs - 1000)))
    const now = Date.now()
    const nextState = {
      ...state,
      startedAt: now - boundedElapsedMs,
      endedAt: null,
      status: 'running' as const,
    }
    this.gameRuntime.state = nextState
    this.telemetryRuntime.syncGameState(this.gameRuntime.definition, nextState, this.getHudTargetControlId())
    this.updateSessionTimerLoop()
    this.setState({
      ...this.getGameViewPatch(`调试已设置${labelText} (${this.buildVersion})`),
    }, true)
  }

  handleDebugSetSessionRemainingWarning(): void {
    const definition = this.gameRuntime.definition
    if (!definition) {
      return
    }

    this.applyDebugSessionElapsedMs(
      Math.max(0, definition.sessionCloseAfterMs - definition.sessionCloseWarningMs),
      '剩余10分钟',
    )
  }

  handleDebugSetSessionRemainingOneMinute(): void {
    const definition = this.gameRuntime.definition
    if (!definition) {
      return
    }

    this.applyDebugSessionElapsedMs(
      Math.max(0, definition.sessionCloseAfterMs - 60 * 1000),
      '剩余1分钟',
    )
  }

  handleDebugTimeoutSession(): void {
    const definition = this.gameRuntime.definition
    const state = this.gameRuntime.state
    if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) {
      this.setState({
        statusText: `当前对局未在进行中，无法触发超时 (${this.buildVersion})`,
      }, true)
      return
    }

    this.gameRuntime.state = {
      ...state,
      startedAt: Date.now() - definition.sessionCloseAfterMs,
    }
    this.handleSessionCloseTimeout(Date.now())
  }

  updateSessionTimerLoop(): void {
    const gameState = this.gameRuntime.state
    const shouldRun = !!gameState && gameState.status === 'running' && gameState.endedAt === null

    this.syncSessionTimerText()
    if (this.shouldAutoCloseSession()) {
      this.handleSessionCloseTimeout()
      return
    }
    if (!shouldRun) {
      this.clearSessionTimerInterval()
      return
    }

    if (this.sessionTimerInterval) {
      return
    }

    this.sessionTimerInterval = setInterval(() => {
      this.syncSessionTimerText()
      if (this.shouldAutoCloseSession()) {
        this.handleSessionCloseTimeout()
      }
    }, 1000) as unknown as number
  }

  getControlScreenPoint(controlId: string): { x: number; y: number } | null {
    if (!this.gameRuntime.definition || !this.state.stageWidth || !this.state.stageHeight) {
      return null
    }

    const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
    if (!control) {
      return null
    }

    const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
    const screenPoint = worldToScreen({
      centerWorldX: exactCenter.x,
      centerWorldY: exactCenter.y,
      viewportWidth: this.state.stageWidth,
      viewportHeight: this.state.stageHeight,
      visibleColumns: DESIRED_VISIBLE_COLUMNS,
      rotationRad: this.getRotationRad(this.state.rotationDeg),
    }, lonLatToWorldTile(control.point, this.state.zoom), false)

    if (screenPoint.x < -80 || screenPoint.x > this.state.stageWidth + 80 || screenPoint.y < -80 || screenPoint.y > this.state.stageHeight + 80) {
      return null
    }

    return screenPoint
  }

  setPunchButtonFxClass(className: string): void {
    this.setState({
      punchButtonFxClass: className,
    }, true)
  }

  setHudProgressFxClass(className: string): void {
    this.setState({
      panelProgressFxClass: className,
    }, true)
  }

  setHudDistanceFxClass(className: string): void {
    this.setState({
      panelDistanceFxClass: className,
    }, true)
  }

  showMapPulse(controlId: string, motionClass = ''): void {
    const screenPoint = this.getControlScreenPoint(controlId)
    if (!screenPoint) {
      return
    }

    this.clearMapPulseTimer()
    this.setState({
      mapPulseVisible: true,
      mapPulseLeftPx: screenPoint.x,
      mapPulseTopPx: screenPoint.y,
      mapPulseFxClass: motionClass,
    }, true)
    this.mapPulseTimer = setTimeout(() => {
      this.mapPulseTimer = 0
      this.setState({
        mapPulseVisible: false,
        mapPulseFxClass: '',
      }, true)
    }, 820) as unknown as number
  }

  showStageFx(className: string): void {
    if (!className) {
      return
    }

    this.clearStageFxTimer()
    this.setState({
      stageFxVisible: true,
      stageFxClass: className,
    }, true)
    this.stageFxTimer = setTimeout(() => {
      this.stageFxTimer = 0
      this.setState({
        stageFxVisible: false,
        stageFxClass: '',
      }, true)
    }, 760) as unknown as number
  }

  showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning', motionClass = ''): void {
    this.clearPunchFeedbackTimer()
    this.setState({
      punchFeedbackVisible: true,
      punchFeedbackText: text,
      punchFeedbackTone: tone,
      punchFeedbackFxClass: motionClass,
    }, true)
    this.punchFeedbackTimer = setTimeout(() => {
      this.punchFeedbackTimer = 0
      this.setState({
        punchFeedbackVisible: false,
        punchFeedbackFxClass: '',
      }, true)
    }, 1400) as unknown as number
  }

  showContentCard(title: string, body: string, motionClass = '', options?: { contentKey?: string; autoPopup?: boolean; once?: boolean; priority?: number }): void {
    const autoPopup = !options || options.autoPopup !== false
    const once = !!(options && options.once)
    const priority = options && typeof options.priority === 'number' ? options.priority : 0
    const contentKey = options && options.contentKey ? options.contentKey : ''
    const resolved = this.resolveContentControlByKey(contentKey)
    const resolvedCtas = resolved && resolved.control.displayContent ? resolved.control.displayContent.ctas : []
    const h5Request = this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup)
    const entry = {
      template: resolved && resolved.control.displayContent ? resolved.control.displayContent.template : 'story',
      title,
      body,
      motionClass,
      contentKey,
      once,
      priority,
      autoPopup,
      ctas: resolvedCtas,
      h5Request,
    }

    if (once && contentKey && this.shownContentCardKeys[contentKey]) {
      return
    }

    if (!autoPopup) {
      this.enqueueContentCard(entry)
      return
    }

    if (this.currentH5ExperienceOpen) {
      this.enqueueContentCard(entry)
      return
    }

    if (this.state.contentCardVisible) {
      if (priority > this.currentContentCardPriority) {
        this.openContentCardEntry(entry)
        return
      }

      this.enqueueContentCard(entry)
      return
    }

    this.openContentCardEntry(entry)
  }

  closeContentCard(): void {
    this.clearContentCardTimer()
    this.closeContentQuiz(false)
    this.currentContentCardPriority = 0
    this.currentContentCard = null
    this.currentContentCardH5Request = null
    this.currentH5ExperienceOpen = false
    this.setState({
      contentCardVisible: false,
      contentCardTemplate: 'story',
      contentCardTitle: '',
      contentCardBody: '',
      contentCardFxClass: '',
      contentCardActions: [],
    }, true)
    this.flushQueuedContentCards()
  }

  openPendingContentCard(): void {
    if (!this.pendingContentCards.length) {
      return
    }

    let candidateIndex = -1
    let candidatePriority = Number.NEGATIVE_INFINITY
    for (let index = 0; index < this.pendingContentCards.length; index += 1) {
      const item = this.pendingContentCards[index]
      if (item.autoPopup) {
        continue
      }
      if (item.priority > candidatePriority) {
        candidatePriority = item.priority
        candidateIndex = index
      }
    }

    if (candidateIndex < 0) {
      return
    }

    const pending = this.pendingContentCards.splice(candidateIndex, 1)[0]
    this.openContentCardEntry({
      ...pending,
      autoPopup: true,
    })
  }

  handleH5ExperienceClosed(): void {
    this.currentH5ExperienceOpen = false
    this.currentContentCardPriority = 0
    this.currentContentCard = null
    this.currentContentCardH5Request = null
    this.flushQueuedContentCards()
  }

  handleH5ExperienceFallback(fallback: H5ExperienceFallbackPayload): void {
    this.currentH5ExperienceOpen = false
    this.currentContentCardPriority = 0
    this.currentContentCard = null
    this.currentContentCardH5Request = null
    this.openContentCardEntry({
      template: 'story',
      ...fallback,
      ctas: [],
      h5Request: null,
    })
  }

  clearContentExperienceForResultScene(): void {
    this.clearContentCardTimer()
    this.closeContentQuiz(false)
    this.currentContentCardPriority = 0
    this.currentContentCard = null
    this.currentContentCardH5Request = null
    this.currentH5ExperienceOpen = false
    this.pendingContentCards = []
    this.setState({
      contentCardVisible: false,
      contentCardTemplate: 'story',
      contentCardTitle: '',
      contentCardBody: '',
      contentCardFxClass: '',
      contentCardActions: [],
      pendingContentEntryVisible: false,
      pendingContentEntryText: '',
    }, true)
  }

  applyGameEffects(effects: GameEffect[]): string | null {
    this.feedbackDirector.handleEffects(effects)
    this.applyAutoContentQuizEffects(effects)
    if (effects.some((effect) => effect.type === 'session_finished' || effect.type === 'session_timed_out')) {
      this.clearContentExperienceForResultScene()
      if (this.locationController.listening) {
        this.locationController.stop()
      }
      this.setState({
        gpsTracking: false,
        gpsTrackingText: '测试结束，定位已停止',
      }, true)
    }
    this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId())
    this.updateSessionTimerLoop()
    return this.resolveGameStatusText(effects)
  }

  syncGameResultState(result: GameResult): void {
    this.gamePresentation = result.presentation
    this.refreshCourseHeadingFromPresentation()
  }

  resolveAppliedGameStatusText(result: GameResult, fallbackStatusText?: string | null): string | null {
    return this.applyGameEffects(result.effects) || fallbackStatusText || this.resolveGameStatusText(result.effects)
  }

  commitGameResult(
    result: GameResult,
    fallbackStatusText?: string | null,
    extraPatch: Partial<MapEngineViewState> = {},
    syncRenderer = true,
  ): string | null {
    this.syncGameResultState(result)
    const gameStatusText = this.resolveAppliedGameStatusText(result, fallbackStatusText)
    this.setState({
      ...this.getGameViewPatch(gameStatusText),
      ...extraPatch,
    }, true)
    if (syncRenderer) {
      this.syncRenderer()
    }
    return gameStatusText
  }

  handleStartGame(): void {
    if (!this.gameRuntime.definition || !this.gameRuntime.state) {
      this.setState({
        statusText: `当前还没有可开始的路线 (${this.buildVersion})`,
      }, true)
      return
    }

    if (this.gameRuntime.state.status !== 'idle') {
      if (this.gameRuntime.state.status === 'finished' || this.gameRuntime.state.status === 'failed') {
        const reloadedResult = this.loadGameDefinitionFromCourse()
        if (!reloadedResult || !this.gameRuntime.state) {
          return
        }
      } else {
        return
      }
    }

    this.feedbackDirector.reset()
    this.resetTransientGameUiState()
    this.resetSessionContentExperienceState()
    this.clearStartSessionResidue()

    if (!this.locationController.listening) {
      this.locationController.start()
    }

    const startedAt = Date.now()
    const startResult = this.gameRuntime.startSession(startedAt)
    let gameResult = startResult
    if (this.currentGpsPoint) {
      const gpsResult = this.gameRuntime.dispatch({
        type: 'gps_updated',
        at: Date.now(),
        lon: this.currentGpsPoint.lon,
        lat: this.currentGpsPoint.lat,
        accuracyMeters: this.currentGpsAccuracyMeters,
      })
      gameResult = {
        nextState: gpsResult.nextState,
        presentation: gpsResult.presentation,
        effects: [...startResult.effects, ...gpsResult.effects],
      }
    }

    this.courseOverlayVisible = true
    const gameModeText = this.gameMode === 'score-o' ? '积分赛' : '顺序打点'
    const defaultStatusText = this.currentGpsPoint
      ? `${gameModeText}已开始 (${this.buildVersion})`
      : `${gameModeText}已开始，GPS定位启动中 (${this.buildVersion})`
    this.commitGameResult(gameResult, defaultStatusText)
  }

  handleForceExitGame(): void {
    this.feedbackDirector.reset()
    if (this.locationController.listening) {
      this.locationController.stop()
    }

    if (!this.courseData) {
      this.clearGameRuntime()
      this.resetTransientGameUiState()
      this.resetSessionContentExperienceState()
      this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
      this.setState({
        gpsTracking: false,
        gpsTrackingText: '已退出对局，定位已停止',
        ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
      }, true)
      this.syncRenderer()
      return
    }

    this.loadGameDefinitionFromCourse()
    this.resetTransientGameUiState()
    this.resetSessionContentExperienceState()
    this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
    this.setState({
      gpsTracking: false,
      gpsTrackingText: '已退出对局，定位已停止',
      ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
    }, true)
    this.syncRenderer()
  }


  handlePunchAction(): void {
    const currentPoint = this.currentGpsPoint
    const gameResult = this.gameRuntime.dispatch({
      type: 'punch_requested',
      at: Date.now(),
      lon: currentPoint ? currentPoint.lon : null,
      lat: currentPoint ? currentPoint.lat : null,
    })
    this.commitGameResult(gameResult)
  }

  handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
    const nextPoint: LonLatPoint = { lon: longitude, lat: latitude }
    const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
    if (!lastTrackPoint || getApproxDistanceMeters(lastTrackPoint, nextPoint) >= GPS_TRACK_MIN_STEP_METERS) {
      const sampleAt = Date.now()
      this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS)
      this.currentGpsTrackSamples = [...this.currentGpsTrackSamples, { point: nextPoint, at: sampleAt }].slice(-GPS_TRACK_MAX_POINTS)
      this.lastTrackMotionAt = sampleAt
    }

    this.currentGpsPoint = nextPoint
    this.currentGpsAccuracyMeters = accuracyMeters
    this.updateMovementHeadingDeg()

    const gpsWorldPoint = lonLatToWorldTile(nextPoint, this.state.zoom)
    const gpsTileX = Math.floor(gpsWorldPoint.x)
    const gpsTileY = Math.floor(gpsWorldPoint.y)
    const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
    this.currentGpsInsideMap = gpsInsideMap
    let gameStatusText: string | null = null

    if (!gpsInsideMap && this.gpsLockEnabled) {
      this.gpsLockEnabled = false
      gameStatusText = `GPS已超出地图范围，锁定已关闭 (${this.buildVersion})`
    }

    if (this.courseData) {
      const eventAt = Date.now()
      const gameResult = this.gameRuntime.dispatch({
        type: 'gps_updated',
        at: eventAt,
        lon: longitude,
        lat: latitude,
        accuracyMeters,
      })
      this.telemetryRuntime.dispatch({
        type: 'gps_updated',
        at: eventAt,
        lon: longitude,
        lat: latitude,
        accuracyMeters,
      })
      this.syncGameResultState(gameResult)
      gameStatusText = this.resolveAppliedGameStatusText(gameResult)
    }

    if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
      this.scheduleAutoRotate()
    }

    if (gpsInsideMap && (this.gpsLockEnabled || !this.hasGpsCenteredOnce)) {
      this.hasGpsCenteredOnce = true
      const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
      this.commitViewport({
        ...lockedViewport,
        gpsTracking: true,
        gpsTrackingText: '持续定位进行中',
        gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
        autoRotateSourceText: this.getAutoRotateSourceText(),
        gpsLockEnabled: this.gpsLockEnabled,
        gpsLockAvailable: true,
        ...this.getGameViewPatch(),
      }, gameStatusText || (this.gpsLockEnabled ? `GPS锁定跟随中 (${this.buildVersion})` : `GPS定位成功，已定位到当前位置 (${this.buildVersion})`), true)
      return
    }

    this.setState({
      gpsTracking: true,
      gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
      gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
      autoRotateSourceText: this.getAutoRotateSourceText(),
      gpsLockEnabled: this.gpsLockEnabled,
      gpsLockAvailable: gpsInsideMap,
      ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
    })
    this.syncRenderer()
  }

  handleToggleGpsLock(): void {
    if (!this.currentGpsPoint || !this.currentGpsInsideMap) {
      this.setState({
        gpsLockEnabled: false,
        gpsLockAvailable: false,
        statusText: this.currentGpsPoint
          ? `当前位置不在地图范围内，无法锁定 (${this.buildVersion})`
          : `当前还没有可锁定的GPS位置 (${this.buildVersion})`,
      }, true)
      return
    }

    const nextEnabled = !this.gpsLockEnabled
    this.gpsLockEnabled = nextEnabled

    if (nextEnabled) {
      const gpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
      const gpsTileX = Math.floor(gpsWorldPoint.x)
      const gpsTileY = Math.floor(gpsWorldPoint.y)
      const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
      if (gpsInsideMap) {
        this.hasGpsCenteredOnce = true
        const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
        this.commitViewport({
          ...lockedViewport,
          gpsLockEnabled: true,
          gpsLockAvailable: true,
        }, `GPS已锁定在屏幕中央 (${this.buildVersion})`, true)
        return
      }

      this.setState({
        gpsLockEnabled: true,
        gpsLockAvailable: true,
        statusText: `GPS锁定已开启，等待进入地图范围 (${this.buildVersion})`,
      }, true)
      this.syncRenderer()
      return
    }

    this.setState({
      gpsLockEnabled: false,
      gpsLockAvailable: true,
      statusText: `GPS锁定已关闭 (${this.buildVersion})`,
    }, true)
    this.syncRenderer()
  }

  handleToggleOsmReference(): void {
    const nextEnabled = !this.state.osmReferenceEnabled
    this.setState({
      osmReferenceEnabled: nextEnabled,
      osmReferenceText: nextEnabled ? 'OSM参考：开' : 'OSM参考：关',
      statusText: nextEnabled ? `OSM参考底图已开启 (${this.buildVersion})` : `OSM参考底图已关闭 (${this.buildVersion})`,
    }, true)
    this.syncRenderer()
  }

  handleToggleGpsTracking(): void {
    if (this.locationController.listening) {
      this.locationController.stop()
      return
    }

    this.locationController.start()
  }

  handleSetRealLocationMode(): void {
    this.locationController.setSourceMode('real')
  }

  handleSetMockLocationMode(): void {
    const wasListening = this.locationController.listening
    if (!this.locationController.mockBridge.connected && !this.locationController.mockBridge.connecting) {
      this.locationController.connectMockBridge()
    }
    this.locationController.setSourceMode('mock')
    if (!wasListening && !this.locationController.listening) {
      this.locationController.start()
    }
  }

  handleConnectMockLocationBridge(): void {
    this.locationController.connectMockBridge()
  }

  handleDisconnectMockLocationBridge(): void {
    this.locationController.disconnectMockBridge()
  }

  handleSetMockLocationBridgeUrl(url: string): void {
    this.locationController.setMockBridgeUrl(url)
  }

  handleSetMockChannelId(channelId: string): void {
    const normalized = String(channelId || '').trim() || 'default'
    const shouldReconnectLocation = this.locationController.mockBridge.connected || this.locationController.mockBridge.connecting
    const locationBridgeUrl = this.locationController.mockBridgeUrl
    const shouldReconnectHeartRate = this.heartRateController.mockBridge.connected || this.heartRateController.mockBridge.connecting
    const heartRateBridgeUrl = this.heartRateController.mockBridgeUrl
    const shouldReconnectDebugLog = this.mockSimulatorDebugLogger.enabled
    this.locationController.setMockChannelId(normalized)
    this.heartRateController.setMockChannelId(normalized)
    this.mockSimulatorDebugLogger.setChannelId(normalized)
    if (shouldReconnectLocation) {
      this.locationController.disconnectMockBridge()
      this.locationController.connectMockBridge(locationBridgeUrl)
    }
    if (shouldReconnectHeartRate) {
      this.heartRateController.disconnectMockBridge()
      this.heartRateController.connectMockBridge(heartRateBridgeUrl)
    }
    if (shouldReconnectDebugLog) {
      this.mockSimulatorDebugLogger.disconnect()
      this.mockSimulatorDebugLogger.connect()
    }
    this.setState({
      mockChannelIdText: normalized,
    })
  }

  handleSetMockDebugLogBridgeUrl(url: string): void {
    this.mockSimulatorDebugLogger.setUrl(url)
  }

  handleConnectMockDebugLogBridge(): void {
    this.mockSimulatorDebugLogger.connect()
  }

  handleDisconnectMockDebugLogBridge(): void {
    this.mockSimulatorDebugLogger.disconnect()
  }

  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<MapEngineViewState> = {
      mapName: config.configTitle,
      configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`,
      projectionMode: config.projectionModeText,
      tileSource: config.tileSource,
      sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
      compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
      northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
      northReferenceText: formatNorthReferenceText(this.northReferenceMode),
      compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
    }

    if (!this.state.stageWidth || !this.state.stageHeight) {
      this.setState({
        ...statePatch,
        zoom: this.defaultZoom,
        centerTileX: this.defaultCenterTileX,
        centerTileY: this.defaultCenterTileY,
        centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
        statusText: `路线已载入，点击开始进入游戏 (${this.buildVersion})`,
      }, true)
      return
    }

    this.commitViewport({
      ...statePatch,
      zoom: this.defaultZoom,
      centerTileX: this.defaultCenterTileX,
      centerTileY: this.defaultCenterTileY,
      tileTranslateX: 0,
      tileTranslateY: 0,
    }, `路线已载入，点击开始进入游戏 (${this.buildVersion})`, true, () => {
      this.resetPreviewState()
      this.syncRenderer()
      if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
        this.scheduleAutoRotate()
      }
    })
  }

  handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
    this.clearInertiaTimer()
    this.clearPreviewResetTimer()
    this.panVelocityX = 0
    this.panVelocityY = 0

    if (event.touches.length >= 2) {
      const origin = this.gpsLockEnabled
        ? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
        : this.getStagePoint(event.touches)
      this.gestureMode = 'pinch'
      this.pinchStartDistance = this.getTouchDistance(event.touches)
      this.pinchStartScale = this.previewScale || 1
      this.pinchStartAngle = this.getTouchAngle(event.touches)
      this.pinchStartRotationDeg = this.state.rotationDeg
      const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
        ? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
        : screenToWorld(this.getCameraState(), origin, true)
      this.pinchAnchorWorldX = anchorWorld.x
      this.pinchAnchorWorldY = anchorWorld.y
      this.setPreviewState(this.pinchStartScale, origin.x, origin.y)
      this.syncRenderer()
      this.compassController.start()
      return
    }

    if (event.touches.length === 1) {
      this.gestureMode = 'pan'
      this.panLastX = event.touches[0].pageX
      this.panLastY = event.touches[0].pageY
      this.panLastTimestamp = event.timeStamp || Date.now()
      this.tapStartX = event.touches[0].pageX
      this.tapStartY = event.touches[0].pageY
      this.tapStartAt = event.timeStamp || Date.now()
    }
  }

  handleTouchMove(event: WechatMiniprogram.TouchEvent): void {
    if (event.touches.length >= 2) {
      const distance = this.getTouchDistance(event.touches)
      const angle = this.getTouchAngle(event.touches)
      const origin = this.gpsLockEnabled
        ? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
        : this.getStagePoint(event.touches)
      if (!this.pinchStartDistance) {
        this.pinchStartDistance = distance
        this.pinchStartScale = this.previewScale || 1
        this.pinchStartAngle = angle
        this.pinchStartRotationDeg = this.state.rotationDeg
        const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
          ? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
          : screenToWorld(this.getCameraState(), origin, true)
        this.pinchAnchorWorldX = anchorWorld.x
        this.pinchAnchorWorldY = anchorWorld.y
      }

      this.gestureMode = 'pinch'
      const nextRotationDeg = this.state.orientationMode === 'heading-up'
        ? this.state.rotationDeg
        : normalizeRotationDeg(this.pinchStartRotationDeg + normalizeAngleDeltaRad(angle - this.pinchStartAngle) * 180 / Math.PI)
      const anchorOffset = this.getWorldOffsetFromScreen(origin.x, origin.y, nextRotationDeg)
      const resolvedViewport = this.resolveViewportForExactCenter(
        this.pinchAnchorWorldX - anchorOffset.x,
        this.pinchAnchorWorldY - anchorOffset.y,
        nextRotationDeg,
      )
      this.setPreviewState(
        clamp(this.pinchStartScale * (distance / this.pinchStartDistance), MIN_PREVIEW_SCALE, MAX_PREVIEW_SCALE),
        origin.x,
        origin.y,
      )
      this.commitViewport(
        {
          ...resolvedViewport,
          rotationDeg: nextRotationDeg,
          rotationText: formatRotationText(nextRotationDeg),
        },
        this.state.orientationMode === 'heading-up'
          ? `双指缩放中，自动朝向保持开启 (${this.buildVersion})`
          : `双指缩放与旋转中 (${this.buildVersion})`,
      )
      return
    }

    if (this.gestureMode !== 'pan' || event.touches.length !== 1) {
      return
    }
    const touch = event.touches[0]
    const deltaX = touch.pageX - this.panLastX
    const deltaY = touch.pageY - this.panLastY
    const nextTimestamp = event.timeStamp || Date.now()
    const elapsed = Math.max(nextTimestamp - this.panLastTimestamp, 16)
    const instantVelocityX = deltaX / elapsed
    const instantVelocityY = deltaY / elapsed

    this.panVelocityX = this.panVelocityX * 0.72 + instantVelocityX * 0.28
    this.panVelocityY = this.panVelocityY * 0.72 + instantVelocityY * 0.28
    this.panLastX = touch.pageX
    this.panLastY = touch.pageY
    this.panLastTimestamp = nextTimestamp

    if (this.gpsLockEnabled) {
      this.panVelocityX = 0
      this.panVelocityY = 0
      return
    }

    this.normalizeTranslate(
      this.state.tileTranslateX + deltaX,
      this.state.tileTranslateY + deltaY,
      `宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`,
    )
  }

  handleTouchEnd(event: WechatMiniprogram.TouchEvent): void {
    const changedTouch = event.changedTouches && event.changedTouches.length ? event.changedTouches[0] : null
    const endedAsTap = changedTouch
      && this.gestureMode === 'pan'
      && event.touches.length === 0
      && Math.abs(changedTouch.pageX - this.tapStartX) <= MAP_TAP_MOVE_THRESHOLD_PX
      && Math.abs(changedTouch.pageY - this.tapStartY) <= MAP_TAP_MOVE_THRESHOLD_PX
      && ((event.timeStamp || Date.now()) - this.tapStartAt) <= MAP_TAP_DURATION_MS

    if (this.gestureMode === 'pinch' && event.touches.length < 2) {
      const gestureScale = this.previewScale || 1
      const zoomDelta = Math.round(Math.log2(gestureScale))
      const originX = this.gpsLockEnabled ? this.state.stageWidth / 2 : (this.previewOriginX || this.state.stageWidth / 2)
      const originY = this.gpsLockEnabled ? this.state.stageHeight / 2 : (this.previewOriginY || this.state.stageHeight / 2)

      if (zoomDelta) {
        const residualScale = gestureScale / Math.pow(2, zoomDelta)
        this.zoomAroundPoint(zoomDelta, originX, originY, residualScale)
      } else {
        this.animatePreviewToRest()
      }

      this.resetPinchState()
      this.panVelocityX = 0
      this.panVelocityY = 0

      if (event.touches.length === 1) {
        this.gestureMode = 'pan'
        this.panLastX = event.touches[0].pageX
        this.panLastY = event.touches[0].pageY
        this.panLastTimestamp = event.timeStamp || Date.now()
        return
      }

      this.gestureMode = 'idle'
      this.renderer.setAnimationPaused(false)
      this.scheduleAutoRotate()
      return
    }

    if (event.touches.length === 1) {
      this.gestureMode = 'pan'
      this.panLastX = event.touches[0].pageX
      this.panLastY = event.touches[0].pageY
      this.panLastTimestamp = event.timeStamp || Date.now()
      return
    }

    if (this.gestureMode === 'pan' && (Math.abs(this.panVelocityX) >= INERTIA_MIN_SPEED || Math.abs(this.panVelocityY) >= INERTIA_MIN_SPEED)) {
      this.startInertia()
      this.gestureMode = 'idle'
      this.resetPinchState()
      return
    }

    if (endedAsTap && changedTouch) {
      this.handleMapTap(changedTouch.pageX - this.state.stageLeft, changedTouch.pageY - this.state.stageTop)
    }

    this.gestureMode = 'idle'
    this.resetPinchState()
    this.renderer.setAnimationPaused(false)
    this.scheduleAutoRotate()
  }

  handleTouchCancel(): void {
    this.gestureMode = 'idle'
    this.resetPinchState()
    this.panVelocityX = 0
    this.panVelocityY = 0
    this.clearInertiaTimer()
    this.animatePreviewToRest()
    this.renderer.setAnimationPaused(false)
    this.scheduleAutoRotate()
  }

  handleMapTap(stageX: number, stageY: number): void {
    if (!this.gameRuntime.definition || !this.gameRuntime.state) {
      return
    }

    if (this.gameRuntime.definition.mode === 'score-o') {
      const focusedControlId = this.findFocusableControlAt(stageX, stageY)
      if (focusedControlId !== undefined) {
        const gameResult = this.gameRuntime.dispatch({
          type: 'control_focused',
          at: Date.now(),
          controlId: focusedControlId,
        })
        this.commitGameResult(
          gameResult,
          this.buildFocusSelectionStatusText(focusedControlId),
        )
      }
    }

    const contentControlId = this.findContentControlAt(stageX, stageY)
    if (contentControlId) {
      this.openControlClickContent(contentControlId)
    }
  }

  findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
    if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
      return undefined
    }

    const focusableControls = this.gameRuntime.definition.controls.filter((control) => (
      this.gamePresentation.map.focusableControlIds.includes(control.id)
    ))

    let matchedControlId: string | null | undefined
    let matchedDistance = Number.POSITIVE_INFINITY
    const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())

    for (const control of focusableControls) {
      const screenPoint = this.getControlScreenPoint(control.id)
      if (!screenPoint) {
        continue
      }

      const distancePx = Math.sqrt(
        Math.pow(screenPoint.x - stageX, 2)
        + Math.pow(screenPoint.y - stageY, 2),
      )
      if (distancePx <= hitRadiusPx && distancePx < matchedDistance) {
        matchedDistance = distancePx
        matchedControlId = control.id
      }
    }

    if (matchedControlId === undefined) {
      return undefined
    }

    return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId
  }

  buildFocusSelectionStatusText(controlId: string | null): string {
    if (!controlId || !this.gameRuntime.definition) {
      return `已取消目标点选择 (${this.buildVersion})`
    }

    const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
    if (!control) {
      return `已更新目标点选择 (${this.buildVersion})`
    }

    if (control.kind === 'finish') {
      return `已选择终点 ${control.label} (${this.buildVersion})`
    }

    if (control.kind === 'start') {
      return `已选择开始点 ${control.label} (${this.buildVersion})`
    }

    const scoreText = typeof control.score === 'number' ? ` / ${control.score}分` : ''
    return `已选择目标点 ${control.label}${scoreText} (${this.buildVersion})`
  }

  findContentControlAt(stageX: number, stageY: number): string | undefined {
    if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
      return undefined
    }

    let matchedControlId: string | undefined
    let matchedDistance = Number.POSITIVE_INFINITY
    let matchedPriority = Number.NEGATIVE_INFINITY
    const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())

    for (const control of this.gameRuntime.definition.controls) {
      if (
        !control.displayContent
        || (
          !control.displayContent.clickTitle
          && !control.displayContent.clickBody
          && !(control.displayContent.clickExperience && control.displayContent.clickExperience.type === 'h5')
          && !(control.displayContent.contentExperience && control.displayContent.contentExperience.type === 'h5')
        )
      ) {
        continue
      }
      if (!this.isControlTapContentVisible(control)) {
        continue
      }

      const screenPoint = this.getControlScreenPoint(control.id)
      if (!screenPoint) {
        continue
      }

      const distancePx = Math.sqrt(
        Math.pow(screenPoint.x - stageX, 2)
        + Math.pow(screenPoint.y - stageY, 2),
      )
      if (distancePx > hitRadiusPx) {
        continue
      }

      const controlPriority = this.getControlTapContentPriority(control)
      const sameDistance = Math.abs(distancePx - matchedDistance) <= 2
      if (
        distancePx < matchedDistance
        || (sameDistance && controlPriority > matchedPriority)
      ) {
        matchedDistance = distancePx
        matchedPriority = controlPriority
        matchedControlId = control.id
      }
    }

    return matchedControlId
  }

  getControlTapContentPriority(control: { kind: 'start' | 'control' | 'finish'; id: string }): number {
    if (!this.gameRuntime.state || !this.gamePresentation.map) {
      return 0
    }

    const currentTargetControlId = this.gameRuntime.state.currentTargetControlId
    const completedControlIds = this.gameRuntime.state.completedControlIds

    if (currentTargetControlId === control.id) {
      return 100
    }

    if (control.kind === 'start') {
      return completedControlIds.includes(control.id) ? 10 : 90
    }

    if (control.kind === 'finish') {
      return completedControlIds.includes(control.id)
        ? 80
        : (this.gamePresentation.map.completedStart ? 85 : 5)
    }

    return completedControlIds.includes(control.id) ? 40 : 60
  }

  isControlTapContentVisible(control: { kind: 'start' | 'control' | 'finish'; sequence: number | null; id: string }): boolean {
    if (this.gamePresentation.map.revealFullCourse) {
      return true
    }

    if (control.kind === 'start') {
      return this.gamePresentation.map.activeStart || this.gamePresentation.map.completedStart
    }

    if (control.kind === 'finish') {
      return this.gamePresentation.map.activeFinish || this.gamePresentation.map.focusedFinish || this.gamePresentation.map.completedFinish
    }

    if (control.sequence === null) {
      return false
    }

    const readyControlSequences = this.resolveReadyControlSequences()
    return this.gamePresentation.map.activeControlSequences.includes(control.sequence)
      || this.gamePresentation.map.completedControlSequences.includes(control.sequence)
      || this.gamePresentation.map.skippedControlSequences.includes(control.sequence)
      || this.gamePresentation.map.focusedControlSequences.includes(control.sequence)
      || readyControlSequences.includes(control.sequence)
  }

  openControlClickContent(controlId: string): void {
    if (!this.gameRuntime.definition) {
      return
    }

    const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
    if (!control || !control.displayContent) {
      return
    }

    const title = control.displayContent.clickTitle || control.displayContent.title || control.label || '内容体验'
    const body = control.displayContent.clickBody || control.displayContent.body || ''
    if (!title && !body) {
      return
    }

    const entry = this.buildControlContentCardEntry(`${control.id}:click`, {
      title,
      body,
      motionClass: 'game-content-card--fx-pop',
      autoPopup: true,
      once: false,
      priority: control.displayContent.priority,
    })
    if (!entry) {
      return
    }

    this.replaceVisibleContentCard(entry)
  }

  getControlHitRadiusPx(): number {
    if (!this.state.tileSizePx) {
      return 28
    }

    const centerLonLat = worldTileToLonLat({ x: this.state.centerTileX + 0.5, y: this.state.centerTileY + 0.5 }, this.state.zoom)
    const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * 40075016.686 / Math.pow(2, this.state.zoom)
    if (!metersPerTile) {
      return 28
    }

    const pixelsPerMeter = this.state.tileSizePx / metersPerTile
    return Math.max(28, this.cpRadiusMeters * pixelsPerMeter * 1.6)
  }

  handleRecenter(): void {
    this.clearInertiaTimer()
    this.clearPreviewResetTimer()
    this.panVelocityX = 0
    this.panVelocityY = 0
    this.renderer.setAnimationPaused(false)
    this.commitViewport(
      {
        zoom: this.defaultZoom,
        centerTileX: this.defaultCenterTileX,
        centerTileY: this.defaultCenterTileY,
        tileTranslateX: 0,
        tileTranslateY: 0,
      },
      `已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`,
      true,
      () => {
        this.resetPreviewState()
        this.syncRenderer()
        this.compassController.start()
        this.scheduleAutoRotate()
      },
    )
  }

  handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
    if (this.state.rotationMode === 'auto') {
      this.setState({
        statusText: `当前不是手动旋转模式，请先切回手动 (${this.buildVersion})`,
      }, true)
      return
    }

    const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
    const nextRotationDeg = normalizeRotationDeg(this.state.rotationDeg + stepDeg)
    const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)

    this.clearInertiaTimer()
    this.clearPreviewResetTimer()
    this.panVelocityX = 0
    this.panVelocityY = 0
    this.renderer.setAnimationPaused(false)
    this.commitViewport(
      {
        ...resolvedViewport,
        rotationDeg: nextRotationDeg,
        rotationText: formatRotationText(nextRotationDeg),
      },
      `旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
      true,
      () => {
        this.resetPreviewState()
        this.syncRenderer()
        this.compassController.start()
      },
    )
  }

  handleRotationReset(): void {
    if (this.state.rotationMode === 'auto') {
      this.setState({
        statusText: `当前不是手动旋转模式，请先切回手动 (${this.buildVersion})`,
      }, true)
      return
    }

    const targetRotationDeg = MAP_NORTH_OFFSET_DEG
    if (Math.abs(normalizeAngleDeltaDeg(this.state.rotationDeg - targetRotationDeg)) <= 0.01) {
      return
    }

    const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
    const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, targetRotationDeg)

    this.clearInertiaTimer()
    this.clearPreviewResetTimer()
    this.panVelocityX = 0
    this.panVelocityY = 0
    this.renderer.setAnimationPaused(false)
    this.commitViewport(
      {
        ...resolvedViewport,
        rotationDeg: targetRotationDeg,
        rotationText: formatRotationText(targetRotationDeg),
      },
      `旋转角度已回到真北参考 (${this.buildVersion})`,
      true,
      () => {
        this.resetPreviewState()
        this.syncRenderer()
        this.compassController.start()
      },
    )
  }

  handleToggleRotationMode(): void {
    if (this.state.orientationMode === 'manual') {
      this.setNorthUpMode()
      return
    }

    if (this.state.orientationMode === 'north-up') {
      this.setHeadingUpMode()
      return
    }

    this.setManualMode()
  }

  handleSetManualMode(): void {
    this.setManualMode()
  }

  handleSetNorthUpMode(): void {
    this.setNorthUpMode()
  }

  handleSetHeadingUpMode(): void {
    this.setHeadingUpMode()
  }

  handleCycleNorthReferenceMode(): void {
    this.cycleNorthReferenceMode()
  }

  handleSetNorthReferenceMode(mode: NorthReferenceMode): void {
    this.setNorthReferenceMode(mode)
  }

  handleSetAnimationLevel(level: AnimationLevel): void {
    if (this.animationLevel === level) {
      return
    }

    this.animationLevel = level
    this.feedbackDirector.setAnimationLevel(level)
    this.setState({
      animationLevel: level,
      statusText: `动画性能已切换为${formatAnimationLevelText(level)} (${this.buildVersion})`,
    })
    this.syncRenderer()
  }

  handleSetTrackMode(mode: TrackDisplayMode): void {
    if (this.trackStyleConfig.mode === mode) {
      return
    }
    this.trackStyleConfig = {
      ...this.trackStyleConfig,
      mode,
    }
    this.setState({
      trackDisplayMode: mode,
      statusText: `轨迹模式已切换为${formatTrackDisplayModeText(mode)} (${this.buildVersion})`,
    })
    this.syncRenderer()
  }

  playPunchHintHaptic(): void {
    this.feedbackDirector.playHapticCue('hint:changed')
  }

  handleSetTrackTailLength(length: TrackTailLengthPreset): void {
    if (this.trackStyleConfig.tailLength === length) {
      return
    }
    this.trackStyleConfig = {
      ...this.trackStyleConfig,
      tailLength: length,
      tailMeters: TRACK_TAIL_LENGTH_METERS[length],
    }
    this.setState({
      trackTailLength: length,
      statusText: `拖尾长度已切换为${formatTrackTailLengthText(length)} (${this.buildVersion})`,
    })
    this.syncRenderer()
  }

  handleSetTrackColorPreset(colorPreset: TrackColorPreset): void {
    if (this.trackStyleConfig.colorPreset === colorPreset) {
      return
    }
    const palette = TRACK_COLOR_PRESET_MAP[colorPreset]
    this.trackStyleConfig = {
      ...this.trackStyleConfig,
      colorPreset,
      colorHex: palette.colorHex,
      headColorHex: palette.headColorHex,
    }
    this.setState({
      trackColorPreset: colorPreset,
      statusText: `轨迹颜色已切换为${formatTrackColorPresetText(colorPreset)} (${this.buildVersion})`,
    })
    this.syncRenderer()
  }

  handleSetTrackStyleProfile(style: TrackStyleProfile): void {
    if (this.trackStyleConfig.style === style) {
      return
    }
    const nextGlowStrength = style === 'neon'
      ? Math.max(this.trackStyleConfig.glowStrength, 0.18)
      : Math.min(this.trackStyleConfig.glowStrength, 0.08)
    this.trackStyleConfig = {
      ...this.trackStyleConfig,
      style,
      glowStrength: nextGlowStrength,
    }
    this.setState({
      trackStyleProfile: style,
      statusText: `轨迹风格已切换为${style === 'neon' ? '流光' : '经典'} (${this.buildVersion})`,
    })
    this.syncRenderer()
  }

  handleSetGpsMarkerVisible(visible: boolean): void {
    if (this.gpsMarkerStyleConfig.visible === visible) {
      return
    }
    this.gpsMarkerStyleConfig = {
      ...this.gpsMarkerStyleConfig,
      visible,
    }
    this.setState({
      gpsMarkerVisible: visible,
      statusText: `GPS点显示已切换为${visible ? '显示' : '隐藏'} (${this.buildVersion})`,
    })
    this.syncRenderer()
  }

  handleSetGpsMarkerStyle(style: GpsMarkerStyleId): void {
    if (this.gpsMarkerStyleConfig.style === style) {
      return
    }
    this.gpsMarkerStyleConfig = {
      ...this.gpsMarkerStyleConfig,
      style,
    }
    this.setState({
      gpsMarkerStyle: style,
      statusText: `GPS点风格已切换为${formatGpsMarkerStyleText(style)} (${this.buildVersion})`,
    })
    this.syncRenderer()
  }

  handleSetGpsMarkerSize(size: GpsMarkerSizePreset): void {
    if (this.gpsMarkerStyleConfig.size === size) {
      return
    }
    this.gpsMarkerStyleConfig = {
      ...this.gpsMarkerStyleConfig,
      size,
    }
    this.setState({
      gpsMarkerSize: size,
      statusText: `GPS点大小已切换为${formatGpsMarkerSizeText(size)} (${this.buildVersion})`,
    })
    this.syncRenderer()
  }

  handleSetGpsMarkerColorPreset(colorPreset: GpsMarkerColorPreset): void {
    if (this.gpsMarkerStyleConfig.colorPreset === colorPreset) {
      return
    }
    const palette = GPS_MARKER_COLOR_PRESET_MAP[colorPreset]
    this.gpsMarkerStyleConfig = {
      ...this.gpsMarkerStyleConfig,
      colorPreset,
      colorHex: palette.colorHex,
      ringColorHex: palette.ringColorHex,
      indicatorColorHex: palette.indicatorColorHex,
    }
    this.setState({
      gpsMarkerColorPreset: colorPreset,
      statusText: `GPS点颜色已切换为${formatGpsMarkerColorPresetText(colorPreset)} (${this.buildVersion})`,
    })
    this.syncRenderer()
  }

  handleSetCompassTuningProfile(profile: CompassTuningProfile): void {
    if (this.compassTuningProfile === profile) {
      return
    }

    this.compassTuningProfile = profile
    this.compassController.setTuningProfile(profile)
    this.setState({
      compassTuningProfile: profile,
      compassTuningProfileText: formatCompassTuningProfileText(profile),
      statusText: `指北针响应已切换为${formatCompassTuningProfileText(profile)} (${this.buildVersion})`,
    }, true)
  }

  handleAutoRotateCalibrate(): void {
    if (this.state.orientationMode !== 'heading-up') {
      this.setState({
        statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`,
      }, true)
      return
    }

    if (!this.calibrateAutoRotateToCurrentOrientation()) {
      this.setState({
        statusText: `当前还没有传感器方向数据，暂时无法校准 (${this.buildVersion})`,
      }, true)
      return
    }

    this.setState({
      statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`,
    }, true)
    this.scheduleAutoRotate()
  }

  setManualMode(): void {
    this.clearAutoRotateTimer()
    this.targetAutoRotationDeg = null
    this.autoRotateCalibrationPending = false
    this.setState({
      rotationMode: 'manual',
      rotationModeText: formatRotationModeText('manual'),
      rotationToggleText: formatRotationToggleText('manual'),
      orientationMode: 'manual',
      orientationModeText: formatOrientationModeText('manual'),
      autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
      statusText: `已切回手动地图旋转 (${this.buildVersion})`,
    }, true)
  }

  setNorthUpMode(): void {
    this.clearAutoRotateTimer()
    this.targetAutoRotationDeg = null
    this.autoRotateCalibrationPending = false

    const mapNorthOffsetDeg = MAP_NORTH_OFFSET_DEG
    this.autoRotateCalibrationOffsetDeg = mapNorthOffsetDeg
    const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
    const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, mapNorthOffsetDeg)

    this.commitViewport(
      {
        ...resolvedViewport,
        rotationDeg: mapNorthOffsetDeg,
        rotationText: formatRotationText(mapNorthOffsetDeg),
        rotationMode: 'manual',
        rotationModeText: formatRotationModeText('north-up'),
        rotationToggleText: formatRotationToggleText('north-up'),
        orientationMode: 'north-up',
        orientationModeText: formatOrientationModeText('north-up'),
        autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
        northReferenceText: formatNorthReferenceText(this.northReferenceMode),
      },
      `地图已固定为真北朝上 (${this.buildVersion})`,
      true,
      () => {
        this.resetPreviewState()
        this.syncRenderer()
      },
    )
  }

  setHeadingUpMode(): void {
    this.autoRotateCalibrationPending = false
    this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(this.northReferenceMode)
    this.targetAutoRotationDeg = null
    this.setState({
      rotationMode: 'auto',
      rotationModeText: formatRotationModeText('heading-up'),
      rotationToggleText: formatRotationToggleText('heading-up'),
      orientationMode: 'heading-up',
      orientationModeText: formatOrientationModeText('heading-up'),
      autoRotateSourceText: this.getAutoRotateSourceText(),
      autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
      northReferenceText: formatNorthReferenceText(this.northReferenceMode),
      statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
    }, true)
    if (this.refreshAutoRotateTarget()) {
      this.scheduleAutoRotate()
    }
  }

  applyHeadingSample(headingDeg: number, source: 'compass' | 'motion'): void {
    this.compassSource = source
    this.sensorHeadingDeg = normalizeRotationDeg(headingDeg)
    this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null
      ? this.sensorHeadingDeg
      : interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING)

    const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
    if (this.compassDisplayHeadingDeg === null) {
      this.compassDisplayHeadingDeg = compassHeadingDeg
      this.targetCompassDisplayHeadingDeg = compassHeadingDeg
      this.syncCompassDisplayState()
    } else {
      this.targetCompassDisplayHeadingDeg = compassHeadingDeg
      const displayDeltaDeg = Math.abs(normalizeAngleDeltaDeg(compassHeadingDeg - this.compassDisplayHeadingDeg))
      if (displayDeltaDeg >= COMPASS_TUNING_PRESETS[this.compassTuningProfile].displayDeadzoneDeg) {
        this.scheduleCompassNeedleFollow()
      }
    }

    this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
    this.setState({
      compassSourceText: formatCompassSourceText(this.compassSource),
      ...(this.diagnosticUiEnabled
        ? {
            ...this.getTelemetrySensorViewPatch(),
            northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
            autoRotateSourceText: this.getAutoRotateSourceText(),
            northReferenceText: formatNorthReferenceText(this.northReferenceMode),
          }
        : {}),
    })

    if (!this.refreshAutoRotateTarget()) {
      return
    }

    if (this.state.orientationMode === 'heading-up') {
      this.scheduleAutoRotate()
    }
  }

  handleCompassHeading(headingDeg: number): void {
    this.lastCompassSampleAt = Date.now()
    this.clearCompassBootstrapRetryTimer()
    this.applyHeadingSample(headingDeg, 'compass')
  }

  handleCompassError(message: string): void {
    this.clearAutoRotateTimer()
    this.clearCompassNeedleTimer()
    this.targetAutoRotationDeg = null
    this.autoRotateCalibrationPending = false
    this.compassSource = null
    this.targetCompassDisplayHeadingDeg = null
    this.setState({
      compassSourceText: formatCompassSourceText(null),
      autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
      statusText: `${message} (${this.buildVersion})`,
    }, true)
  }

  cycleNorthReferenceMode(): void {
    this.setNorthReferenceMode(getNextNorthReferenceMode(this.northReferenceMode))
  }

  setNorthReferenceMode(nextMode: NorthReferenceMode): void {
    if (nextMode === this.northReferenceMode) {
      return
    }

    const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
    const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
      ? null
      : getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg)

    this.northReferenceMode = nextMode
    this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg
    this.compassDisplayHeadingDeg = compassHeadingDeg
    this.targetCompassDisplayHeadingDeg = compassHeadingDeg

    if (this.state.orientationMode === 'north-up') {
      const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
      const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, MAP_NORTH_OFFSET_DEG)
      this.commitViewport(
        {
          ...resolvedViewport,
          rotationDeg: MAP_NORTH_OFFSET_DEG,
          rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
          northReferenceText: formatNorthReferenceText(nextMode),
          sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
          ...this.getTelemetrySensorViewPatch(),
          compassDeclinationText: formatCompassDeclinationText(nextMode),
          northReferenceMode: nextMode,
          northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
          compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
          autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
        },
        `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
        true,
        () => {
          this.resetPreviewState()
          this.syncRenderer()
        },
      )
      return
    }

    this.setState({
      northReferenceText: formatNorthReferenceText(nextMode),
      sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
      ...this.getTelemetrySensorViewPatch(),
      compassDeclinationText: formatCompassDeclinationText(nextMode),
      northReferenceMode: nextMode,
      northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
      compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
      autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
      statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
    }, true)

    if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
      this.scheduleAutoRotate()
    }

    if (this.compassDisplayHeadingDeg !== null) {
      this.syncCompassDisplayState()
    }
  }

  setCourseHeading(headingDeg: number | null): void {
    this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg)
    this.setState({
      autoRotateSourceText: this.getAutoRotateSourceText(),
    })

    if (this.refreshAutoRotateTarget()) {
      this.scheduleAutoRotate()
    }
  }

  getRawMovementHeadingDeg(): number | null {
    if (!this.currentGpsInsideMap) {
      return null
    }

    if (this.currentGpsAccuracyMeters !== null && this.currentGpsAccuracyMeters > SMART_HEADING_MAX_ACCURACY_METERS) {
      return null
    }

    if (this.currentGpsTrack.length < 2) {
      return null
    }

    const lastPoint = this.currentGpsTrack[this.currentGpsTrack.length - 1]
    let accumulatedDistanceMeters = 0

    for (let index = this.currentGpsTrack.length - 2; index >= 0; index -= 1) {
      const nextPoint = this.currentGpsTrack[index + 1]
      const point = this.currentGpsTrack[index]
      accumulatedDistanceMeters += getApproxDistanceMeters(point, nextPoint)

      if (accumulatedDistanceMeters >= SMART_HEADING_MIN_DISTANCE_METERS) {
        return getInitialBearingDeg(point, lastPoint)
      }
    }

    return null
  }

  updateMovementHeadingDeg(): void {
    const rawMovementHeadingDeg = this.getRawMovementHeadingDeg()
    if (rawMovementHeadingDeg === null) {
      this.smoothedMovementHeadingDeg = null
      return
    }

    const smoothingFactor = getMovementHeadingSmoothingFactor(this.telemetryRuntime.state.currentSpeedKmh)
    this.smoothedMovementHeadingDeg = this.smoothedMovementHeadingDeg === null
      ? rawMovementHeadingDeg
      : interpolateAngleDeg(this.smoothedMovementHeadingDeg, rawMovementHeadingDeg, smoothingFactor)
  }

  getTrackFadeFactor(now: number): number {
    if (this.trackStyleConfig.mode !== 'tail' || !this.trackStyleConfig.fadeOutWhenStill) {
      return 1
    }

    const currentSpeedKmh = this.telemetryRuntime.state.currentSpeedKmh || 0
    if (currentSpeedKmh > this.trackStyleConfig.stillSpeedKmh) {
      return 1
    }

    if (!this.lastTrackMotionAt) {
      return 1
    }

    const elapsedMs = Math.max(0, now - this.lastTrackMotionAt)
    const fadeDurationMs = Math.max(1, this.trackStyleConfig.fadeOutDurationMs)
    return Math.max(0, 1 - elapsedMs / fadeDurationMs)
  }

  getDynamicTailMeters(): number {
    const speedKmh = Math.max(0, this.telemetryRuntime.state.currentSpeedKmh || 0)
    const speedFactor = Math.max(0.35, Math.min(1.8, 0.4 + speedKmh / 6))
    return this.trackStyleConfig.tailMeters * speedFactor
  }

  buildTrackStyleConfigForScene(): TrackVisualizationConfig {
    const base = this.trackStyleConfig
    const speedKmh = Math.max(0, this.telemetryRuntime.state.currentSpeedKmh || 0)
    const speedIntensity = clampNumber(speedKmh / 14, 0, 1)
    const toneBoost = this.state.panelTelemetryTone === 'red'
      ? 0.24
      : this.state.panelTelemetryTone === 'orange'
        ? 0.16
        : this.state.panelTelemetryTone === 'yellow'
          ? 0.08
          : 0
    const brighten = clampNumber(speedIntensity * 0.34 + toneBoost, 0, 0.42)
    const liteGlowFactor = this.state.animationLevel === 'lite' ? 0.58 : 1
    const liteWidthFactor = this.state.animationLevel === 'lite' ? 0.88 : 1
    return {
      ...base,
      colorHex: mixHexColor(base.colorHex, '#ffffff', brighten * 0.62),
      headColorHex: mixHexColor(base.headColorHex, '#ffffff', brighten),
      widthPx: Math.max(2.6, base.widthPx * liteWidthFactor),
      headWidthPx: Math.max(4.8, base.headWidthPx * liteWidthFactor),
      glowStrength: clampNumber((base.glowStrength + speedIntensity * 0.18 + toneBoost * 0.9) * liteGlowFactor, 0, 1.2),
    }
  }

  buildGpsMarkerStyleConfigForScene(): GpsMarkerStyleConfig {
      const headingConfidence = this.telemetryRuntime.state.headingConfidence
      const headingAlpha = headingConfidence === 'high'
        ? 1
        : headingConfidence === 'medium'
          ? 0.72
          : 0.42
      const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
      const safeSpeedKmh = speedKmh !== null && Number.isFinite(speedKmh)
        ? Math.max(0, speedKmh)
        : 0
      const tone = this.state.panelTelemetryTone
      const toneScale = tone === 'red'
          ? 1.3
          : tone === 'orange'
            ? 1.2
          : tone === 'yellow'
            ? 1.1
            : 1
    const tonePulseBoost = tone === 'red'
        ? 0.68
        : tone === 'orange'
          ? 0.4
          : tone === 'yellow'
            ? 0.18
            : 0
    const toneMixTarget = tone === 'red'
        ? '#ff3c6a'
        : tone === 'orange'
          ? '#ff8a2d'
          : tone === 'yellow'
            ? '#ffe15a'
            : '#ffffff'
      const toneMix = tone === 'red'
          ? 0.48
          : tone === 'orange'
            ? 0.32
            : tone === 'yellow'
              ? 0.18
              : 0
      const litePulseFactor = this.animationLevel === 'lite' ? 0.65 : 1
      const movingBlend = Math.max(0, Math.min(1, (safeSpeedKmh - 1.0) / 3.2))
      const fastBlend = Math.max(0, Math.min(1, (safeSpeedKmh - 6.8) / 3.4))
      const warningBlend = tone === 'red'
        ? 1
        : tone === 'orange'
          ? 0.72
          : tone === 'yellow'
            ? 0.28
            : 0
      const motionState = warningBlend >= 0.68
        ? 'warning'
        : safeSpeedKmh >= 6.8
          ? 'fast-moving'
          : safeSpeedKmh >= 1.0
            ? 'moving'
            : 'idle'
      const motionIntensityBase = motionState === 'idle'
        ? Math.max(0, Math.min(0.2, safeSpeedKmh / 5))
        : motionState === 'moving'
          ? 0.38 + movingBlend * 0.34
          : motionState === 'fast-moving'
            ? 0.76 + fastBlend * 0.24
            : 0.58 + Math.max(warningBlend * 0.3, fastBlend * 0.16)
      const profile = this.gpsMarkerStyleConfig.animationProfile
      const profileGain = profile === 'minimal'
        ? 0.72
        : profile === 'warning-reactive'
          ? 1.08
          : 1
      const motionIntensity = Math.max(0, Math.min(1.2, motionIntensityBase * profileGain))
      const statePulseBoost = motionState === 'idle'
        ? 0.06
        : motionState === 'moving'
          ? 0.24 + movingBlend * 0.12
          : motionState === 'fast-moving'
            ? 0.48 + fastBlend * 0.18
            : 0.42 + warningBlend * 0.24
      const wakeStrength = profile === 'minimal'
        ? (motionState === 'idle' ? 0 : motionState === 'moving' ? 0.14 + movingBlend * 0.08 : motionState === 'fast-moving' ? 0.28 + fastBlend * 0.16 : 0.18 + warningBlend * 0.16)
        : motionState === 'idle'
          ? 0
          : motionState === 'moving'
            ? 0.24 + movingBlend * 0.16
            : motionState === 'fast-moving'
              ? 0.52 + fastBlend * 0.24
              : 0.3 + warningBlend * 0.24
      const warningGlowStrength = Math.max(
        0,
        Math.min(
          1,
          (warningBlend * (profile === 'warning-reactive' ? 1.12 : 0.9))
            * (this.animationLevel === 'lite' ? 0.72 : 1),
        ),
      )
      const dynamicEffectScale = motionState === 'idle'
        ? 0.98 + motionIntensity * 0.04
        : motionState === 'moving'
          ? 1.03 + movingBlend * 0.06
          : motionState === 'fast-moving'
            ? 1.1 + fastBlend * 0.12
            : 1.08 + warningBlend * 0.08
      const indicatorScale = motionState === 'idle'
        ? 0.96
        : motionState === 'moving'
          ? 1.08
          : motionState === 'fast-moving'
            ? 1.18
            : 1.1
      const logoScale = motionState === 'idle'
        ? 0.96
        : motionState === 'moving'
          ? 1
          : motionState === 'fast-moving'
            ? 1.06
            : 1
      return {
        ...this.gpsMarkerStyleConfig,
        colorHex: mixHexColor(this.gpsMarkerStyleConfig.colorHex, toneMixTarget, toneMix),
        indicatorColorHex: mixHexColor(this.gpsMarkerStyleConfig.indicatorColorHex, '#ffffff', Math.min(0.22, toneMix + 0.06)),
        motionState,
        motionIntensity,
        pulseStrength: (this.gpsMarkerStyleConfig.pulseStrength + tonePulseBoost + statePulseBoost) * litePulseFactor,
        headingAlpha: Math.max(
          headingAlpha,
          motionState === 'fast-moving' ? 0.72 : motionState === 'moving' ? 0.56 : motionState === 'warning' ? 0.66 : 0.42,
        ),
        effectScale: toneScale * dynamicEffectScale,
        wakeStrength,
        warningGlowStrength,
        indicatorScale,
        logoScale,
        showHeadingIndicator: this.gpsMarkerStyleConfig.showHeadingIndicator && this.compassDisplayHeadingDeg !== null,
      }
    }

  buildTrackPointsForScene(): LonLatPoint[] {
    if (this.trackStyleConfig.mode === 'none') {
      return []
    }

    if (this.trackStyleConfig.mode === 'full') {
      return this.currentGpsTrack
    }

    if (this.currentGpsTrackSamples.length < 2) {
      return this.currentGpsTrack
    }

    const now = Date.now()
    const fadeFactor = this.getTrackFadeFactor(now)
    if (fadeFactor <= 0.02) {
      return []
    }

    const effectiveTailMeters = Math.max(4, this.getDynamicTailMeters() * fadeFactor)
    const cutoffAt = this.trackStyleConfig.tailMaxSeconds > 0
      ? now - this.trackStyleConfig.tailMaxSeconds * 1000
      : 0
    const samples = this.currentGpsTrackSamples
    const collected: GpsTrackSample[] = [samples[samples.length - 1]]
    let accumulatedDistanceMeters = 0

    for (let index = samples.length - 2; index >= 0; index -= 1) {
      const nextSample = samples[index + 1]
      const sample = samples[index]
      if (cutoffAt && sample.at < cutoffAt) {
        break
      }

      accumulatedDistanceMeters += getApproxDistanceMeters(sample.point, nextSample.point)
      collected.unshift(sample)
      if (accumulatedDistanceMeters >= effectiveTailMeters) {
        break
      }
    }

    return collected.map((sample) => sample.point)
  }

  getMovementHeadingDeg(): number | null {
    return this.smoothedMovementHeadingDeg
  }

  getPreferredSensorHeadingDeg(): number | null {
    return this.smoothedSensorHeadingDeg === null
      ? null
      : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
  }

  getSmartAutoRotateHeadingDeg(): number | null {
    const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
    const movementHeadingDeg = this.getMovementHeadingDeg()
    const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
    const smartSource = resolveSmartHeadingSource(speedKmh, movementHeadingDeg !== null)

    if (smartSource === 'movement') {
      return movementHeadingDeg === null ? sensorHeadingDeg : movementHeadingDeg
    }

    if (smartSource === 'blended' && sensorHeadingDeg !== null && movementHeadingDeg !== null && speedKmh !== null) {
      const blend = Math.max(0, Math.min(1, (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH) / (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH)))
      return interpolateAngleDeg(sensorHeadingDeg, movementHeadingDeg, blend)
    }

    return sensorHeadingDeg === null ? movementHeadingDeg : sensorHeadingDeg
  }

  getAutoRotateSourceText(): string {
    if (this.autoRotateSourceMode !== 'smart') {
      return formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null)
    }

    const smartSource = resolveSmartHeadingSource(
      this.telemetryRuntime.state.currentSpeedKmh,
      this.getMovementHeadingDeg() !== null,
    )
    return formatSmartHeadingSourceText(smartSource)
  }

  resolveAutoRotateInputHeadingDeg(): number | null {
    if (this.autoRotateSourceMode === 'smart') {
      return this.getSmartAutoRotateHeadingDeg()
    }

    const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
    const courseHeadingDeg = this.courseHeadingDeg === null
      ? null
      : getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg)

    if (this.autoRotateSourceMode === 'sensor') {
      return sensorHeadingDeg
    }

    if (this.autoRotateSourceMode === 'course') {
      return courseHeadingDeg === null ? sensorHeadingDeg : courseHeadingDeg
    }

    if (sensorHeadingDeg !== null && courseHeadingDeg !== null) {
      return interpolateAngleDeg(sensorHeadingDeg, courseHeadingDeg, 0.35)
    }

    return sensorHeadingDeg === null ? courseHeadingDeg : sensorHeadingDeg
  }

  calibrateAutoRotateToCurrentOrientation(): boolean {
    const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
    if (inputHeadingDeg === null) {
      return false
    }

    this.autoRotateCalibrationOffsetDeg = normalizeRotationDeg(this.state.rotationDeg + inputHeadingDeg)
    this.autoRotateCalibrationPending = false
    this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
    this.setState({
      autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
    })
    return true
  }

  refreshAutoRotateTarget(): boolean {
    const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
    if (inputHeadingDeg === null) {
      return false
    }

    if (this.autoRotateCalibrationPending || this.autoRotateCalibrationOffsetDeg === null) {
      if (!this.calibrateAutoRotateToCurrentOrientation()) {
        return false
      }
      return true
    }

    this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
    this.setState({
      autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
    })
    return true
  }

  scheduleAutoRotate(): void {
    if (this.autoRotateTimer || this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
      return
    }

    const step = () => {
      this.autoRotateTimer = 0

      if (this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
        return
      }

      if (this.gestureMode !== 'idle' || this.inertiaTimer || this.previewResetTimer) {
        this.scheduleAutoRotate()
        return
      }

      const currentRotationDeg = this.state.rotationDeg
      const deltaDeg = normalizeAngleDeltaDeg(this.targetAutoRotationDeg - currentRotationDeg)
      if (Math.abs(deltaDeg) <= AUTO_ROTATE_SNAP_DEG) {
        if (Math.abs(deltaDeg) > 0.01) {
          this.applyAutoRotation(this.targetAutoRotationDeg)
        }
        this.scheduleAutoRotate()
        return
      }

      if (Math.abs(deltaDeg) <= AUTO_ROTATE_DEADZONE_DEG) {
        this.scheduleAutoRotate()
        return
      }

      const easedStepDeg = clamp(deltaDeg * AUTO_ROTATE_EASE, -AUTO_ROTATE_MAX_STEP_DEG, AUTO_ROTATE_MAX_STEP_DEG)
      this.applyAutoRotation(normalizeRotationDeg(currentRotationDeg + easedStepDeg))
      this.scheduleAutoRotate()
    }

    this.autoRotateTimer = setTimeout(step, AUTO_ROTATE_FRAME_MS) as unknown as number
  }

  applyAutoRotation(nextRotationDeg: number): void {
    const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
    const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)

    this.setState({
      ...resolvedViewport,
      rotationDeg: nextRotationDeg,
      rotationText: formatRotationText(nextRotationDeg),
      centerText: buildCenterText(this.state.zoom, resolvedViewport.centerTileX, resolvedViewport.centerTileY),
    })
    this.syncRenderer()
  }

  applyStats(stats: MapRendererStats): void {
    const statsPatch = {
      visibleTileCount: stats.visibleTileCount,
      readyTileCount: stats.readyTileCount,
      memoryTileCount: stats.memoryTileCount,
      diskTileCount: stats.diskTileCount,
      memoryHitCount: stats.memoryHitCount,
      diskHitCount: stats.diskHitCount,
      networkFetchCount: stats.networkFetchCount,
      cacheHitRateText: formatCacheHitRate(stats.memoryHitCount, stats.diskHitCount, stats.networkFetchCount),
    }

    if (!this.diagnosticUiEnabled) {
      this.state = {
        ...this.state,
        ...statsPatch,
      }
      return
    }

    const now = Date.now()
    if (now - this.lastStatsUiSyncAt < 500) {
      this.state = {
        ...this.state,
        ...statsPatch,
      }
      return
    }

    this.lastStatsUiSyncAt = now
    this.setState(statsPatch)
  }

  setState(patch: Partial<MapEngineViewState>, immediateUi = false): void {
    this.state = {
      ...this.state,
      ...patch,
    }

    const viewPatch = this.pickViewPatch(patch)
    if (!Object.keys(viewPatch).length) {
      return
    }

    this.pendingViewPatch = {
      ...this.pendingViewPatch,
      ...viewPatch,
    }

    if (immediateUi) {
      this.flushViewPatch()
      return
    }

    if (this.viewSyncTimer) {
      return
    }

    this.viewSyncTimer = setTimeout(() => {
      this.viewSyncTimer = 0
      this.flushViewPatch()
    }, UI_SYNC_INTERVAL_MS) as unknown as number
  }
  commitViewport(
    patch: Partial<MapEngineViewState>,
    statusText: string,
    immediateUi = false,
    afterUpdate?: () => void,
  ): void {
    const nextZoom = typeof patch.zoom === 'number' ? patch.zoom : this.state.zoom
    const nextCenterTileX = typeof patch.centerTileX === 'number' ? patch.centerTileX : this.state.centerTileX
    const nextCenterTileY = typeof patch.centerTileY === 'number' ? patch.centerTileY : this.state.centerTileY
    const nextStageWidth = typeof patch.stageWidth === 'number' ? patch.stageWidth : this.state.stageWidth
    const nextStageHeight = typeof patch.stageHeight === 'number' ? patch.stageHeight : this.state.stageHeight
    const tileSizePx = getTileSizePx({
      centerWorldX: nextCenterTileX,
      centerWorldY: nextCenterTileY,
      viewportWidth: nextStageWidth,
      viewportHeight: nextStageHeight,
      visibleColumns: DESIRED_VISIBLE_COLUMNS,
    })

    this.setState({
      ...patch,
      tileSizePx,
      centerText: buildCenterText(nextZoom, nextCenterTileX, nextCenterTileY),
      statusText,
    }, immediateUi)
    this.syncRenderer()
    this.compassController.start()
    if (afterUpdate) {
      afterUpdate()
    }
  }

  buildScene() {
    const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
    const readyControlSequences = this.resolveReadyControlSequences()
    const controlScoresBySequence: Record<number, number> = {}
    const controlStyleOverridesBySequence: Record<number, ControlPointStyleEntry> = {}
    const startStyleOverrides: ControlPointStyleEntry[] = []
    const finishStyleOverrides: ControlPointStyleEntry[] = []
    const gpsMarkerStyleConfig = this.buildGpsMarkerStyleConfigForScene()
    if (this.gameRuntime.definition) {
      for (let index = 0; index < this.gameRuntime.definition.controls.length; index += 1) {
        const control = this.gameRuntime.definition.controls[index]
        if (control.sequence !== null && control.score !== null) {
          controlScoresBySequence[control.sequence] = control.score
        }
        const styleOverride = this.controlPointStyleOverrides[control.id]
        if (!styleOverride) {
          continue
        }
        if (control.kind === 'control' && control.sequence !== null) {
          controlStyleOverridesBySequence[control.sequence] = styleOverride
          continue
        }
        if (control.kind === 'start') {
          const startIndexMatch = control.id.match(/^start-(\d+)$/)
          if (startIndexMatch) {
            startStyleOverrides[Math.max(0, Number(startIndexMatch[1]) - 1)] = styleOverride
          }
          continue
        }
        if (control.kind === 'finish') {
          const finishIndexMatch = control.id.match(/^finish-(\d+)$/)
          if (finishIndexMatch) {
            finishStyleOverrides[Math.max(0, Number(finishIndexMatch[1]) - 1)] = styleOverride
          }
        }
      }
    }
    return {
      tileSource: this.state.tileSource,
      osmTileSource: OSM_TILE_SOURCE,
      zoom: this.state.zoom,
      centerTileX: this.state.centerTileX,
      centerTileY: this.state.centerTileY,
      exactCenterWorldX: exactCenter.x,
      exactCenterWorldY: exactCenter.y,
      tileBoundsByZoom: this.tileBoundsByZoom,
      viewportWidth: this.state.stageWidth,
      viewportHeight: this.state.stageHeight,
      visibleColumns: DESIRED_VISIBLE_COLUMNS,
      overdraw: OVERDRAW,
      translateX: this.state.tileTranslateX,
      translateY: this.state.tileTranslateY,
      rotationRad: this.getRotationRad(this.state.rotationDeg),
      animationLevel: this.state.animationLevel,
      previewScale: this.previewScale || 1,
      previewOriginX: this.previewOriginX || this.state.stageWidth / 2,
      previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
      trackMode: this.trackStyleConfig.mode,
      trackStyleConfig: this.buildTrackStyleConfigForScene(),
      track: this.buildTrackPointsForScene(),
      gpsPoint: this.currentGpsPoint,
      gpsMarkerStyleConfig,
      gpsHeadingDeg: this.compassDisplayHeadingDeg,
      gpsHeadingAlpha: gpsMarkerStyleConfig.headingAlpha,
      gpsCalibration: GPS_MAP_CALIBRATION,
      gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
      course: this.courseOverlayVisible ? this.courseData : null,
      cpRadiusMeters: this.cpRadiusMeters,
      gameMode: this.gameMode,
      courseStyleConfig: this.courseStyleConfig,
      controlScoresBySequence,
      defaultControlStyleOverride: this.defaultControlPointStyleOverride,
      controlStyleOverridesBySequence,
      startStyleOverrides,
      finishStyleOverrides,
      defaultLegStyleOverride: this.defaultLegStyleOverride,
      legStyleOverridesByIndex: this.legStyleOverrides,
      controlVisualMode: this.gamePresentation.map.controlVisualMode,
      showCourseLegs: this.gamePresentation.map.showCourseLegs,
      guidanceLegAnimationEnabled: this.gamePresentation.map.guidanceLegAnimationEnabled,
      focusableControlIds: this.gamePresentation.map.focusableControlIds,
      focusedControlId: this.gamePresentation.map.focusedControlId,
      focusedControlSequences: this.gamePresentation.map.focusedControlSequences,
      activeControlSequences: this.gamePresentation.map.activeControlSequences,
      readyControlSequences,
      activeStart: this.gamePresentation.map.activeStart,
      completedStart: this.gamePresentation.map.completedStart,
      activeFinish: this.gamePresentation.map.activeFinish,
      focusedFinish: this.gamePresentation.map.focusedFinish,
      completedFinish: this.gamePresentation.map.completedFinish,
      revealFullCourse: this.gamePresentation.map.revealFullCourse,
      activeLegIndices: this.gamePresentation.map.activeLegIndices,
      completedLegIndices: this.gamePresentation.map.completedLegIndices,
      completedControlSequences: this.gamePresentation.map.completedControlSequences,
      skippedControlIds: this.gamePresentation.map.skippedControlIds,
      skippedControlSequences: this.gamePresentation.map.skippedControlSequences,
      osmReferenceEnabled: this.state.osmReferenceEnabled,
      overlayOpacity: MAP_OVERLAY_OPACITY,
    }
  }

  resolveReadyControlSequences(): number[] {
    const punchableControlId = this.gamePresentation.hud.punchableControlId
    const definition = this.gameRuntime.definition
    if (!punchableControlId || !definition) {
      return []
    }

    const control = definition.controls.find((item) => item.id === punchableControlId)
    if (!control || control.sequence === null) {
      return []
    }

    return [control.sequence]
  }

  syncRenderer(): void {
    if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) {
      return
    }

    this.renderer.updateScene(this.buildScene())
  }

  getCameraState(rotationDeg = this.state.rotationDeg): CameraState {
    return {
      centerWorldX: this.state.centerTileX + 0.5,
      centerWorldY: this.state.centerTileY + 0.5,
      viewportWidth: this.state.stageWidth,
      viewportHeight: this.state.stageHeight,
      visibleColumns: DESIRED_VISIBLE_COLUMNS,
      translateX: this.state.tileTranslateX,
      translateY: this.state.tileTranslateY,
      rotationRad: this.getRotationRad(rotationDeg),
    }
  }

  getRotationRad(rotationDeg = this.state.rotationDeg): number {
    return normalizeRotationDeg(rotationDeg) * Math.PI / 180
  }

  getBaseCamera(centerTileX = this.state.centerTileX, centerTileY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState {
    return {
      centerWorldX: centerTileX + 0.5,
      centerWorldY: centerTileY + 0.5,
      viewportWidth: this.state.stageWidth,
      viewportHeight: this.state.stageHeight,
      visibleColumns: DESIRED_VISIBLE_COLUMNS,
      rotationRad: this.getRotationRad(rotationDeg),
    }
  }

  getWorldOffsetFromScreen(stageX: number, stageY: number, rotationDeg = this.state.rotationDeg): { x: number; y: number } {
    const baseCamera = {
      centerWorldX: 0,
      centerWorldY: 0,
      viewportWidth: this.state.stageWidth,
      viewportHeight: this.state.stageHeight,
      visibleColumns: DESIRED_VISIBLE_COLUMNS,
      rotationRad: this.getRotationRad(rotationDeg),
    }

    return screenToWorld(baseCamera, { x: stageX, y: stageY }, false)
  }

  getExactCenterFromTranslate(translateX: number, translateY: number): { x: number; y: number } {
    if (!this.state.stageWidth || !this.state.stageHeight) {
      return {
        x: this.state.centerTileX + 0.5,
        y: this.state.centerTileY + 0.5,
      }
    }

    const screenCenterX = this.state.stageWidth / 2
    const screenCenterY = this.state.stageHeight / 2
    return screenToWorld(this.getBaseCamera(), {
      x: screenCenterX - translateX,
      y: screenCenterY - translateY,
    }, false)
  }

  resolveViewportForExactCenter(centerWorldX: number, centerWorldY: number, rotationDeg = this.state.rotationDeg): {
    centerTileX: number
    centerTileY: number
    tileTranslateX: number
    tileTranslateY: number
  } {
    const nextCenterTileX = Math.floor(centerWorldX)
    const nextCenterTileY = Math.floor(centerWorldY)

    if (!this.state.stageWidth || !this.state.stageHeight) {
      return {
        centerTileX: nextCenterTileX,
        centerTileY: nextCenterTileY,
        tileTranslateX: 0,
        tileTranslateY: 0,
      }
    }

    const roundedCamera = this.getBaseCamera(nextCenterTileX, nextCenterTileY, rotationDeg)
    const projectedCenter = worldToScreen(roundedCamera, { x: centerWorldX, y: centerWorldY }, false)

    return {
      centerTileX: nextCenterTileX,
      centerTileY: nextCenterTileY,
      tileTranslateX: this.state.stageWidth / 2 - projectedCenter.x,
      tileTranslateY: this.state.stageHeight / 2 - projectedCenter.y,
    }
  }

  setPreviewState(scale: number, originX: number, originY: number): void {
    this.previewScale = scale
    this.previewOriginX = originX
    this.previewOriginY = originY
    this.setState({
      previewScale: scale,
    }, true)
  }

  resetPreviewState(): void {
    this.setPreviewState(1, this.state.stageWidth / 2, this.state.stageHeight / 2)
  }

  resetPinchState(): void {
    this.pinchStartDistance = 0
    this.pinchStartScale = 1
    this.pinchStartAngle = 0
    this.pinchStartRotationDeg = this.state.rotationDeg
    this.pinchAnchorWorldX = 0
    this.pinchAnchorWorldY = 0
  }

  clearPreviewResetTimer(): void {
    if (this.previewResetTimer) {
      clearTimeout(this.previewResetTimer)
      this.previewResetTimer = 0
    }
  }

  clearInertiaTimer(): void {
    if (this.inertiaTimer) {
      clearTimeout(this.inertiaTimer)
      this.inertiaTimer = 0
    }
  }

  clearViewSyncTimer(): void {
    if (this.viewSyncTimer) {
      clearTimeout(this.viewSyncTimer)
      this.viewSyncTimer = 0
    }
  }

  clearAutoRotateTimer(): void {
    if (this.autoRotateTimer) {
      clearTimeout(this.autoRotateTimer)
      this.autoRotateTimer = 0
    }
  }

  clearCompassNeedleTimer(): void {
    if (this.compassNeedleTimer) {
      clearTimeout(this.compassNeedleTimer)
      this.compassNeedleTimer = 0
    }
  }

  clearCompassBootstrapRetryTimer(): void {
    if (this.compassBootstrapRetryTimer) {
      clearTimeout(this.compassBootstrapRetryTimer)
      this.compassBootstrapRetryTimer = 0
    }
  }

  scheduleCompassBootstrapRetry(): void {
    this.clearCompassBootstrapRetryTimer()
    if (!this.mounted) {
      return
    }

    this.compassBootstrapRetryTimer = setTimeout(() => {
      this.compassBootstrapRetryTimer = 0
      if (!this.mounted || this.lastCompassSampleAt > 0) {
        return
      }
      this.compassController.stop()
      this.compassController.start()
    }, COMPASS_BOOTSTRAP_RETRY_DELAY_MS) as unknown as number
  }

  syncCompassDisplayState(): void {
    this.setState({
      compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
      sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
      compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
      ...(this.diagnosticUiEnabled
        ? {
            ...this.getTelemetrySensorViewPatch(),
            northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
            autoRotateSourceText: this.getAutoRotateSourceText(),
            northReferenceText: formatNorthReferenceText(this.northReferenceMode),
          }
        : {}),
    })
  }

  applyTelemetryPlayerProfile(profile?: PlayerTelemetryProfile | null): void {
    this.telemetryPlayerProfile = profile ? { ...profile } : null
    this.telemetryRuntime.setPlayerProfile(this.telemetryPlayerProfile)
    this.setState(this.getGameViewPatch(), true)
  }

  applyCompiledTelemetryProfile(profile: RuntimeTelemetryProfile): void {
    this.telemetryPlayerProfile = profile.playerProfile ? { ...profile.playerProfile } : null
    this.telemetryRuntime.applyCompiledProfile(profile.config, this.telemetryPlayerProfile)
    this.setState(this.getGameViewPatch(), true)
  }

  applyCompiledSettingsProfile(profile: RuntimeSettingsProfile): void {
    const values = profile.values
    this.handleSetAnimationLevel(values.animationLevel)
    this.handleSetTrackMode(values.trackDisplayMode)
    this.handleSetTrackTailLength(values.trackTailLength)
    this.handleSetTrackColorPreset(values.trackColorPreset)
    this.handleSetTrackStyleProfile(values.trackStyleProfile)
    this.handleSetGpsMarkerVisible(values.gpsMarkerVisible)
    this.handleSetGpsMarkerStyle(values.gpsMarkerStyle)
    this.handleSetGpsMarkerSize(values.gpsMarkerSize)
    this.handleSetGpsMarkerColorPreset(values.gpsMarkerColorPreset)
    if (values.autoRotateEnabled) {
      this.handleSetHeadingUpMode()
    } else {
      this.handleSetManualMode()
    }
    this.handleSetCompassTuningProfile(values.compassTuningProfile)
    this.handleSetNorthReferenceMode(values.northReferenceMode)
  }

  applyCompiledMapProfile(profile: RuntimeMapProfile): void {
    MAGNETIC_DECLINATION_DEG = profile.magneticDeclinationDeg
    MAGNETIC_DECLINATION_TEXT = normalizeDegreeDisplayText(profile.magneticDeclinationText)
    this.minZoom = profile.minZoom
    this.maxZoom = profile.maxZoom
    this.defaultZoom = profile.initialZoom
    this.defaultCenterTileX = profile.initialCenterTileX
    this.defaultCenterTileY = profile.initialCenterTileY
    this.tileBoundsByZoom = profile.tileBoundsByZoom
    this.cpRadiusMeters = profile.cpRadiusMeters

    this.setState({
      mapName: profile.title,
      configStatusText: `配置已载入 / ${profile.title} / ${profile.courseStatusText}`,
      projectionMode: profile.projectionModeText,
      tileSource: profile.tileSource,
      compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
      sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
      northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
      northReferenceText: formatNorthReferenceText(this.northReferenceMode),
      compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
    }, true)
  }

  applyCompiledGameProfile(profile: RuntimeGameProfile): void {
    this.gameMode = profile.mode
    this.sessionCloseAfterMs = profile.sessionCloseAfterMs
    this.sessionCloseWarningMs = profile.sessionCloseWarningMs
    this.minCompletedControlsBeforeFinish = profile.minCompletedControlsBeforeFinish
    this.punchPolicy = profile.punchPolicy
    this.punchRadiusMeters = profile.punchRadiusMeters
    this.requiresFocusSelection = profile.requiresFocusSelection
    this.skipEnabled = profile.skipEnabled
    this.skipRadiusMeters = profile.skipRadiusMeters
    this.skipRequiresConfirm = profile.skipRequiresConfirm
    this.autoFinishOnLastControl = profile.autoFinishOnLastControl
    this.defaultControlScore = profile.defaultControlScore

    const gameResult = this.loadGameDefinitionFromCourse()
    const gameStatusText = gameResult ? this.resolveAppliedGameStatusText(gameResult) : null
    this.setState(this.getGameViewPatch(gameStatusText), true)
  }

  applyCompiledFeedbackProfile(profile: RuntimeFeedbackProfile): void {
    this.feedbackDirector.configure({
      audioConfig: profile.audio,
      hapticsConfig: profile.haptics,
      uiEffectsConfig: profile.uiEffects,
    })
  }

  applyCompiledPresentationProfile(profile: RuntimePresentationProfile): void {
    this.courseStyleConfig = profile.course
    this.trackStyleConfig = profile.track
    this.gpsMarkerStyleConfig = profile.gpsMarker
    this.setState({
      trackDisplayMode: this.trackStyleConfig.mode,
      trackTailLength: this.trackStyleConfig.tailLength,
      trackColorPreset: this.trackStyleConfig.colorPreset,
      trackStyleProfile: this.trackStyleConfig.style,
      gpsMarkerVisible: this.gpsMarkerStyleConfig.visible,
      gpsMarkerStyle: this.gpsMarkerStyleConfig.style,
      gpsMarkerSize: this.gpsMarkerStyleConfig.size,
      gpsMarkerColorPreset: this.gpsMarkerStyleConfig.colorPreset,
      gpsLogoStatusText: this.gpsMarkerStyleConfig.logoUrl ? '等待渲染' : '未配置',
      gpsLogoSourceText: this.gpsMarkerStyleConfig.logoUrl || '--',
    }, true)
  }

  scheduleCompassNeedleFollow(): void {
    if (
      this.compassNeedleTimer
      || this.targetCompassDisplayHeadingDeg === null
      || this.compassDisplayHeadingDeg === null
    ) {
      return
    }

    const step = () => {
      this.compassNeedleTimer = 0

      if (
        this.targetCompassDisplayHeadingDeg === null
        || this.compassDisplayHeadingDeg === null
      ) {
        return
      }

      const deltaDeg = normalizeAngleDeltaDeg(
        this.targetCompassDisplayHeadingDeg - this.compassDisplayHeadingDeg,
      )
      const absDeltaDeg = Math.abs(deltaDeg)

      if (absDeltaDeg <= COMPASS_NEEDLE_SNAP_DEG) {
        if (absDeltaDeg > 0.001) {
          this.compassDisplayHeadingDeg = this.targetCompassDisplayHeadingDeg
          this.syncCompassDisplayState()
        }
        return
      }

      this.compassDisplayHeadingDeg = interpolateAngleDeg(
        this.compassDisplayHeadingDeg,
        this.targetCompassDisplayHeadingDeg,
        getCompassNeedleSmoothingFactor(
          this.compassDisplayHeadingDeg,
          this.targetCompassDisplayHeadingDeg,
          this.compassTuningProfile,
        ),
      )
      this.syncCompassDisplayState()
      this.scheduleCompassNeedleFollow()
    }

    this.compassNeedleTimer = setTimeout(step, COMPASS_NEEDLE_FRAME_MS) as unknown as number
  }

  pickViewPatch(patch: Partial<MapEngineViewState>): Partial<MapEngineViewState> {
    const viewPatch = {} as Partial<MapEngineViewState>
    for (const key of VIEW_SYNC_KEYS) {
      if (Object.prototype.hasOwnProperty.call(patch, key)) {
        ;(viewPatch as any)[key] = patch[key]
      }
    }
    return viewPatch
  }

  flushViewPatch(): void {
    if (!Object.keys(this.pendingViewPatch).length) {
      return
    }

    const patch = this.pendingViewPatch
    const shouldDeferForInteraction = this.gestureMode !== 'idle' || !!this.inertiaTimer || !!this.previewResetTimer
    const nextPendingPatch = {} as Partial<MapEngineViewState>
    const outputPatch = {} as Partial<MapEngineViewState>

    for (const [key, value] of Object.entries(patch) as Array<[keyof MapEngineViewState, MapEngineViewState[keyof MapEngineViewState]]>) {
      if (shouldDeferForInteraction && INTERACTION_DEFERRED_VIEW_KEYS.has(key)) {
        ;(nextPendingPatch as Record<string, unknown>)[key] = value
        continue
      }
      ;(outputPatch as Record<string, unknown>)[key] = value
    }

    this.pendingViewPatch = nextPendingPatch

    if (Object.keys(this.pendingViewPatch).length && !this.viewSyncTimer) {
      this.viewSyncTimer = setTimeout(() => {
        this.viewSyncTimer = 0
        this.flushViewPatch()
      }, UI_SYNC_INTERVAL_MS) as unknown as number
    }

    if (!Object.keys(outputPatch).length) {
      return
    }

    this.onData(outputPatch)
  }

  getTouchDistance(touches: TouchPoint[]): number {
    if (touches.length < 2) {
      return 0
    }

    const first = touches[0]
    const second = touches[1]
    const deltaX = first.pageX - second.pageX
    const deltaY = first.pageY - second.pageY
    return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
  }

  getTouchAngle(touches: TouchPoint[]): number {
    if (touches.length < 2) {
      return 0
    }

    const first = touches[0]
    const second = touches[1]
    return Math.atan2(second.pageY - first.pageY, second.pageX - first.pageX)
  }

  getStagePoint(touches: TouchPoint[]): { x: number; y: number } {
    if (!touches.length) {
      return {
        x: this.state.stageWidth / 2,
        y: this.state.stageHeight / 2,
      }
    }

    let pageX = 0
    let pageY = 0
    for (const touch of touches) {
      pageX += touch.pageX
      pageY += touch.pageY
    }

    return {
      x: pageX / touches.length - this.state.stageLeft,
      y: pageY / touches.length - this.state.stageTop,
    }
  }

  animatePreviewToRest(): void {
    this.clearPreviewResetTimer()

    const startScale = this.previewScale || 1
    const originX = this.previewOriginX || this.state.stageWidth / 2
    const originY = this.previewOriginY || this.state.stageHeight / 2

    if (Math.abs(startScale - 1) < 0.01) {
      this.resetPreviewState()
      this.syncRenderer()
      this.compassController.start()
      this.scheduleAutoRotate()
      return
    }

    const startAt = Date.now()
    const step = () => {
      const progress = Math.min(1, (Date.now() - startAt) / PREVIEW_RESET_DURATION_MS)
      const eased = 1 - Math.pow(1 - progress, 3)
      const nextScale = startScale + (1 - startScale) * eased
      this.setPreviewState(nextScale, originX, originY)
      this.syncRenderer()
      this.compassController.start()

      if (progress >= 1) {
        this.resetPreviewState()
        this.syncRenderer()
        this.compassController.start()
        this.previewResetTimer = 0
        this.scheduleAutoRotate()
        return
      }

      this.previewResetTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
    }

    step()
  }

  normalizeTranslate(translateX: number, translateY: number, statusText: string): void {
    if (!this.state.stageWidth) {
      this.setState({
        tileTranslateX: translateX,
        tileTranslateY: translateY,
      })
      this.syncRenderer()
      this.compassController.start()
      return
    }

    const exactCenter = this.getExactCenterFromTranslate(translateX, translateY)
    const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y)
    const centerChanged = resolvedViewport.centerTileX !== this.state.centerTileX || resolvedViewport.centerTileY !== this.state.centerTileY

    if (centerChanged) {
      this.commitViewport(resolvedViewport, statusText)
      return
    }

    this.setState({
      tileTranslateX: resolvedViewport.tileTranslateX,
      tileTranslateY: resolvedViewport.tileTranslateY,
    })
    this.syncRenderer()
    this.compassController.start()
  }

  zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void {
    const nextZoom = clamp(this.state.zoom + zoomDelta, this.minZoom, this.maxZoom)
    const appliedDelta = nextZoom - this.state.zoom
    if (!appliedDelta) {
      this.animatePreviewToRest()
      return
    }

    if (this.gpsLockEnabled && this.currentGpsPoint) {
      const nextGpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, nextZoom)
      const resolvedViewport = this.resolveViewportForExactCenter(nextGpsWorldPoint.x, nextGpsWorldPoint.y)
      this.commitViewport(
        {
          zoom: nextZoom,
          ...resolvedViewport,
        },
        `缩放级别调整到 ${nextZoom}`,
        true,
        () => {
          this.setPreviewState(residualScale, this.state.stageWidth / 2, this.state.stageHeight / 2)
          this.syncRenderer()
          this.compassController.start()
          this.animatePreviewToRest()
        },
      )
      return
    }

    if (!this.state.stageWidth || !this.state.stageHeight) {
      this.commitViewport(
        {
          zoom: nextZoom,
          centerTileX: appliedDelta > 0 ? this.state.centerTileX * 2 : Math.floor(this.state.centerTileX / 2),
          centerTileY: appliedDelta > 0 ? this.state.centerTileY * 2 : Math.floor(this.state.centerTileY / 2),
          tileTranslateX: 0,
          tileTranslateY: 0,
        },
        `缩放级别调整到 ${nextZoom}`,
        true,
        () => {
          this.setPreviewState(residualScale, stageX, stageY)
          this.syncRenderer()
          this.compassController.start()
          this.animatePreviewToRest()
        },
      )
      return
    }

    const camera = this.getCameraState()
    const world = screenToWorld(camera, { x: stageX, y: stageY }, true)
    const zoomFactor = Math.pow(2, appliedDelta)
    const nextWorldX = world.x * zoomFactor
    const nextWorldY = world.y * zoomFactor
    const anchorOffset = this.getWorldOffsetFromScreen(stageX, stageY)
    const exactCenterX = nextWorldX - anchorOffset.x
    const exactCenterY = nextWorldY - anchorOffset.y
    const resolvedViewport = this.resolveViewportForExactCenter(exactCenterX, exactCenterY)

    this.commitViewport(
      {
        zoom: nextZoom,
        ...resolvedViewport,
      },
      `缩放级别调整到 ${nextZoom}`,
      true,
      () => {
        this.setPreviewState(residualScale, stageX, stageY)
        this.syncRenderer()
        this.compassController.start()
        this.animatePreviewToRest()
      },
    )
  }

  startInertia(): void {
    this.clearInertiaTimer()

    const step = () => {
      this.panVelocityX *= INERTIA_DECAY
      this.panVelocityY *= INERTIA_DECAY

      if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
        this.setState({
          statusText: `惯性滑动结束 (${this.buildVersion})`,
        })
        this.renderer.setAnimationPaused(false)
        this.inertiaTimer = 0
        this.scheduleAutoRotate()
        return
      }

      this.normalizeTranslate(
        this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
        this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS,
        `惯性滑动中 (${this.buildVersion})`,
      )

      this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
    }

    this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  }
}






























































