2823 lines
90 KiB
TypeScript
2823 lines
90 KiB
TypeScript
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
||
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
||
import { HeartRateController, type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
|
||
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 { GameRuntime } from '../../game/core/gameRuntime'
|
||
import { type GameEffect } from '../../game/core/gameResult'
|
||
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
|
||
import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
|
||
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
|
||
import { TelemetryRuntime } from '../../game/telemetry/telemetryRuntime'
|
||
import { getHeartRateToneSampleBpm, type HeartRateTone } from '../../game/telemetry/telemetryConfig'
|
||
|
||
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.32
|
||
const COMPASS_NEEDLE_SMOOTHING = 0.12
|
||
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
|
||
|
||
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'
|
||
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 {
|
||
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
|
||
compassDeclinationText: string
|
||
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
|
||
stageWidth: number
|
||
stageHeight: number
|
||
stageLeft: number
|
||
stageTop: number
|
||
statusText: string
|
||
gpsTracking: boolean
|
||
gpsTrackingText: string
|
||
locationSourceMode: 'real' | 'mock'
|
||
locationSourceText: string
|
||
mockBridgeConnected: boolean
|
||
mockBridgeStatusText: string
|
||
mockBridgeUrlText: string
|
||
mockCoordText: string
|
||
mockSpeedText: string
|
||
gpsCoordText: string
|
||
heartRateConnected: boolean
|
||
heartRateStatusText: string
|
||
heartRateDeviceText: string
|
||
heartRateScanText: string
|
||
heartRateDiscoveredDevices: Array<{
|
||
deviceId: string
|
||
name: string
|
||
rssiText: string
|
||
preferred: boolean
|
||
connected: boolean
|
||
}>
|
||
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
|
||
gameModeText: string
|
||
panelTimerText: string
|
||
panelMileageText: string
|
||
panelActionTagText: string
|
||
panelDistanceTagText: string
|
||
panelDistanceValueText: string
|
||
panelDistanceUnitText: string
|
||
panelProgressText: string
|
||
panelSpeedValueText: string
|
||
panelTelemetryTone: 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
|
||
panelHeartRateZoneNameText: string
|
||
panelHeartRateZoneRangeText: string
|
||
panelHeartRateValueText: string
|
||
panelHeartRateUnitText: string
|
||
panelCaloriesValueText: string
|
||
panelCaloriesUnitText: string
|
||
panelAverageSpeedValueText: string
|
||
panelAverageSpeedUnitText: string
|
||
panelAccuracyValueText: string
|
||
panelAccuracyUnitText: string
|
||
punchButtonText: string
|
||
punchButtonEnabled: boolean
|
||
punchHintText: string
|
||
punchFeedbackVisible: boolean
|
||
punchFeedbackText: string
|
||
punchFeedbackTone: 'neutral' | 'success' | 'warning'
|
||
contentCardVisible: boolean
|
||
contentCardTitle: string
|
||
contentCardBody: string
|
||
punchButtonFxClass: 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
|
||
}
|
||
|
||
const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
||
'buildVersion',
|
||
'renderMode',
|
||
'projectionMode',
|
||
'mapReady',
|
||
'mapReadyText',
|
||
'mapName',
|
||
'configStatusText',
|
||
'zoom',
|
||
'rotationDeg',
|
||
'rotationText',
|
||
'rotationMode',
|
||
'rotationModeText',
|
||
'rotationToggleText',
|
||
'orientationMode',
|
||
'orientationModeText',
|
||
'sensorHeadingText',
|
||
'compassDeclinationText',
|
||
'northReferenceButtonText',
|
||
'autoRotateSourceText',
|
||
'autoRotateCalibrationText',
|
||
'northReferenceText',
|
||
'compassNeedleDeg',
|
||
'centerText',
|
||
'tileSource',
|
||
'visibleTileCount',
|
||
'readyTileCount',
|
||
'memoryTileCount',
|
||
'diskTileCount',
|
||
'memoryHitCount',
|
||
'diskHitCount',
|
||
'networkFetchCount',
|
||
'cacheHitRateText',
|
||
'tileSizePx',
|
||
'statusText',
|
||
'gpsTracking',
|
||
'gpsTrackingText',
|
||
'locationSourceMode',
|
||
'locationSourceText',
|
||
'mockBridgeConnected',
|
||
'mockBridgeStatusText',
|
||
'mockBridgeUrlText',
|
||
'mockCoordText',
|
||
'mockSpeedText',
|
||
'gpsCoordText',
|
||
'heartRateConnected',
|
||
'heartRateStatusText',
|
||
'heartRateDeviceText',
|
||
'heartRateScanText',
|
||
'heartRateDiscoveredDevices',
|
||
'gameSessionStatus',
|
||
'gameModeText',
|
||
'panelTimerText',
|
||
'panelMileageText',
|
||
'panelActionTagText',
|
||
'panelDistanceTagText',
|
||
'panelDistanceValueText',
|
||
'panelDistanceUnitText',
|
||
'panelProgressText',
|
||
'panelSpeedValueText',
|
||
'panelTelemetryTone',
|
||
'panelHeartRateZoneNameText',
|
||
'panelHeartRateZoneRangeText',
|
||
'panelHeartRateValueText',
|
||
'panelHeartRateUnitText',
|
||
'panelCaloriesValueText',
|
||
'panelCaloriesUnitText',
|
||
'panelAverageSpeedValueText',
|
||
'panelAverageSpeedUnitText',
|
||
'panelAccuracyValueText',
|
||
'panelAccuracyUnitText',
|
||
'punchButtonText',
|
||
'punchButtonEnabled',
|
||
'punchHintText',
|
||
'punchFeedbackVisible',
|
||
'punchFeedbackText',
|
||
'punchFeedbackTone',
|
||
'contentCardVisible',
|
||
'contentCardTitle',
|
||
'contentCardBody',
|
||
'punchButtonFxClass',
|
||
'punchFeedbackFxClass',
|
||
'contentCardFxClass',
|
||
'mapPulseVisible',
|
||
'mapPulseLeftPx',
|
||
'mapPulseTopPx',
|
||
'mapPulseFxClass',
|
||
'stageFxVisible',
|
||
'stageFxClass',
|
||
'osmReferenceEnabled',
|
||
'osmReferenceText',
|
||
]
|
||
|
||
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 formatRotationText(rotationDeg: number): string {
|
||
return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
|
||
}
|
||
|
||
function formatHeadingText(headingDeg: number | null): string {
|
||
if (headingDeg === null) {
|
||
return '--'
|
||
}
|
||
|
||
return `${Math.round(normalizeRotationDeg(headingDeg))}掳`
|
||
}
|
||
|
||
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 === 'sensor') {
|
||
return 'Sensor Only'
|
||
}
|
||
|
||
if (mode === 'course') {
|
||
return hasCourseHeading ? 'Course Only' : 'Course Pending'
|
||
}
|
||
|
||
return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
|
||
}
|
||
|
||
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 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 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
|
||
renderer: WebGLMapRenderer
|
||
compassController: CompassHeadingController
|
||
locationController: LocationController
|
||
heartRateController: HeartRateController
|
||
feedbackDirector: FeedbackDirector
|
||
onData: (patch: Partial<MapEngineViewState>) => void
|
||
state: MapEngineViewState
|
||
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
|
||
pendingViewPatch: Partial<MapEngineViewState>
|
||
mounted: boolean
|
||
northReferenceMode: NorthReferenceMode
|
||
sensorHeadingDeg: number | null
|
||
smoothedSensorHeadingDeg: number | null
|
||
compassDisplayHeadingDeg: number | null
|
||
autoRotateHeadingDeg: number | null
|
||
courseHeadingDeg: number | null
|
||
targetAutoRotationDeg: number | null
|
||
autoRotateSourceMode: AutoRotateSourceMode
|
||
autoRotateCalibrationOffsetDeg: number | null
|
||
autoRotateCalibrationPending: boolean
|
||
minZoom: number
|
||
maxZoom: number
|
||
defaultZoom: number
|
||
defaultCenterTileX: number
|
||
defaultCenterTileY: number
|
||
tileBoundsByZoom: Record<number, TileZoomBounds> | null
|
||
currentGpsPoint: LonLatPoint | null
|
||
currentGpsTrack: LonLatPoint[]
|
||
currentGpsAccuracyMeters: number | null
|
||
courseData: OrienteeringCourseData | null
|
||
courseOverlayVisible: boolean
|
||
cpRadiusMeters: number
|
||
gameRuntime: GameRuntime
|
||
telemetryRuntime: TelemetryRuntime
|
||
gamePresentation: GamePresentationState
|
||
gameMode: 'classic-sequential' | 'score-o'
|
||
punchPolicy: 'enter' | 'enter-confirm'
|
||
punchRadiusMeters: number
|
||
autoFinishOnLastControl: boolean
|
||
punchFeedbackTimer: number
|
||
contentCardTimer: number
|
||
mapPulseTimer: number
|
||
stageFxTimer: number
|
||
sessionTimerInterval: number
|
||
hasGpsCenteredOnce: boolean
|
||
|
||
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
||
this.buildVersion = buildVersion
|
||
this.onData = callbacks.onData
|
||
this.renderer = new WebGLMapRenderer(
|
||
(stats) => {
|
||
this.applyStats(stats)
|
||
},
|
||
(message) => {
|
||
this.setState({
|
||
statusText: `${message} (${this.buildVersion})`,
|
||
})
|
||
},
|
||
)
|
||
this.compassController = new CompassHeadingController({
|
||
onHeading: (headingDeg) => {
|
||
this.handleCompassHeading(headingDeg)
|
||
},
|
||
onError: (message) => {
|
||
this.handleCompassError(message)
|
||
},
|
||
})
|
||
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(),
|
||
}, true)
|
||
},
|
||
onError: (message) => {
|
||
this.setState({
|
||
gpsTracking: this.locationController.listening,
|
||
gpsTrackingText: message,
|
||
...this.getLocationControllerViewPatch(),
|
||
statusText: `${message} (${this.buildVersion})`,
|
||
}, true)
|
||
},
|
||
onDebugStateChange: () => {
|
||
this.setState(this.getLocationControllerViewPatch(), true)
|
||
},
|
||
})
|
||
this.heartRateController = new HeartRateController({
|
||
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(),
|
||
}, true)
|
||
},
|
||
onError: (message) => {
|
||
this.clearHeartRateSignal()
|
||
const deviceName = this.heartRateController.reconnecting
|
||
? (this.heartRateController.lastDeviceName || '--')
|
||
: '--'
|
||
this.setState({
|
||
heartRateConnected: false,
|
||
heartRateStatusText: message,
|
||
heartRateDeviceText: deviceName,
|
||
heartRateScanText: this.getHeartRateScanText(),
|
||
statusText: `${message} (${this.buildVersion})`,
|
||
}, true)
|
||
},
|
||
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.reconnecting ? '心率带自动重连中' : '心率带未连接'),
|
||
heartRateScanText: this.getHeartRateScanText(),
|
||
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
|
||
}, true)
|
||
},
|
||
onDeviceListChange: (devices) => {
|
||
this.setState({
|
||
heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
|
||
heartRateScanText: this.getHeartRateScanText(),
|
||
}, true)
|
||
},
|
||
})
|
||
this.feedbackDirector = new FeedbackDirector({
|
||
showPunchFeedback: (text, tone, motionClass) => {
|
||
this.showPunchFeedback(text, tone, motionClass)
|
||
},
|
||
showContentCard: (title, body, motionClass) => {
|
||
this.showContentCard(title, body, motionClass)
|
||
},
|
||
setPunchButtonFxClass: (className) => {
|
||
this.setPunchButtonFxClass(className)
|
||
},
|
||
showMapPulse: (controlId, motionClass) => {
|
||
this.showMapPulse(controlId, motionClass)
|
||
},
|
||
showStageFx: (className) => {
|
||
this.showStageFx(className)
|
||
},
|
||
stopLocationTracking: () => {
|
||
if (this.locationController.listening) {
|
||
this.locationController.stop()
|
||
}
|
||
},
|
||
})
|
||
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.currentGpsAccuracyMeters = null
|
||
this.courseData = null
|
||
this.courseOverlayVisible = false
|
||
this.cpRadiusMeters = 5
|
||
this.gameRuntime = new GameRuntime()
|
||
this.telemetryRuntime = new TelemetryRuntime()
|
||
this.telemetryRuntime.configure()
|
||
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
|
||
this.gameMode = 'classic-sequential'
|
||
this.punchPolicy = 'enter-confirm'
|
||
this.punchRadiusMeters = 5
|
||
this.autoFinishOnLastControl = true
|
||
this.punchFeedbackTimer = 0
|
||
this.contentCardTimer = 0
|
||
this.mapPulseTimer = 0
|
||
this.stageFxTimer = 0
|
||
this.sessionTimerInterval = 0
|
||
this.hasGpsCenteredOnce = false
|
||
this.state = {
|
||
buildVersion: this.buildVersion,
|
||
renderMode: RENDER_MODE,
|
||
projectionMode: PROJECTION_MODE,
|
||
mapReady: false,
|
||
mapReadyText: 'BOOTING',
|
||
mapName: 'LCX 测试地图',
|
||
configStatusText: '远程配置待加载',
|
||
zoom: DEFAULT_ZOOM,
|
||
rotationDeg: 0,
|
||
rotationText: formatRotationText(0),
|
||
rotationMode: 'manual',
|
||
rotationModeText: formatRotationModeText('manual'),
|
||
rotationToggleText: formatRotationToggleText('manual'),
|
||
orientationMode: 'manual',
|
||
orientationModeText: formatOrientationModeText('manual'),
|
||
sensorHeadingText: '--',
|
||
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
|
||
northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
|
||
autoRotateSourceText: formatAutoRotateSourceText('sensor', 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,
|
||
stageWidth: 0,
|
||
stageHeight: 0,
|
||
stageLeft: 0,
|
||
stageTop: 0,
|
||
statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
|
||
gpsTracking: false,
|
||
gpsTrackingText: '持续定位待启动',
|
||
locationSourceMode: 'real',
|
||
locationSourceText: '真实定位',
|
||
mockBridgeConnected: false,
|
||
mockBridgeStatusText: '未连接',
|
||
mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||
mockCoordText: '--',
|
||
mockSpeedText: '--',
|
||
gpsCoordText: '--',
|
||
heartRateConnected: false,
|
||
heartRateStatusText: '心率带未连接',
|
||
heartRateDeviceText: '--',
|
||
heartRateScanText: '未扫描',
|
||
heartRateDiscoveredDevices: [],
|
||
panelTimerText: '00:00:00',
|
||
panelMileageText: '0m',
|
||
panelActionTagText: '目标',
|
||
panelDistanceTagText: '点距',
|
||
panelDistanceValueText: '--',
|
||
panelDistanceUnitText: '',
|
||
panelProgressText: '0/0',
|
||
panelSpeedValueText: '0',
|
||
panelTelemetryTone: 'blue',
|
||
panelHeartRateZoneNameText: '激活放松',
|
||
panelHeartRateZoneRangeText: '<=39%',
|
||
panelHeartRateValueText: '--',
|
||
panelHeartRateUnitText: '',
|
||
panelCaloriesValueText: '0',
|
||
panelCaloriesUnitText: 'kcal',
|
||
panelAverageSpeedValueText: '0',
|
||
panelAverageSpeedUnitText: 'km/h',
|
||
panelAccuracyValueText: '--',
|
||
panelAccuracyUnitText: '',
|
||
punchButtonText: '打点',
|
||
gameSessionStatus: 'idle',
|
||
gameModeText: '顺序赛',
|
||
punchButtonEnabled: false,
|
||
punchHintText: '等待进入检查点范围',
|
||
punchFeedbackVisible: false,
|
||
punchFeedbackText: '',
|
||
punchFeedbackTone: 'neutral',
|
||
contentCardVisible: false,
|
||
contentCardTitle: '',
|
||
contentCardBody: '',
|
||
punchButtonFxClass: '',
|
||
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.pendingViewPatch = {}
|
||
this.mounted = false
|
||
this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
|
||
this.sensorHeadingDeg = null
|
||
this.smoothedSensorHeadingDeg = null
|
||
this.compassDisplayHeadingDeg = null
|
||
this.autoRotateHeadingDeg = null
|
||
this.courseHeadingDeg = null
|
||
this.targetAutoRotationDeg = null
|
||
this.autoRotateSourceMode = 'sensor'
|
||
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
|
||
this.autoRotateCalibrationPending = false
|
||
}
|
||
|
||
getInitialData(): MapEngineViewState {
|
||
return { ...this.state }
|
||
}
|
||
|
||
destroy(): void {
|
||
this.clearInertiaTimer()
|
||
this.clearPreviewResetTimer()
|
||
this.clearViewSyncTimer()
|
||
this.clearAutoRotateTimer()
|
||
this.clearPunchFeedbackTimer()
|
||
this.clearContentCardTimer()
|
||
this.clearMapPulseTimer()
|
||
this.clearStageFxTimer()
|
||
this.clearSessionTimerInterval()
|
||
this.compassController.destroy()
|
||
this.locationController.destroy()
|
||
this.heartRateController.destroy()
|
||
this.feedbackDirector.destroy()
|
||
this.renderer.destroy()
|
||
this.mounted = false
|
||
}
|
||
|
||
handleAppShow(): void {
|
||
this.feedbackDirector.setAppAudioMode('foreground')
|
||
}
|
||
|
||
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.currentGpsAccuracyMeters = null
|
||
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
|
||
}
|
||
|
||
getLocationControllerViewPatch(): Partial<MapEngineViewState> {
|
||
const debugState = this.locationController.getDebugState()
|
||
return {
|
||
gpsTracking: debugState.listening,
|
||
locationSourceMode: debugState.sourceMode,
|
||
locationSourceText: debugState.sourceModeText,
|
||
mockBridgeConnected: debugState.mockBridgeConnected,
|
||
mockBridgeStatusText: debugState.mockBridgeStatusText,
|
||
mockBridgeUrlText: debugState.mockBridgeUrlText,
|
||
mockCoordText: debugState.mockCoordText,
|
||
mockSpeedText: debugState.mockSpeedText,
|
||
}
|
||
}
|
||
|
||
getGameModeText(): string {
|
||
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
||
}
|
||
|
||
loadGameDefinitionFromCourse(): GameEffect[] {
|
||
if (!this.courseData) {
|
||
this.clearGameRuntime()
|
||
return []
|
||
}
|
||
|
||
const definition = buildGameDefinitionFromCourse(
|
||
this.courseData,
|
||
this.cpRadiusMeters,
|
||
this.gameMode,
|
||
this.autoFinishOnLastControl,
|
||
this.punchPolicy,
|
||
this.punchRadiusMeters,
|
||
)
|
||
const result = this.gameRuntime.loadDefinition(definition)
|
||
this.telemetryRuntime.loadDefinition(definition)
|
||
this.gamePresentation = result.presentation
|
||
this.courseOverlayVisible = true
|
||
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, this.getHudTargetControlId())
|
||
this.refreshCourseHeadingFromPresentation()
|
||
this.updateSessionTimerLoop()
|
||
this.setState({
|
||
gameModeText: this.getGameModeText(),
|
||
})
|
||
return result.effects
|
||
}
|
||
|
||
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_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,
|
||
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,
|
||
panelProgressText: this.gamePresentation.hud.progressText,
|
||
punchButtonText: this.gamePresentation.hud.punchButtonText,
|
||
punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
|
||
punchHintText: this.gamePresentation.hud.punchHintText,
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
clearMapPulseTimer(): void {
|
||
if (this.mapPulseTimer) {
|
||
clearTimeout(this.mapPulseTimer)
|
||
this.mapPulseTimer = 0
|
||
}
|
||
}
|
||
|
||
clearStageFxTimer(): void {
|
||
if (this.stageFxTimer) {
|
||
clearTimeout(this.stageFxTimer)
|
||
this.stageFxTimer = 0
|
||
}
|
||
}
|
||
|
||
clearSessionTimerInterval(): void {
|
||
if (this.sessionTimerInterval) {
|
||
clearInterval(this.sessionTimerInterval)
|
||
this.sessionTimerInterval = 0
|
||
}
|
||
}
|
||
|
||
syncSessionTimerText(): void {
|
||
const telemetryPresentation = this.telemetryRuntime.getPresentation()
|
||
this.setState({
|
||
panelTimerText: telemetryPresentation.timerText,
|
||
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,
|
||
}, true)
|
||
}
|
||
|
||
updateSessionTimerLoop(): void {
|
||
const gameState = this.gameRuntime.state
|
||
const shouldRun = !!gameState && gameState.status === 'running' && gameState.endedAt === null
|
||
|
||
this.syncSessionTimerText()
|
||
if (!shouldRun) {
|
||
this.clearSessionTimerInterval()
|
||
return
|
||
}
|
||
|
||
if (this.sessionTimerInterval) {
|
||
return
|
||
}
|
||
|
||
this.sessionTimerInterval = setInterval(() => {
|
||
this.syncSessionTimerText()
|
||
}, 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)
|
||
}
|
||
|
||
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 = ''): void {
|
||
this.clearContentCardTimer()
|
||
this.setState({
|
||
contentCardVisible: true,
|
||
contentCardTitle: title,
|
||
contentCardBody: body,
|
||
contentCardFxClass: motionClass,
|
||
}, true)
|
||
this.contentCardTimer = setTimeout(() => {
|
||
this.contentCardTimer = 0
|
||
this.setState({
|
||
contentCardVisible: false,
|
||
contentCardFxClass: '',
|
||
}, true)
|
||
}, 2600) as unknown as number
|
||
}
|
||
|
||
closeContentCard(): void {
|
||
this.clearContentCardTimer()
|
||
this.setState({
|
||
contentCardVisible: false,
|
||
contentCardFxClass: '',
|
||
}, true)
|
||
}
|
||
|
||
applyGameEffects(effects: GameEffect[]): string | null {
|
||
this.feedbackDirector.handleEffects(effects)
|
||
if (effects.some((effect) => effect.type === 'session_finished')) {
|
||
this.setState({
|
||
gpsTracking: false,
|
||
gpsTrackingText: '测试结束,定位已停止',
|
||
}, true)
|
||
}
|
||
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId())
|
||
this.updateSessionTimerLoop()
|
||
return this.resolveGameStatusText(effects)
|
||
}
|
||
|
||
handleStartGame(): void {
|
||
if (!this.gameRuntime.definition || !this.gameRuntime.state) {
|
||
this.setState({
|
||
statusText: `当前还没有可开始的路线 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
if (this.gameRuntime.state.status !== 'idle') {
|
||
return
|
||
}
|
||
|
||
if (!this.locationController.listening) {
|
||
this.locationController.start()
|
||
}
|
||
|
||
const startedAt = Date.now()
|
||
let gameResult = this.gameRuntime.startSession(startedAt)
|
||
if (this.currentGpsPoint) {
|
||
gameResult = this.gameRuntime.dispatch({
|
||
type: 'gps_updated',
|
||
at: Date.now(),
|
||
lon: this.currentGpsPoint.lon,
|
||
lat: this.currentGpsPoint.lat,
|
||
accuracyMeters: this.currentGpsAccuracyMeters,
|
||
})
|
||
}
|
||
|
||
this.gamePresentation = this.gameRuntime.getPresentation()
|
||
this.courseOverlayVisible = true
|
||
this.refreshCourseHeadingFromPresentation()
|
||
const defaultStatusText = this.currentGpsPoint
|
||
? `顺序打点已开始 (${this.buildVersion})`
|
||
: `顺序打点已开始,GPS定位启动中 (${this.buildVersion})`
|
||
const gameStatusText = this.applyGameEffects(gameResult.effects) || defaultStatusText
|
||
this.setState({
|
||
...this.getGameViewPatch(gameStatusText),
|
||
}, true)
|
||
this.syncRenderer()
|
||
}
|
||
|
||
|
||
handlePunchAction(): void {
|
||
const gameResult = this.gameRuntime.dispatch({
|
||
type: 'punch_requested',
|
||
at: Date.now(),
|
||
})
|
||
this.gamePresentation = gameResult.presentation
|
||
this.refreshCourseHeadingFromPresentation()
|
||
const gameStatusText = this.applyGameEffects(gameResult.effects)
|
||
this.setState({
|
||
...this.getGameViewPatch(gameStatusText),
|
||
}, true)
|
||
this.syncRenderer()
|
||
}
|
||
|
||
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) {
|
||
this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS)
|
||
}
|
||
|
||
this.currentGpsPoint = nextPoint
|
||
this.currentGpsAccuracyMeters = accuracyMeters
|
||
|
||
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)
|
||
let gameStatusText: string | null = null
|
||
|
||
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.gamePresentation = gameResult.presentation
|
||
this.refreshCourseHeadingFromPresentation()
|
||
gameStatusText = this.applyGameEffects(gameResult.effects)
|
||
}
|
||
|
||
if (gpsInsideMap && !this.hasGpsCenteredOnce) {
|
||
this.hasGpsCenteredOnce = true
|
||
this.commitViewport({
|
||
centerTileX: gpsWorldPoint.x,
|
||
centerTileY: gpsWorldPoint.y,
|
||
tileTranslateX: 0,
|
||
tileTranslateY: 0,
|
||
gpsTracking: true,
|
||
gpsTrackingText: '持续定位进行中',
|
||
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
|
||
...this.getGameViewPatch(),
|
||
}, gameStatusText || `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true)
|
||
return
|
||
}
|
||
|
||
this.setState({
|
||
gpsTracking: true,
|
||
gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
|
||
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
|
||
...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `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 {
|
||
this.locationController.setSourceMode('mock')
|
||
}
|
||
|
||
handleConnectMockLocationBridge(): void {
|
||
this.locationController.connectMockBridge()
|
||
}
|
||
|
||
handleDisconnectMockLocationBridge(): void {
|
||
this.locationController.disconnectMockBridge()
|
||
}
|
||
|
||
handleSetMockLocationBridgeUrl(url: string): void {
|
||
this.locationController.setMockBridgeUrl(url)
|
||
}
|
||
|
||
handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void {
|
||
if (this.gameMode === nextMode) {
|
||
return
|
||
}
|
||
|
||
this.gameMode = nextMode
|
||
const effects = this.loadGameDefinitionFromCourse()
|
||
const modeText = this.getGameModeText()
|
||
const statusText = this.applyGameEffects(effects) || `已切换到${modeText} (${this.buildVersion})`
|
||
this.setState({
|
||
...this.getGameViewPatch(statusText),
|
||
gameModeText: modeText,
|
||
}, true)
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleConnectHeartRate(): void {
|
||
this.heartRateController.startScanAndConnect()
|
||
}
|
||
|
||
handleDisconnectHeartRate(): void {
|
||
this.heartRateController.disconnect()
|
||
}
|
||
|
||
handleConnectHeartRateDevice(deviceId: string): void {
|
||
this.heartRateController.connectToDiscoveredDevice(deviceId)
|
||
}
|
||
|
||
handleClearPreferredHeartRateDevice(): void {
|
||
this.heartRateController.clearPreferredDevice()
|
||
this.setState({
|
||
heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
|
||
heartRateScanText: this.getHeartRateScanText(),
|
||
}, true)
|
||
}
|
||
|
||
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()}`,
|
||
}, true)
|
||
this.syncSessionTimerText()
|
||
}
|
||
|
||
handleClearDebugHeartRate(): void {
|
||
this.telemetryRuntime.dispatch({
|
||
type: 'heart_rate_updated',
|
||
at: Date.now(),
|
||
bpm: null,
|
||
})
|
||
this.setState({
|
||
heartRateStatusText: this.heartRateController.connected ? '心率带已连接' : '心率带未连接',
|
||
heartRateScanText: this.getHeartRateScanText(),
|
||
}, true)
|
||
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.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 {
|
||
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.compassController.start()
|
||
}
|
||
|
||
applyRemoteMapConfig(config: RemoteMapConfig): void {
|
||
MAGNETIC_DECLINATION_DEG = config.magneticDeclinationDeg
|
||
MAGNETIC_DECLINATION_TEXT = config.magneticDeclinationText
|
||
this.minZoom = config.minZoom
|
||
this.maxZoom = config.maxZoom
|
||
this.defaultZoom = config.defaultZoom
|
||
this.defaultCenterTileX = config.initialCenterTileX
|
||
this.defaultCenterTileY = config.initialCenterTileY
|
||
this.tileBoundsByZoom = config.tileBoundsByZoom
|
||
this.courseData = config.course
|
||
this.cpRadiusMeters = config.cpRadiusMeters
|
||
this.gameMode = config.gameMode
|
||
this.punchPolicy = config.punchPolicy
|
||
this.punchRadiusMeters = config.punchRadiusMeters
|
||
this.autoFinishOnLastControl = config.autoFinishOnLastControl
|
||
this.telemetryRuntime.configure(config.telemetryConfig)
|
||
this.feedbackDirector.configure({
|
||
audioConfig: config.audioConfig,
|
||
hapticsConfig: config.hapticsConfig,
|
||
uiEffectsConfig: config.uiEffectsConfig,
|
||
})
|
||
|
||
const gameEffects = this.loadGameDefinitionFromCourse()
|
||
const gameStatusText = this.applyGameEffects(gameEffects)
|
||
const statePatch: Partial<MapEngineViewState> = {
|
||
configStatusText: `远程配置已载入 / ${config.courseStatusText}`,
|
||
projectionMode: config.projectionModeText,
|
||
tileSource: config.tileSource,
|
||
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
|
||
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
||
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
|
||
...this.getGameViewPatch(),
|
||
}
|
||
|
||
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: gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
this.commitViewport({
|
||
...statePatch,
|
||
zoom: this.defaultZoom,
|
||
centerTileX: this.defaultCenterTileX,
|
||
centerTileY: this.defaultCenterTileY,
|
||
tileTranslateX: 0,
|
||
tileTranslateY: 0,
|
||
}, gameStatusText || `路线已载入,点击开始进入游戏 (${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.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 = 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.getStagePoint(event.touches)
|
||
if (!this.pinchStartDistance) {
|
||
this.pinchStartDistance = distance
|
||
this.pinchStartScale = this.previewScale || 1
|
||
this.pinchStartAngle = angle
|
||
this.pinchStartRotationDeg = this.state.rotationDeg
|
||
const anchorWorld = 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
|
||
|
||
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.previewOriginX || this.state.stageWidth / 2
|
||
const originY = 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 || this.gameRuntime.definition.mode !== 'score-o') {
|
||
return
|
||
}
|
||
|
||
const focusedControlId = this.findFocusableControlAt(stageX, stageY)
|
||
if (focusedControlId === undefined) {
|
||
return
|
||
}
|
||
|
||
const gameResult = this.gameRuntime.dispatch({
|
||
type: 'control_focused',
|
||
at: Date.now(),
|
||
controlId: focusedControlId,
|
||
})
|
||
this.gamePresentation = gameResult.presentation
|
||
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId())
|
||
this.setState({
|
||
...this.getGameViewPatch(focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`),
|
||
}, true)
|
||
this.syncRenderer()
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
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'),
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||
statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
|
||
}, true)
|
||
if (this.refreshAutoRotateTarget()) {
|
||
this.scheduleAutoRotate()
|
||
}
|
||
}
|
||
|
||
handleCompassHeading(headingDeg: number): void {
|
||
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)
|
||
this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null
|
||
? compassHeadingDeg
|
||
: interpolateAngleDeg(this.compassDisplayHeadingDeg, compassHeadingDeg, COMPASS_NEEDLE_SMOOTHING)
|
||
|
||
this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
|
||
|
||
this.setState({
|
||
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
||
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
||
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||
autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
|
||
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
|
||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||
})
|
||
|
||
if (!this.refreshAutoRotateTarget()) {
|
||
return
|
||
}
|
||
|
||
if (this.state.orientationMode === 'heading-up') {
|
||
this.scheduleAutoRotate()
|
||
}
|
||
}
|
||
|
||
handleCompassError(message: string): void {
|
||
this.clearAutoRotateTimer()
|
||
this.targetAutoRotationDeg = null
|
||
this.autoRotateCalibrationPending = false
|
||
this.setState({
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
||
statusText: `${message} (${this.buildVersion})`,
|
||
}, true)
|
||
}
|
||
|
||
cycleNorthReferenceMode(): void {
|
||
const nextMode = getNextNorthReferenceMode(this.northReferenceMode)
|
||
const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
|
||
const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
|
||
? null
|
||
: getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg)
|
||
|
||
this.northReferenceMode = nextMode
|
||
this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg
|
||
this.compassDisplayHeadingDeg = 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(compassHeadingDeg),
|
||
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
||
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
||
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
|
||
},
|
||
`${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
|
||
true,
|
||
() => {
|
||
this.resetPreviewState()
|
||
this.syncRenderer()
|
||
},
|
||
)
|
||
return
|
||
}
|
||
|
||
this.setState({
|
||
northReferenceText: formatNorthReferenceText(nextMode),
|
||
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
||
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
||
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
||
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
|
||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
|
||
statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
|
||
}, true)
|
||
|
||
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
||
this.scheduleAutoRotate()
|
||
}
|
||
}
|
||
|
||
setCourseHeading(headingDeg: number | null): void {
|
||
this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg)
|
||
this.setState({
|
||
autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
|
||
})
|
||
|
||
if (this.refreshAutoRotateTarget()) {
|
||
this.scheduleAutoRotate()
|
||
}
|
||
}
|
||
|
||
resolveAutoRotateInputHeadingDeg(): number | null {
|
||
const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
|
||
? null
|
||
: getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
|
||
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 {
|
||
this.setState({
|
||
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),
|
||
})
|
||
}
|
||
|
||
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)
|
||
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),
|
||
previewScale: this.previewScale || 1,
|
||
previewOriginX: this.previewOriginX || this.state.stageWidth / 2,
|
||
previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
|
||
track: this.currentGpsTrack,
|
||
gpsPoint: this.currentGpsPoint,
|
||
gpsCalibration: GPS_MAP_CALIBRATION,
|
||
gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
|
||
course: this.courseOverlayVisible ? this.courseData : null,
|
||
cpRadiusMeters: this.cpRadiusMeters,
|
||
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,
|
||
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,
|
||
osmReferenceEnabled: this.state.osmReferenceEnabled,
|
||
overlayOpacity: MAP_OVERLAY_OPACITY,
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
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
|
||
this.pendingViewPatch = {}
|
||
this.onData(patch)
|
||
}
|
||
|
||
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.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
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|