Files
cmr-mini/miniprogram/pages/map/map.ts

3061 lines
90 KiB
TypeScript

import {
MapEngine,
type MapEngineGameInfoRow,
type MapEngineGameInfoSnapshot,
type MapEngineResultSnapshot,
type MapEngineStageRect,
type MapEngineViewState,
} from '../../engine/map/mapEngine'
import {
getBackendSessionContextFromLaunchEnvelope,
getDemoGameLaunchEnvelope,
resolveGameLaunchEnvelope,
type GameLaunchEnvelope,
type MapPageLaunchOptions,
} from '../../utils/gameLaunch'
import { finishSession, startSession, type BackendSessionFinishSummaryPayload } from '../../utils/backendApi'
import { loadBackendBaseUrl } from '../../utils/backendAuth'
import { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig'
import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
import { type TrackColorPreset } from '../../game/presentation/trackStyleConfig'
import { type GpsMarkerColorPreset } from '../../game/presentation/gpsMarkerStyleConfig'
import { type PlayerTelemetryProfile } from '../../game/telemetry/playerTelemetryProfile'
import {
DEFAULT_SETTING_LOCKS,
DEFAULT_STORED_USER_SETTINGS,
loadStoredUserSettings,
mergeStoredUserSettings,
persistStoredUserSettings,
resolveSystemSettingsState,
type SystemSettingsConfig,
type CenterScaleRulerAnchorMode,
type ResolvedSystemSettingsState,
type SideButtonPlacement,
type StoredUserSettings,
} from '../../game/core/systemSettingsState'
import {
compileRuntimeProfile,
} from '../../game/core/runtimeProfileCompiler'
import {
clearSessionRecoverySnapshot,
loadSessionRecoverySnapshot,
saveSessionRecoverySnapshot,
type SessionRecoverySnapshot,
} from '../../game/core/sessionRecovery'
type CompassTickData = {
angle: number
long: boolean
major: boolean
}
type CompassLabelData = {
text: string
angle: number
rotateBack: number
radius: number
className: string
}
type ScaleRulerMinorTickData = {
key: string
topPx: number
long: boolean
}
type ScaleRulerMajorMarkData = {
key: string
topPx: number
label: string
}
type SideButtonMode = 'shown' | 'hidden'
type SideActionButtonState = 'muted' | 'default' | 'active'
type MapPageData = MapEngineViewState & {
showDebugPanel: boolean
showGameInfoPanel: boolean
showResultScene: boolean
showSystemSettingsPanel: boolean
showCenterScaleRuler: boolean
showPunchHintBanner: boolean
punchHintFxClass: string
centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode
statusBarHeight: number
topInsetHeight: number
hudPanelIndex: number
configSourceText: string
mockBridgeUrlDraft: string
mockHeartRateBridgeUrlDraft: string
mockDebugLogBridgeUrlDraft: string
mockChannelIdDraft: string
gameInfoTitle: string
gameInfoSubtitle: string
gameInfoLocalRows: MapEngineGameInfoRow[]
gameInfoGlobalRows: MapEngineGameInfoRow[]
resultSceneTitle: string
resultSceneSubtitle: string
resultSceneHeroLabel: string
resultSceneHeroValue: string
resultSceneRows: MapEngineGameInfoRow[]
panelTimerText: string
panelTimerMode: 'elapsed' | 'countdown'
panelMileageText: string
panelTargetSummaryText: string
panelDistanceValueText: string
panelProgressText: string
panelSpeedValueText: string
panelTimerFxClass: string
panelMileageFxClass: string
panelSpeedFxClass: string
panelHeartRateFxClass: string
compassTicks: CompassTickData[]
compassLabels: CompassLabelData[]
sideButtonMode: SideButtonMode
sideButtonPlacement: SideButtonPlacement
autoRotateEnabled: boolean
lockAnimationLevel: boolean
lockTrackMode: boolean
lockTrackTailLength: boolean
lockTrackColor: boolean
lockTrackStyle: boolean
lockGpsMarkerVisible: boolean
lockGpsMarkerStyle: boolean
lockGpsMarkerSize: boolean
lockGpsMarkerColor: boolean
lockSideButtonPlacement: boolean
lockAutoRotate: boolean
lockCompassTuning: boolean
lockScaleRulerVisible: boolean
lockScaleRulerAnchor: boolean
lockNorthReference: boolean
lockHeartRateDevice: boolean
sideToggleIconSrc: string
sideButton2Class: string
sideButton4Class: string
sideButton11Class: string
sideButton12Class: string
sideButton13Class: string
sideButton14Class: string
sideButton16Class: string
centerScaleRulerVisible: boolean
centerScaleRulerCenterXPx: number
centerScaleRulerZeroYPx: number
centerScaleRulerHeightPx: number
centerScaleRulerAxisBottomPx: number
centerScaleRulerZeroVisible: boolean
centerScaleRulerZeroLabel: string
centerScaleRulerMinorTicks: ScaleRulerMinorTickData[]
centerScaleRulerMajorMarks: ScaleRulerMajorMarkData[]
showLeftButtonGroup: boolean
showRightButtonGroups: boolean
showBottomDebugButton: boolean
}
function getGlobalTelemetryProfile(): PlayerTelemetryProfile | null {
const app = getApp<IAppOption>()
const profile = app.globalData && app.globalData.telemetryPlayerProfile
return profile ? { ...profile } : null
}
const INTERNAL_BUILD_VERSION = 'map-build-293'
const PUNCH_HINT_AUTO_HIDE_MS = 30000
const PUNCH_HINT_FX_DURATION_MS = 420
const PUNCH_HINT_HAPTIC_GAP_MS = 2400
const SESSION_RECOVERY_PERSIST_INTERVAL_MS = 5000
let currentGameLaunchEnvelope: GameLaunchEnvelope = getDemoGameLaunchEnvelope()
let mapEngine: MapEngine | null = null
let stageCanvasAttached = false
let gameInfoPanelSyncTimer = 0
let centerScaleRulerSyncTimer = 0
let contentAudioRecorder: WechatMiniprogram.RecorderManager | null = null
let contentAudioRecording = false
let centerScaleRulerUpdateTimer = 0
let punchHintDismissTimer = 0
let punchHintFxTimer = 0
let panelTimerFxTimer = 0
let panelMileageFxTimer = 0
let panelSpeedFxTimer = 0
let panelHeartRateFxTimer = 0
let sessionRecoveryPersistTimer = 0
let lastPunchHintHapticAt = 0
let currentSystemSettingsConfig: SystemSettingsConfig | undefined
let currentRemoteMapConfig: RemoteMapConfig | undefined
let systemSettingsLockLifetimeActive = false
let syncedBackendSessionStartId = ''
let syncedBackendSessionFinishId = ''
let shouldAutoRestoreRecoverySnapshot = false
const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1'
const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1'
let lastCenterScaleRulerStablePatch: Pick<
MapPageData,
| 'centerScaleRulerVisible'
| 'centerScaleRulerCenterXPx'
| 'centerScaleRulerZeroYPx'
| 'centerScaleRulerHeightPx'
| 'centerScaleRulerAxisBottomPx'
| 'centerScaleRulerZeroVisible'
| 'centerScaleRulerZeroLabel'
| 'centerScaleRulerMinorTicks'
| 'centerScaleRulerMajorMarks'
> = {
centerScaleRulerVisible: false,
centerScaleRulerCenterXPx: 0,
centerScaleRulerZeroYPx: 0,
centerScaleRulerHeightPx: 0,
centerScaleRulerAxisBottomPx: 0,
centerScaleRulerZeroVisible: false,
centerScaleRulerZeroLabel: '0 m',
centerScaleRulerMinorTicks: [],
centerScaleRulerMajorMarks: [],
}
let centerScaleRulerInputCache: Partial<Pick<
MapPageData,
'stageWidth'
| 'stageHeight'
| 'zoom'
| 'centerTileY'
| 'tileSizePx'
| 'previewScale'
>> = {}
const DEBUG_ONLY_VIEW_KEYS = new Set<string>([
'buildVersion',
'renderMode',
'projectionMode',
'mapReady',
'mapReadyText',
'mapName',
'configStatusText',
'deviceHeadingText',
'devicePoseText',
'headingConfidenceText',
'accelerometerText',
'gyroscopeText',
'deviceMotionText',
'compassSourceText',
'compassTuningProfile',
'compassTuningProfileText',
'northReferenceButtonText',
'autoRotateSourceText',
'autoRotateCalibrationText',
'northReferenceText',
'centerText',
'tileSource',
'visibleTileCount',
'readyTileCount',
'memoryTileCount',
'diskTileCount',
'memoryHitCount',
'diskHitCount',
'networkFetchCount',
'cacheHitRateText',
'locationSourceMode',
'locationSourceText',
'mockBridgeConnected',
'mockBridgeStatusText',
'mockBridgeUrlText',
'mockCoordText',
'mockSpeedText',
'gpsCoordText',
'heartRateSourceMode',
'heartRateSourceText',
'heartRateConnected',
'heartRateStatusText',
'heartRateDeviceText',
'heartRateScanText',
'heartRateDiscoveredDevices',
'mockHeartRateBridgeConnected',
'mockHeartRateBridgeStatusText',
'mockHeartRateBridgeUrlText',
'mockHeartRateText',
])
const CENTER_SCALE_RULER_DEP_KEYS = new Set<string>([
'showCenterScaleRuler',
'centerScaleRulerAnchorMode',
'stageWidth',
'stageHeight',
'topInsetHeight',
'zoom',
'centerTileY',
'tileSizePx',
'previewScale',
])
const CENTER_SCALE_RULER_CACHE_KEYS: Array<keyof typeof centerScaleRulerInputCache> = [
'stageWidth',
'stageHeight',
'zoom',
'centerTileY',
'tileSizePx',
'previewScale',
]
const RULER_ONLY_VIEW_KEYS = new Set<string>([
'zoom',
'centerTileX',
'centerTileY',
'tileSizePx',
'previewScale',
'stageWidth',
'stageHeight',
'stageLeft',
'stageTop',
])
const SIDE_BUTTON_DEP_KEYS = new Set<string>([
'sideButtonMode',
'showGameInfoPanel',
'showCenterScaleRuler',
'centerScaleRulerAnchorMode',
'skipButtonEnabled',
'gameSessionStatus',
'gpsLockEnabled',
'gpsLockAvailable',
])
function hasAnyPatchKey(patch: Record<string, unknown>, keys: Set<string>): boolean {
return Object.keys(patch).some((key) => keys.has(key))
}
function filterDebugOnlyPatch(
patch: Partial<MapPageData>,
includeDebugFields: boolean,
includeRulerFields: boolean,
): Partial<MapPageData> {
if (includeDebugFields && includeRulerFields) {
return patch
}
const filteredPatch: Partial<MapPageData> = {}
for (const [key, value] of Object.entries(patch)) {
if (!includeDebugFields && DEBUG_ONLY_VIEW_KEYS.has(key)) {
continue
}
if (!includeRulerFields && RULER_ONLY_VIEW_KEYS.has(key)) {
continue
}
{
;(filteredPatch as Record<string, unknown>)[key] = value
}
}
return filteredPatch
}
function clearGameInfoPanelSyncTimer() {
if (gameInfoPanelSyncTimer) {
clearTimeout(gameInfoPanelSyncTimer)
gameInfoPanelSyncTimer = 0
}
}
function clearCenterScaleRulerSyncTimer() {
if (centerScaleRulerSyncTimer) {
clearTimeout(centerScaleRulerSyncTimer)
centerScaleRulerSyncTimer = 0
}
}
function clearCenterScaleRulerUpdateTimer() {
if (centerScaleRulerUpdateTimer) {
clearTimeout(centerScaleRulerUpdateTimer)
centerScaleRulerUpdateTimer = 0
}
}
function clearPunchHintDismissTimer() {
if (punchHintDismissTimer) {
clearTimeout(punchHintDismissTimer)
punchHintDismissTimer = 0
}
}
function clearPunchHintFxTimer() {
if (punchHintFxTimer) {
clearTimeout(punchHintFxTimer)
punchHintFxTimer = 0
}
}
function clearHudFxTimer(key: 'timer' | 'mileage' | 'speed' | 'heartRate') {
const timerMap = {
timer: panelTimerFxTimer,
mileage: panelMileageFxTimer,
speed: panelSpeedFxTimer,
heartRate: panelHeartRateFxTimer,
}
const timer = timerMap[key]
if (timer) {
clearTimeout(timer)
}
if (key === 'timer') {
panelTimerFxTimer = 0
} else if (key === 'mileage') {
panelMileageFxTimer = 0
} else if (key === 'speed') {
panelSpeedFxTimer = 0
} else {
panelHeartRateFxTimer = 0
}
}
function updateCenterScaleRulerInputCache(patch: Partial<MapPageData>) {
for (const key of CENTER_SCALE_RULER_CACHE_KEYS) {
if (Object.prototype.hasOwnProperty.call(patch, key)) {
;(centerScaleRulerInputCache as Record<string, unknown>)[key] =
(patch as Record<string, unknown>)[key]
}
}
}
function updateStoredUserSettings(patch: Partial<StoredUserSettings>) {
persistStoredUserSettings(
mergeStoredUserSettings(loadStoredUserSettings(), patch),
)
}
function loadStoredMockChannelId(): string {
try {
const value = wx.getStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY)
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim()
}
} catch (_error) {
// Ignore storage read failures and fall back to default.
}
return 'default'
}
function persistMockChannelId(channelId: string) {
try {
wx.setStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY, channelId)
} catch (_error) {
// Ignore storage write failures in debug preference persistence.
}
}
function loadMockAutoConnectEnabled(): boolean {
try {
return wx.getStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY) === true
} catch (_error) {
return false
}
}
function persistMockAutoConnectEnabled(enabled: boolean) {
try {
wx.setStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY, enabled)
} catch (_error) {
// Ignore storage write failures in debug preference persistence.
}
}
function buildResolvedSystemSettingsPatch(
resolvedSettings: ResolvedSystemSettingsState,
): Partial<MapPageData> {
return {
...resolvedSettings.values,
...resolvedSettings.locks,
autoRotateEnabled: resolvedSettings.values.autoRotateEnabled,
sideButtonPlacement: resolvedSettings.values.sideButtonPlacement,
showCenterScaleRuler: resolvedSettings.values.showCenterScaleRuler,
centerScaleRulerAnchorMode: resolvedSettings.values.centerScaleRulerAnchorMode,
}
}
function isSystemSettingsLockLifetimeActive(): boolean {
return systemSettingsLockLifetimeActive
}
function clearSessionRecoveryPersistTimer() {
if (sessionRecoveryPersistTimer) {
clearInterval(sessionRecoveryPersistTimer)
sessionRecoveryPersistTimer = 0
}
}
function hasExplicitLaunchOptions(options?: MapPageLaunchOptions | null): boolean {
if (!options) {
return false
}
return !!(
options.launchId
|| options.preset
|| options.configUrl
|| options.competitionId
|| options.eventId
|| options.sessionId
|| options.launchRequestId
)
}
function getCurrentBackendSessionContext(): { sessionId: string; sessionToken: string } | null {
return getBackendSessionContextFromLaunchEnvelope(currentGameLaunchEnvelope)
}
function getCurrentBackendBaseUrl(): string {
const app = getApp<IAppOption>()
if (app.globalData && app.globalData.backendBaseUrl) {
return app.globalData.backendBaseUrl
}
return loadBackendBaseUrl()
}
function buildSideButtonVisibility(mode: SideButtonMode) {
return {
sideButtonMode: mode,
showLeftButtonGroup: mode === 'shown',
showRightButtonGroups: false,
showBottomDebugButton: true,
}
}
function getNextSideButtonMode(currentMode: SideButtonMode): SideButtonMode {
return currentMode === 'shown' ? 'hidden' : 'shown'
}
function buildCompassTicks(): CompassTickData[] {
const ticks: CompassTickData[] = []
for (let angle = 0; angle < 360; angle += 5) {
ticks.push({
angle,
long: angle % 15 === 0,
major: angle % 45 === 0,
})
}
return ticks
}
function buildCompassLabels(): CompassLabelData[] {
return [
{ text: '\u5317', angle: 0, rotateBack: 0, radius: 68, className: 'compass-widget__mark--cardinal compass-widget__mark--north' },
{ text: '\u4e1c\u5317', angle: 45, rotateBack: 0, radius: 58, className: 'compass-widget__mark--intermediate compass-widget__mark--northeast' },
{ text: '\u4e1c', angle: 90, rotateBack: 0, radius: 68, className: 'compass-widget__mark--cardinal' },
{ text: '\u4e1c\u5357', angle: 135, rotateBack: 0, radius: 58, className: 'compass-widget__mark--intermediate' },
{ text: '\u5357', angle: 180, rotateBack: 0, radius: 68, className: 'compass-widget__mark--cardinal' },
{ text: '\u897f\u5357', angle: 225, rotateBack: 0, radius: 58, className: 'compass-widget__mark--intermediate' },
{ text: '\u897f', angle: 270, rotateBack: 0, radius: 68, className: 'compass-widget__mark--cardinal' },
{ text: '\u897f\u5317', angle: 315, rotateBack: 0, radius: 58, className: 'compass-widget__mark--intermediate compass-widget__mark--northwest' },
]
}
function getFallbackStageRect(): MapEngineStageRect {
const systemInfo = wx.getSystemInfoSync()
const width = Math.max(320, systemInfo.windowWidth)
const height = Math.max(280, systemInfo.windowHeight)
return {
width,
height,
left: 0,
top: 0,
}
}
function getSideToggleIconSrc(mode: SideButtonMode): string {
if (mode === 'hidden') {
return '../../assets/btn_more1.png'
}
return '../../assets/btn_more3.png'
}
function getSideActionButtonClass(state: SideActionButtonState): string {
if (state === 'muted') {
return 'map-side-button map-side-button--muted'
}
if (state === 'active') {
return 'map-side-button map-side-button--active'
}
return 'map-side-button map-side-button--default'
}
function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGameInfoPanel' | 'showSystemSettingsPanel' | 'showCenterScaleRuler' | 'centerScaleRulerAnchorMode' | 'skipButtonEnabled' | 'gameSessionStatus' | 'gpsLockEnabled' | 'gpsLockAvailable'>) {
const sideButton2State: SideActionButtonState = !data.gpsLockAvailable
? 'muted'
: data.gpsLockEnabled
? 'active'
: 'default'
const sideButton4State: SideActionButtonState = data.gameSessionStatus === 'running' ? 'active' : 'muted'
const sideButton11State: SideActionButtonState = data.showGameInfoPanel ? 'active' : 'default'
const sideButton12State: SideActionButtonState = data.showSystemSettingsPanel ? 'active' : 'default'
const sideButton13State: SideActionButtonState = data.showCenterScaleRuler ? 'active' : 'default'
const sideButton14State: SideActionButtonState = !data.showCenterScaleRuler
? 'muted'
: data.centerScaleRulerAnchorMode === 'compass-center'
? 'active'
: 'default'
const sideButton16State: SideActionButtonState = data.skipButtonEnabled ? 'default' : 'muted'
return {
sideToggleIconSrc: getSideToggleIconSrc(data.sideButtonMode),
sideButton2Class: getSideActionButtonClass(sideButton2State),
sideButton4Class: getSideActionButtonClass(sideButton4State),
sideButton11Class: getSideActionButtonClass(sideButton11State),
sideButton12Class: getSideActionButtonClass(sideButton12State),
sideButton13Class: getSideActionButtonClass(sideButton13State),
sideButton14Class: getSideActionButtonClass(sideButton14State),
sideButton16Class: getSideActionButtonClass(sideButton16State),
}
}
function getRpxUnitInPx(): number {
const systemInfo = wx.getSystemInfoSync()
return systemInfo.windowWidth / 750
}
function worldTileYToLat(worldTileY: number, zoom: number): number {
const scale = Math.pow(2, zoom)
const n = Math.PI - (2 * Math.PI * worldTileY) / scale
return (180 / Math.PI) * Math.atan(Math.sinh(n))
}
function getNiceDistanceMeters(rawDistanceMeters: number): number {
if (!Number.isFinite(rawDistanceMeters) || rawDistanceMeters <= 0) {
return 50
}
const exponent = Math.floor(Math.log10(rawDistanceMeters))
const base = Math.pow(10, exponent)
const normalized = rawDistanceMeters / base
if (normalized <= 1) {
return base
}
if (normalized <= 2) {
return 2 * base
}
if (normalized <= 5) {
return 5 * base
}
return 10 * base
}
function formatScaleDistanceLabel(distanceMeters: number): string {
if (distanceMeters >= 1000) {
const distanceKm = distanceMeters / 1000
const formatted = distanceKm >= 10 ? distanceKm.toFixed(0) : distanceKm.toFixed(1)
return `${formatted.replace(/\.0$/, '')} km`
}
return `${Math.round(distanceMeters)} m`
}
function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRuler' | 'centerScaleRulerAnchorMode' | 'stageWidth' | 'stageHeight' | 'topInsetHeight' | 'zoom' | 'centerTileY' | 'tileSizePx' | 'previewScale'>) {
if (!data.showCenterScaleRuler) {
lastCenterScaleRulerStablePatch = {
centerScaleRulerVisible: false,
centerScaleRulerCenterXPx: 0,
centerScaleRulerZeroYPx: 0,
centerScaleRulerHeightPx: 0,
centerScaleRulerAxisBottomPx: 0,
centerScaleRulerZeroVisible: false,
centerScaleRulerZeroLabel: '0 m',
centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
}
return { ...lastCenterScaleRulerStablePatch }
}
if (!data.stageWidth || !data.stageHeight) {
return { ...lastCenterScaleRulerStablePatch }
}
const topPadding = 12
const rpxUnitPx = getRpxUnitInPx()
const compassBottomPaddingPx = 248 * rpxUnitPx
const compassDialRadiusPx = (196 * rpxUnitPx) / 2
const compassHeadingOverlayHeightPx = 40 * rpxUnitPx
const compassOcclusionPaddingPx = 10 * rpxUnitPx
const zeroYPx = data.centerScaleRulerAnchorMode === 'compass-center'
? Math.round(data.stageHeight - compassBottomPaddingPx - compassDialRadiusPx)
: Math.round(data.stageHeight / 2)
const fallbackHeight = Math.max(zeroYPx - topPadding, 160)
const coveredBottomPx = data.centerScaleRulerAnchorMode === 'compass-center'
? Math.round(compassDialRadiusPx + compassHeadingOverlayHeightPx + compassOcclusionPaddingPx)
: 0
if (
!data.tileSizePx
|| !Number.isFinite(data.zoom)
|| !Number.isFinite(data.centerTileY)
) {
return {
...lastCenterScaleRulerStablePatch,
centerScaleRulerVisible: true,
centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2),
centerScaleRulerZeroYPx: zeroYPx,
centerScaleRulerHeightPx: lastCenterScaleRulerStablePatch.centerScaleRulerHeightPx || fallbackHeight,
centerScaleRulerAxisBottomPx: coveredBottomPx,
centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center',
}
}
const centerLat = worldTileYToLat(data.centerTileY + 0.5, data.zoom)
const metersPerTile = Math.cos(centerLat * Math.PI / 180) * 40075016.686 / Math.pow(2, data.zoom)
const metersPerPixel = metersPerTile / data.tileSizePx
const effectivePreviewScale = Number.isFinite(data.previewScale) && data.previewScale > 0 ? data.previewScale : 1
const effectiveMetersPerPixel = metersPerPixel / effectivePreviewScale
const rulerHeight = Math.floor(zeroYPx - topPadding)
if (!Number.isFinite(effectiveMetersPerPixel) || effectiveMetersPerPixel <= 0 || rulerHeight < 120) {
return {
...lastCenterScaleRulerStablePatch,
centerScaleRulerVisible: true,
centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2),
centerScaleRulerZeroYPx: zeroYPx,
centerScaleRulerHeightPx: lastCenterScaleRulerStablePatch.centerScaleRulerHeightPx || fallbackHeight,
centerScaleRulerAxisBottomPx: coveredBottomPx,
centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center',
}
}
const labelDistanceMeters = getNiceDistanceMeters(effectiveMetersPerPixel * 80)
const minorDistanceMeters = labelDistanceMeters / 8
const minorStepPx = minorDistanceMeters / effectiveMetersPerPixel
const visibleTopLimitPx = rulerHeight - coveredBottomPx
const minorTicks: ScaleRulerMinorTickData[] = []
const majorMarks: ScaleRulerMajorMarkData[] = []
for (let index = 1; index <= 200; index += 1) {
const topPx = Math.round(rulerHeight - index * minorStepPx)
if (topPx < 0) {
break
}
if (topPx >= visibleTopLimitPx) {
continue
}
const isHalfMajor = index % 4 === 0
const isLabelMajor = index % 8 === 0
minorTicks.push({
key: `minor-${index}`,
topPx,
long: isHalfMajor,
})
if (isLabelMajor) {
majorMarks.push({
key: `major-${index}`,
topPx,
label: formatScaleDistanceLabel((index / 8) * labelDistanceMeters),
})
}
}
lastCenterScaleRulerStablePatch = {
centerScaleRulerVisible: true,
centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2),
centerScaleRulerZeroYPx: zeroYPx,
centerScaleRulerHeightPx: rulerHeight,
centerScaleRulerAxisBottomPx: coveredBottomPx,
centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center',
centerScaleRulerZeroLabel: '0 m',
centerScaleRulerMinorTicks: minorTicks,
centerScaleRulerMajorMarks: majorMarks,
}
return { ...lastCenterScaleRulerStablePatch }
}
function buildEmptyGameInfoSnapshot(): MapEngineGameInfoSnapshot {
return {
title: '当前游戏',
subtitle: '未开始',
localRows: [],
globalRows: [
{ label: '全球积分', value: '未接入' },
{ label: '全球排名', value: '未接入' },
{ label: '在线人数', value: '未接入' },
{ label: '队伍状态', value: '未接入' },
{ label: '实时广播', value: '未接入' },
],
}
}
function buildEmptyResultSceneSnapshot(): MapEngineResultSnapshot {
return {
title: '本局结果',
subtitle: '未开始',
heroLabel: '本局用时',
heroValue: '--',
rows: [],
}
}
Page({
data: {
showDebugPanel: false,
showGameInfoPanel: false,
showResultScene: false,
showSystemSettingsPanel: false,
showCenterScaleRuler: false,
statusBarHeight: 0,
topInsetHeight: 12,
hudPanelIndex: 0,
configSourceText: '顺序赛配置',
centerScaleRulerAnchorMode: DEFAULT_STORED_USER_SETTINGS.centerScaleRulerAnchorMode,
punchHintFxClass: '',
autoRotateEnabled: DEFAULT_STORED_USER_SETTINGS.autoRotateEnabled,
...DEFAULT_SETTING_LOCKS,
gameInfoTitle: '当前游戏',
gameInfoSubtitle: '未开始',
gameInfoLocalRows: [],
gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
resultSceneTitle: '本局结果',
resultSceneSubtitle: '未开始',
resultSceneHeroLabel: '本局用时',
resultSceneHeroValue: '--',
resultSceneRows: buildEmptyResultSceneSnapshot().rows,
panelTimerText: '00:00:00',
panelTimerMode: 'elapsed',
panelMileageText: '0m',
panelActionTagText: '目标',
panelDistanceTagText: '点距',
panelTargetSummaryText: '等待选择目标',
panelDistanceValueText: '--',
panelDistanceUnitText: '',
panelProgressText: '0/0',
showPunchHintBanner: true,
sideButtonPlacement: 'left',
gameSessionStatus: 'idle',
gameModeText: '顺序赛',
gpsLockEnabled: false,
gpsLockAvailable: false,
locationSourceMode: 'real',
locationSourceText: '真实定位',
mockBridgeConnected: false,
mockBridgeStatusText: '未连接',
mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
mockChannelIdText: 'default',
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
mockChannelIdDraft: 'default',
mockCoordText: '--',
mockSpeedText: '--',
heartRateSourceMode: 'real',
heartRateSourceText: '真实心率',
mockHeartRateBridgeConnected: false,
mockHeartRateBridgeStatusText: '未连接',
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr',
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-hr',
mockHeartRateText: '--',
mockDebugLogBridgeConnected: false,
mockDebugLogBridgeStatusText: '已关闭 (wss://gs.gotomars.xyz/debug-log)',
mockDebugLogBridgeUrlText: 'wss://gs.gotomars.xyz/debug-log',
mockDebugLogBridgeUrlDraft: 'wss://gs.gotomars.xyz/debug-log',
heartRateScanText: '未扫描',
heartRateDiscoveredDevices: [],
panelSpeedValueText: '0',
panelTelemetryTone: 'blue',
trackDisplayMode: DEFAULT_STORED_USER_SETTINGS.trackDisplayMode,
trackTailLength: DEFAULT_STORED_USER_SETTINGS.trackTailLength,
trackColorPreset: DEFAULT_STORED_USER_SETTINGS.trackColorPreset,
trackStyleProfile: DEFAULT_STORED_USER_SETTINGS.trackStyleProfile,
gpsMarkerVisible: DEFAULT_STORED_USER_SETTINGS.gpsMarkerVisible,
gpsMarkerStyle: DEFAULT_STORED_USER_SETTINGS.gpsMarkerStyle,
gpsMarkerSize: DEFAULT_STORED_USER_SETTINGS.gpsMarkerSize,
gpsMarkerColorPreset: DEFAULT_STORED_USER_SETTINGS.gpsMarkerColorPreset,
gpsLogoStatusText: '未配置',
gpsLogoSourceText: '--',
panelHeartRateZoneNameText: '--',
panelHeartRateZoneRangeText: '',
heartRateConnected: false,
heartRateStatusText: '心率带未连接',
heartRateDeviceText: '--',
panelHeartRateValueText: '--',
panelHeartRateUnitText: '',
panelCaloriesValueText: '0',
panelCaloriesUnitText: 'kcal',
panelAverageSpeedValueText: '0',
panelAverageSpeedUnitText: 'km/h',
panelAccuracyValueText: '--',
panelAccuracyUnitText: '',
deviceHeadingText: '--',
devicePoseText: '竖持',
headingConfidenceText: '低',
accelerometerText: '--',
gyroscopeText: '--',
deviceMotionText: '--',
compassSourceText: '无数据',
compassTuningProfile: DEFAULT_STORED_USER_SETTINGS.compassTuningProfile,
compassTuningProfileText: '平衡',
punchButtonText: '打点',
punchButtonEnabled: false,
skipButtonEnabled: false,
punchHintText: '等待进入检查点范围',
punchFeedbackVisible: false,
punchFeedbackText: '',
punchFeedbackTone: 'neutral',
contentCardVisible: false,
contentCardTemplate: 'story',
contentCardTitle: '',
contentCardBody: '',
contentCardActions: [],
contentQuizVisible: false,
contentQuizQuestionText: '',
contentQuizCountdownText: '',
contentQuizOptions: [],
contentQuizFeedbackVisible: false,
contentQuizFeedbackText: '',
contentQuizFeedbackTone: 'neutral',
punchButtonFxClass: '',
panelProgressFxClass: '',
panelDistanceFxClass: '',
punchFeedbackFxClass: '',
contentCardFxClass: '',
mapPulseVisible: false,
mapPulseLeftPx: 0,
mapPulseTopPx: 0,
mapPulseFxClass: '',
stageFxVisible: false,
stageFxClass: '',
centerScaleRulerVisible: false,
centerScaleRulerCenterXPx: 0,
centerScaleRulerZeroYPx: 0,
centerScaleRulerHeightPx: 0,
centerScaleRulerAxisBottomPx: 0,
centerScaleRulerZeroVisible: false,
centerScaleRulerZeroLabel: '0 m',
centerScaleRulerMinorTicks: [],
centerScaleRulerMajorMarks: [],
compassTicks: buildCompassTicks(),
compassLabels: buildCompassLabels(),
...buildSideButtonVisibility('shown'),
...buildSideButtonState({
sideButtonMode: 'shown',
showGameInfoPanel: false,
showSystemSettingsPanel: false,
showCenterScaleRuler: false,
centerScaleRulerAnchorMode: 'screen-center',
skipButtonEnabled: false,
gameSessionStatus: 'idle',
gpsLockEnabled: false,
gpsLockAvailable: false,
}),
} as unknown as MapPageData,
onLoad(options: MapPageLaunchOptions) {
clearSessionRecoveryPersistTimer()
syncedBackendSessionStartId = ''
syncedBackendSessionFinishId = ''
shouldAutoRestoreRecoverySnapshot = options && options.recoverSession === '1'
currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options)
if (!hasExplicitLaunchOptions(options)) {
const recoverySnapshot = loadSessionRecoverySnapshot()
if (recoverySnapshot) {
currentGameLaunchEnvelope = recoverySnapshot.launchEnvelope
}
}
currentSystemSettingsConfig = undefined
currentRemoteMapConfig = undefined
systemSettingsLockLifetimeActive = false
const storedMockChannelId = loadStoredMockChannelId()
const shouldAutoConnectMockSources = loadMockAutoConnectEnabled()
const systemInfo = wx.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 0
const menuButtonRect = wx.getMenuButtonBoundingClientRect()
const menuButtonBottom = menuButtonRect && typeof menuButtonRect.bottom === 'number' ? menuButtonRect.bottom : statusBarHeight
if (mapEngine) {
mapEngine.destroy()
mapEngine = null
}
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
onData: (patch) => {
const nextPatch = patch as Partial<MapPageData>
const includeDebugFields = this.data.showDebugPanel
const includeRulerFields = this.data.showCenterScaleRuler
let shouldSyncRuntimeSystemSettings = false
let nextLockLifetimeActive = isSystemSettingsLockLifetimeActive()
const nextData: Partial<MapPageData> = filterDebugOnlyPatch({
...nextPatch,
}, includeDebugFields, includeRulerFields)
if (
typeof nextPatch.mockBridgeUrlText === 'string'
&& this.data.mockBridgeUrlDraft === this.data.mockBridgeUrlText
) {
nextData.mockBridgeUrlDraft = nextPatch.mockBridgeUrlText
}
if (
typeof nextPatch.mockHeartRateBridgeUrlText === 'string'
&& this.data.mockHeartRateBridgeUrlDraft === this.data.mockHeartRateBridgeUrlText
) {
nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText
}
if (
typeof nextPatch.mockDebugLogBridgeUrlText === 'string'
&& this.data.mockDebugLogBridgeUrlDraft === this.data.mockDebugLogBridgeUrlText
) {
nextData.mockDebugLogBridgeUrlDraft = nextPatch.mockDebugLogBridgeUrlText
}
if (
typeof nextPatch.mockChannelIdText === 'string'
&& this.data.mockChannelIdDraft === this.data.mockChannelIdText
) {
nextData.mockChannelIdDraft = nextPatch.mockChannelIdText
}
updateCenterScaleRulerInputCache(nextPatch)
const mergedData = {
...centerScaleRulerInputCache,
...this.data,
...nextData,
} as MapPageData
const derivedPatch: Partial<MapPageData> = {}
if (typeof nextPatch.orientationMode === 'string') {
nextData.autoRotateEnabled = nextPatch.orientationMode === 'heading-up'
}
if (
this.data.showCenterScaleRuler
&& hasAnyPatchKey(nextPatch as Record<string, unknown>, CENTER_SCALE_RULER_DEP_KEYS)
) {
clearCenterScaleRulerUpdateTimer()
Object.assign(derivedPatch, buildCenterScaleRulerPatch(mergedData))
}
if (hasAnyPatchKey(nextPatch as Record<string, unknown>, SIDE_BUTTON_DEP_KEYS)) {
Object.assign(derivedPatch, buildSideButtonState(mergedData))
}
if (typeof nextPatch.punchHintText === 'string') {
const nextHintText = nextPatch.punchHintText.trim()
if (nextHintText !== this.data.punchHintText) {
clearPunchHintDismissTimer()
clearPunchHintFxTimer()
nextData.showPunchHintBanner = nextHintText.length > 0
if (nextHintText.length > 0) {
nextData.punchHintFxClass = 'game-punch-hint--fx-enter'
punchHintFxTimer = setTimeout(() => {
punchHintFxTimer = 0
this.setData({
punchHintFxClass: '',
})
}, PUNCH_HINT_FX_DURATION_MS) as unknown as number
const now = Date.now()
if (mapEngine && now - lastPunchHintHapticAt >= PUNCH_HINT_HAPTIC_GAP_MS) {
mapEngine.playPunchHintHaptic()
lastPunchHintHapticAt = now
}
punchHintDismissTimer = setTimeout(() => {
punchHintDismissTimer = 0
this.setData({
showPunchHintBanner: false,
})
}, PUNCH_HINT_AUTO_HIDE_MS) as unknown as number
}
} else if (!nextHintText) {
clearPunchHintDismissTimer()
clearPunchHintFxTimer()
nextData.showPunchHintBanner = false
nextData.punchHintFxClass = ''
}
}
const nextAnimationLevel = typeof nextPatch.animationLevel === 'string'
? nextPatch.animationLevel
: this.data.animationLevel
let shouldSyncBackendSessionStart = false
let backendSessionFinishStatus: 'finished' | 'failed' | null = null
if (nextAnimationLevel === 'lite') {
clearHudFxTimer('timer')
clearHudFxTimer('mileage')
clearHudFxTimer('speed')
clearHudFxTimer('heartRate')
nextData.panelTimerFxClass = ''
nextData.panelMileageFxClass = ''
nextData.panelSpeedFxClass = ''
nextData.panelHeartRateFxClass = ''
} else {
if (typeof nextPatch.panelTimerText === 'string' && nextPatch.panelTimerText !== this.data.panelTimerText && this.data.panelTimerText !== '00:00:00') {
clearHudFxTimer('timer')
nextData.panelTimerFxClass = 'race-panel__timer--fx-tick'
panelTimerFxTimer = setTimeout(() => {
panelTimerFxTimer = 0
this.setData({ panelTimerFxClass: '' })
}, 320) as unknown as number
}
if (typeof nextPatch.panelMileageText === 'string' && nextPatch.panelMileageText !== this.data.panelMileageText && this.data.panelMileageText !== '0m') {
clearHudFxTimer('mileage')
nextData.panelMileageFxClass = 'race-panel__mileage-wrap--fx-update'
panelMileageFxTimer = setTimeout(() => {
panelMileageFxTimer = 0
this.setData({ panelMileageFxClass: '' })
}, 360) as unknown as number
}
if (typeof nextPatch.panelSpeedValueText === 'string' && nextPatch.panelSpeedValueText !== this.data.panelSpeedValueText && this.data.panelSpeedValueText !== '0') {
clearHudFxTimer('speed')
nextData.panelSpeedFxClass = 'race-panel__metric-group--fx-speed-update'
panelSpeedFxTimer = setTimeout(() => {
panelSpeedFxTimer = 0
this.setData({ panelSpeedFxClass: '' })
}, 360) as unknown as number
}
if (typeof nextPatch.panelHeartRateValueText === 'string' && nextPatch.panelHeartRateValueText !== this.data.panelHeartRateValueText && this.data.panelHeartRateValueText !== '--') {
clearHudFxTimer('heartRate')
nextData.panelHeartRateFxClass = 'race-panel__metric-group--fx-heart-rate-update'
panelHeartRateFxTimer = setTimeout(() => {
panelHeartRateFxTimer = 0
this.setData({ panelHeartRateFxClass: '' })
}, 400) as unknown as number
}
}
if (typeof nextPatch.gameSessionStatus === 'string') {
if (
nextPatch.gameSessionStatus !== this.data.gameSessionStatus
&& (nextPatch.gameSessionStatus === 'finished' || nextPatch.gameSessionStatus === 'failed')
) {
systemSettingsLockLifetimeActive = false
nextLockLifetimeActive = false
shouldSyncRuntimeSystemSettings = true
clearSessionRecoverySnapshot()
clearSessionRecoveryPersistTimer()
this.syncResultSceneSnapshot()
nextData.showResultScene = true
nextData.showDebugPanel = false
nextData.showGameInfoPanel = false
nextData.showSystemSettingsPanel = false
clearGameInfoPanelSyncTimer()
backendSessionFinishStatus = nextPatch.gameSessionStatus === 'finished' ? 'finished' : 'failed'
} else if (
nextPatch.gameSessionStatus !== this.data.gameSessionStatus
&& nextPatch.gameSessionStatus === 'idle'
&& !isSystemSettingsLockLifetimeActive()
) {
nextLockLifetimeActive = false
shouldSyncRuntimeSystemSettings = true
clearSessionRecoverySnapshot()
clearSessionRecoveryPersistTimer()
} else if (
nextPatch.gameSessionStatus !== this.data.gameSessionStatus
&& nextPatch.gameSessionStatus === 'running'
) {
shouldSyncBackendSessionStart = true
} else if (nextPatch.gameSessionStatus === 'running' || nextPatch.gameSessionStatus === 'idle') {
nextData.showResultScene = false
}
}
if (Object.keys(nextData).length || Object.keys(derivedPatch).length) {
this.setData({
...nextData,
...derivedPatch,
}, () => {
if (typeof nextPatch.gameSessionStatus === 'string') {
this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus)
}
if (shouldSyncBackendSessionStart) {
this.syncBackendSessionStart()
}
if (backendSessionFinishStatus) {
this.syncBackendSessionFinish(backendSessionFinishStatus)
}
if (shouldSyncRuntimeSystemSettings) {
this.applyRuntimeSystemSettings(nextLockLifetimeActive)
}
if (this.data.showGameInfoPanel) {
this.scheduleGameInfoPanelSnapshotSync()
}
})
} else {
if (typeof nextPatch.gameSessionStatus === 'string') {
this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus)
}
if (shouldSyncBackendSessionStart) {
this.syncBackendSessionStart()
}
if (backendSessionFinishStatus) {
this.syncBackendSessionFinish(backendSessionFinishStatus)
}
if (shouldSyncRuntimeSystemSettings) {
this.applyRuntimeSystemSettings(nextLockLifetimeActive)
}
if (this.data.showGameInfoPanel) {
this.scheduleGameInfoPanelSnapshotSync()
}
}
},
onOpenH5Experience: (request) => {
this.openH5Experience(request)
},
})
mapEngine.applyTelemetryPlayerProfile(getGlobalTelemetryProfile())
const systemSettingsState = resolveSystemSettingsState(undefined, undefined, false)
const initialSystemSettings = systemSettingsState.values
mapEngine.applyCompiledSettingsProfile({
values: initialSystemSettings,
locks: systemSettingsState.locks,
lockLifetimeActive: false,
})
mapEngine.setDiagnosticUiEnabled(false)
centerScaleRulerInputCache = {
stageWidth: 0,
stageHeight: 0,
zoom: 0,
centerTileY: 0,
tileSizePx: 0,
previewScale: 1,
}
const initialEngineData = mapEngine.getInitialData()
this.setData({
...initialEngineData,
...buildResolvedSystemSettingsPatch(systemSettingsState),
showDebugPanel: false,
showGameInfoPanel: false,
showSystemSettingsPanel: false,
statusBarHeight,
topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
hudPanelIndex: 0,
configSourceText: currentGameLaunchEnvelope.config.configLabel,
gameInfoTitle: '当前游戏',
gameInfoSubtitle: '未开始',
gameInfoLocalRows: [],
gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
panelTimerText: '00:00:00',
panelTimerMode: 'elapsed',
panelTimerFxClass: '',
panelMileageText: '0m',
panelMileageFxClass: '',
panelActionTagText: '目标',
panelDistanceTagText: '点距',
panelTargetSummaryText: '等待选择目标',
panelDistanceValueText: '--',
panelDistanceUnitText: '',
panelProgressText: '0/0',
showPunchHintBanner: true,
gameSessionStatus: 'idle',
gameModeText: '顺序赛',
gpsLockEnabled: false,
gpsLockAvailable: false,
locationSourceMode: 'real',
locationSourceText: '真实定位',
mockBridgeConnected: false,
mockBridgeStatusText: '未连接',
mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
mockChannelIdText: storedMockChannelId,
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
mockChannelIdDraft: storedMockChannelId,
mockCoordText: '--',
mockSpeedText: '--',
heartRateSourceMode: 'real',
heartRateSourceText: '真实心率',
mockHeartRateBridgeConnected: false,
mockHeartRateBridgeStatusText: '未连接',
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr',
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-hr',
mockHeartRateText: '--',
mockDebugLogBridgeConnected: false,
mockDebugLogBridgeStatusText: '已关闭 (wss://gs.gotomars.xyz/debug-log)',
mockDebugLogBridgeUrlText: 'wss://gs.gotomars.xyz/debug-log',
mockDebugLogBridgeUrlDraft: 'wss://gs.gotomars.xyz/debug-log',
panelSpeedValueText: '0',
panelSpeedFxClass: '',
panelTelemetryTone: 'blue',
gpsLogoStatusText: '未配置',
gpsLogoSourceText: '--',
panelHeartRateZoneNameText: '--',
panelHeartRateZoneRangeText: '',
heartRateConnected: false,
heartRateStatusText: '心率带未连接',
heartRateDeviceText: '--',
panelHeartRateValueText: '--',
panelHeartRateFxClass: '',
panelHeartRateUnitText: '',
panelCaloriesValueText: '0',
panelCaloriesUnitText: 'kcal',
panelAverageSpeedValueText: '0',
panelAverageSpeedUnitText: 'km/h',
panelAccuracyValueText: '--',
panelAccuracyUnitText: '',
deviceHeadingText: '--',
devicePoseText: '竖持',
headingConfidenceText: '低',
accelerometerText: '--',
gyroscopeText: '--',
deviceMotionText: '--',
compassSourceText: '无数据',
compassTuningProfileText: initialEngineData.compassTuningProfileText || '平衡',
punchButtonText: '打点',
punchButtonEnabled: false,
skipButtonEnabled: false,
punchHintText: '等待进入检查点范围',
punchHintFxClass: '',
punchFeedbackVisible: false,
punchFeedbackText: '',
punchFeedbackTone: 'neutral',
contentCardVisible: false,
contentCardTemplate: 'story',
contentCardTitle: '',
contentCardBody: '',
contentCardActions: [],
contentQuizVisible: false,
contentQuizQuestionText: '',
contentQuizCountdownText: '',
contentQuizOptions: [],
contentQuizFeedbackVisible: false,
contentQuizFeedbackText: '',
contentQuizFeedbackTone: 'neutral',
punchButtonFxClass: '',
panelProgressFxClass: '',
panelDistanceFxClass: '',
punchFeedbackFxClass: '',
contentCardFxClass: '',
mapPulseVisible: false,
mapPulseLeftPx: 0,
mapPulseTopPx: 0,
mapPulseFxClass: '',
stageFxVisible: false,
stageFxClass: '',
compassTicks: buildCompassTicks(),
compassLabels: buildCompassLabels(),
...buildSideButtonVisibility('shown'),
...buildSideButtonState({
sideButtonMode: 'shown',
showGameInfoPanel: false,
showSystemSettingsPanel: false,
showCenterScaleRuler: initialSystemSettings.showCenterScaleRuler,
centerScaleRulerAnchorMode: initialSystemSettings.centerScaleRulerAnchorMode,
skipButtonEnabled: false,
gameSessionStatus: 'idle',
gpsLockEnabled: false,
gpsLockAvailable: false,
}),
...buildCenterScaleRulerPatch({
...(mapEngine.getInitialData() as MapPageData),
showCenterScaleRuler: initialSystemSettings.showCenterScaleRuler,
centerScaleRulerAnchorMode: initialSystemSettings.centerScaleRulerAnchorMode,
stageWidth: 0,
stageHeight: 0,
topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
zoom: 0,
centerTileY: 0,
tileSizePx: 0,
}),
}, () => {
if (shouldAutoConnectMockSources) {
this.handleConnectAllMockSources()
}
})
},
onReady() {
stageCanvasAttached = false
this.measureStageAndCanvas()
this.loadGameLaunchEnvelope(currentGameLaunchEnvelope)
},
onShow() {
if (mapEngine) {
this.applyCompiledRuntimeProfiles()
mapEngine.handleAppShow()
}
},
onHide() {
this.persistSessionRecoverySnapshot()
if (mapEngine) {
mapEngine.handleAppHide()
}
},
onUnload() {
this.persistSessionRecoverySnapshot()
clearSessionRecoveryPersistTimer()
syncedBackendSessionStartId = ''
syncedBackendSessionFinishId = ''
clearGameInfoPanelSyncTimer()
clearCenterScaleRulerSyncTimer()
clearCenterScaleRulerUpdateTimer()
clearPunchHintDismissTimer()
clearPunchHintFxTimer()
clearHudFxTimer('timer')
clearHudFxTimer('mileage')
clearHudFxTimer('speed')
clearHudFxTimer('heartRate')
if (mapEngine) {
mapEngine.destroy()
mapEngine = null
}
currentSystemSettingsConfig = undefined
currentRemoteMapConfig = undefined
systemSettingsLockLifetimeActive = false
currentGameLaunchEnvelope = getDemoGameLaunchEnvelope()
shouldAutoRestoreRecoverySnapshot = false
stageCanvasAttached = false
},
loadGameLaunchEnvelope(envelope: GameLaunchEnvelope) {
this.loadMapConfigFromRemote(
envelope.config.configUrl,
envelope.config.configLabel,
)
},
persistSessionRecoverySnapshot() {
if (!mapEngine || !currentRemoteMapConfig) {
return false
}
const runtimeSnapshot = mapEngine.buildSessionRecoveryRuntimeSnapshot()
if (!runtimeSnapshot) {
return false
}
const snapshot: SessionRecoverySnapshot = {
schemaVersion: 1,
savedAt: Date.now(),
launchEnvelope: currentGameLaunchEnvelope,
configAppId: currentRemoteMapConfig.configAppId,
configVersion: currentRemoteMapConfig.configVersion,
runtime: runtimeSnapshot,
}
saveSessionRecoverySnapshot(snapshot)
return true
},
syncBackendSessionStart() {
const sessionContext = getCurrentBackendSessionContext()
if (!sessionContext || syncedBackendSessionStartId === sessionContext.sessionId) {
return
}
startSession({
baseUrl: getCurrentBackendBaseUrl(),
sessionId: sessionContext.sessionId,
sessionToken: sessionContext.sessionToken,
})
.then(() => {
syncedBackendSessionStartId = sessionContext.sessionId
})
.catch((error) => {
const message = error && error.message ? error.message : '未知错误'
this.setData({
statusText: `session start 上报失败: ${message}`,
})
})
},
syncBackendSessionFinish(statusOverride?: 'finished' | 'failed' | 'cancelled') {
const sessionContext = getCurrentBackendSessionContext()
if (!sessionContext || syncedBackendSessionFinishId === sessionContext.sessionId || !mapEngine) {
return
}
const finishSummary = mapEngine.getSessionFinishSummary(statusOverride)
if (!finishSummary) {
return
}
const summaryPayload: BackendSessionFinishSummaryPayload = {}
if (typeof finishSummary.finalDurationSec === 'number') {
summaryPayload.finalDurationSec = finishSummary.finalDurationSec
}
if (typeof finishSummary.finalScore === 'number') {
summaryPayload.finalScore = finishSummary.finalScore
}
if (typeof finishSummary.completedControls === 'number') {
summaryPayload.completedControls = finishSummary.completedControls
}
if (typeof finishSummary.totalControls === 'number') {
summaryPayload.totalControls = finishSummary.totalControls
}
if (typeof finishSummary.distanceMeters === 'number') {
summaryPayload.distanceMeters = finishSummary.distanceMeters
}
if (typeof finishSummary.averageSpeedKmh === 'number') {
summaryPayload.averageSpeedKmh = finishSummary.averageSpeedKmh
}
finishSession({
baseUrl: getCurrentBackendBaseUrl(),
sessionId: sessionContext.sessionId,
sessionToken: sessionContext.sessionToken,
status: finishSummary.status,
summary: summaryPayload,
})
.then(() => {
syncedBackendSessionFinishId = sessionContext.sessionId
})
.catch((error) => {
const message = error && error.message ? error.message : '未知错误'
this.setData({
statusText: `session finish 上报失败: ${message}`,
})
})
},
reportAbandonedRecoverySnapshot(snapshot: SessionRecoverySnapshot) {
const sessionContext = getBackendSessionContextFromLaunchEnvelope(snapshot.launchEnvelope)
if (!sessionContext) {
clearSessionRecoverySnapshot()
return
}
finishSession({
baseUrl: getCurrentBackendBaseUrl(),
sessionId: sessionContext.sessionId,
sessionToken: sessionContext.sessionToken,
status: 'cancelled',
summary: {},
})
.then(() => {
syncedBackendSessionFinishId = sessionContext.sessionId
clearSessionRecoverySnapshot()
wx.showToast({
title: '已放弃上次对局',
icon: 'none',
duration: 1400,
})
})
.catch((error) => {
clearSessionRecoverySnapshot()
const message = error && error.message ? error.message : '未知错误'
this.setData({
statusText: `放弃恢复已生效,后端取消上报失败: ${message}`,
})
wx.showToast({
title: '已放弃上次对局',
icon: 'none',
duration: 1400,
})
})
},
restoreRecoverySnapshot(snapshot: SessionRecoverySnapshot) {
systemSettingsLockLifetimeActive = true
this.applyRuntimeSystemSettings(true)
const restored = mapEngine ? mapEngine.restoreSessionRecoveryRuntimeSnapshot(snapshot.runtime) : false
if (!restored) {
clearSessionRecoverySnapshot()
wx.showToast({
title: '恢复失败,已回到初始状态',
icon: 'none',
duration: 1600,
})
return false
}
this.setData({
showResultScene: false,
showDebugPanel: false,
showGameInfoPanel: false,
showSystemSettingsPanel: false,
})
const sessionContext = getCurrentBackendSessionContext()
if (sessionContext) {
syncedBackendSessionStartId = sessionContext.sessionId
}
this.syncSessionRecoveryLifecycle('running')
return true
},
syncSessionRecoveryLifecycle(status: MapPageData['gameSessionStatus']) {
if (status === 'running') {
this.persistSessionRecoverySnapshot()
if (!sessionRecoveryPersistTimer) {
sessionRecoveryPersistTimer = setInterval(() => {
this.persistSessionRecoverySnapshot()
}, SESSION_RECOVERY_PERSIST_INTERVAL_MS) as unknown as number
}
return
}
clearSessionRecoveryPersistTimer()
},
maybePromptSessionRecoveryRestore(config: RemoteMapConfig) {
const snapshot = loadSessionRecoverySnapshot()
if (!snapshot || !mapEngine) {
return
}
if (
snapshot.launchEnvelope.config.configUrl !== currentGameLaunchEnvelope.config.configUrl
|| snapshot.configAppId !== config.configAppId
|| snapshot.configVersion !== config.configVersion
) {
clearSessionRecoverySnapshot()
return
}
if (shouldAutoRestoreRecoverySnapshot) {
shouldAutoRestoreRecoverySnapshot = false
this.restoreRecoverySnapshot(snapshot)
return
}
wx.showModal({
title: '恢复对局',
content: '检测到上次有未正常结束的对局,是否继续恢复?',
confirmText: '继续恢复',
cancelText: '放弃',
success: (result) => {
if (!result.confirm) {
this.reportAbandonedRecoverySnapshot(snapshot)
return
}
this.restoreRecoverySnapshot(snapshot)
},
})
},
compileCurrentRuntimeProfile(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) {
if (!currentRemoteMapConfig) {
return null
}
return compileRuntimeProfile(currentRemoteMapConfig, {
playerTelemetryProfile: getGlobalTelemetryProfile(),
settingsLockLifetimeActive: lockLifetimeActive,
})
},
applyCompiledRuntimeProfiles(
lockLifetimeActive = isSystemSettingsLockLifetimeActive(),
options?: {
includeSettings?: boolean
includeMap?: boolean
includeGame?: boolean
includePresentation?: boolean
includeTelemetry?: boolean
includeFeedback?: boolean
},
) {
const currentEngine = mapEngine
if (!currentEngine) {
return null
}
const compiledProfile = this.compileCurrentRuntimeProfile(lockLifetimeActive)
if (!compiledProfile) {
return null
}
if (options && options.includeMap) {
currentEngine.applyCompiledMapProfile(compiledProfile.map)
}
if (options && options.includeSettings) {
currentEngine.applyCompiledSettingsProfile(compiledProfile.settings)
}
if (options && options.includeGame) {
currentEngine.applyCompiledGameProfile(compiledProfile.game)
}
if (options && options.includePresentation) {
currentEngine.applyCompiledPresentationProfile(compiledProfile.presentation)
}
if (!options || options.includeTelemetry !== false) {
currentEngine.applyCompiledTelemetryProfile(compiledProfile.telemetry)
}
if (!options || options.includeFeedback !== false) {
currentEngine.applyCompiledFeedbackProfile(compiledProfile.feedback)
}
return compiledProfile
},
applyRuntimeSystemSettings(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) {
const currentEngine = mapEngine
if (!currentEngine) {
return null
}
const compiledProfile = this.applyCompiledRuntimeProfiles(lockLifetimeActive, {
includeSettings: true,
})
|| {
settings: resolveSystemSettingsState(
currentSystemSettingsConfig,
undefined,
lockLifetimeActive,
),
}
const resolvedSettings = compiledProfile.settings
const engineSnapshot = currentEngine.getInitialData() as Partial<MapPageData>
updateCenterScaleRulerInputCache(engineSnapshot)
const resolvedPatch = buildResolvedSystemSettingsPatch(resolvedSettings)
const mergedData = {
...centerScaleRulerInputCache,
...this.data,
...engineSnapshot,
...resolvedPatch,
} as MapPageData
this.setData({
...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, resolvedSettings.values.showCenterScaleRuler),
...resolvedPatch,
...buildCenterScaleRulerPatch(mergedData),
...buildSideButtonState(mergedData),
})
return resolvedSettings
},
persistAndApplySystemSettings(
patch: Partial<StoredUserSettings>,
options?: {
applyCenterScaleRuler?: boolean
},
) {
updateStoredUserSettings(patch)
const lockLifetimeActive = isSystemSettingsLockLifetimeActive()
const resolvedSettings = this.applyRuntimeSystemSettings(lockLifetimeActive)
if (!resolvedSettings || !(options && options.applyCenterScaleRuler)) {
return resolvedSettings
}
this.applyCenterScaleRulerSettings(
resolvedSettings.values.showCenterScaleRuler,
resolvedSettings.values.centerScaleRulerAnchorMode,
)
return resolvedSettings
},
loadMapConfigFromRemote(configUrl: string, configLabel: string) {
const currentEngine = mapEngine
if (!currentEngine) {
return
}
this.setData({
configSourceText: configLabel,
configStatusText: `加载中: ${configLabel}`,
})
loadRemoteMapConfig(configUrl)
.then((config) => {
if (mapEngine !== currentEngine) {
return
}
currentEngine.applyRemoteMapConfig(config)
this.applyConfiguredSystemSettings(config)
this.applyCompiledRuntimeProfiles(true, {
includeMap: true,
includeGame: true,
includePresentation: true,
})
this.maybePromptSessionRecoveryRestore(config)
})
.catch((error) => {
if (mapEngine !== currentEngine) {
return
}
const rawErrorMessage = error && error.message ? error.message : '未知错误'
const errorMessage = rawErrorMessage.indexOf('404') >= 0
? `release manifest 不存在或未发布 (${configLabel})`
: rawErrorMessage
this.setData({
configStatusText: `载入失败: ${errorMessage}`,
statusText: `远程地图配置载入失败: ${errorMessage} (${INTERNAL_BUILD_VERSION})`,
})
})
},
applyConfiguredSystemSettings(config: RemoteMapConfig) {
currentRemoteMapConfig = config
currentSystemSettingsConfig = config.systemSettingsConfig
systemSettingsLockLifetimeActive = true
this.applyRuntimeSystemSettings(true)
},
measureStageAndCanvas(onApplied?: () => void) {
const page = this
const applyStage = (rawRect?: Partial<WechatMiniprogram.BoundingClientRectCallbackResult>) => {
const fallbackRect = getFallbackStageRect()
const rect: MapEngineStageRect = {
width: rawRect && typeof rawRect.width === 'number' ? rawRect.width : fallbackRect.width,
height: rawRect && typeof rawRect.height === 'number' ? rawRect.height : fallbackRect.height,
left: rawRect && typeof rawRect.left === 'number' ? rawRect.left : fallbackRect.left,
top: rawRect && typeof rawRect.top === 'number' ? rawRect.top : fallbackRect.top,
}
const currentEngine = mapEngine
if (!currentEngine) {
return
}
currentEngine.setStage(rect)
if (onApplied) {
onApplied()
}
if (stageCanvasAttached) {
return
}
const canvasQuery = wx.createSelectorQuery().in(page)
canvasQuery.select('#mapCanvas').fields({ node: true, size: true })
canvasQuery.select('#routeLabelCanvas').fields({ node: true, size: true })
canvasQuery.exec((canvasRes) => {
const canvasRef = canvasRes[0] as any
const labelCanvasRef = canvasRes[1] as any
if (!canvasRef || !canvasRef.node) {
page.setData({
statusText: `WebGL 引擎初始化失败 (${INTERNAL_BUILD_VERSION})`,
})
return
}
const dpr = wx.getSystemInfoSync().pixelRatio || 1
try {
currentEngine.attachCanvas(
canvasRef.node,
rect.width,
rect.height,
dpr,
labelCanvasRef && labelCanvasRef.node ? labelCanvasRef.node : undefined,
)
stageCanvasAttached = true
} catch (error) {
page.setData({
statusText: `WebGL 鍒濆鍖栧け璐?(${INTERNAL_BUILD_VERSION})`,
})
}
})
}
const query = wx.createSelectorQuery().in(page)
query.select('.map-stage').boundingClientRect()
query.exec((res) => {
const rect = res[0] as WechatMiniprogram.BoundingClientRectCallbackResult | undefined
applyStage(rect)
})
},
handleTouchStart(event: WechatMiniprogram.TouchEvent) {
if (mapEngine) {
mapEngine.handleTouchStart(event)
}
},
handleTouchMove(event: WechatMiniprogram.TouchEvent) {
if (mapEngine) {
mapEngine.handleTouchMove(event)
}
},
handleTouchEnd(event: WechatMiniprogram.TouchEvent) {
if (mapEngine) {
mapEngine.handleTouchEnd(event)
}
},
handleTouchCancel() {
if (mapEngine) {
mapEngine.handleTouchCancel()
}
},
handleRecenter() {
if (mapEngine) {
mapEngine.handleRecenter()
}
},
handleRotateStep() {
if (mapEngine) {
mapEngine.handleRotateStep()
}
},
handleRotationReset() {
if (mapEngine) {
mapEngine.handleRotationReset()
}
},
handleSetManualMode() {
if (mapEngine) {
mapEngine.handleSetManualMode()
}
},
handleSetNorthUpMode() {
if (mapEngine) {
mapEngine.handleSetNorthUpMode()
}
},
handleSetHeadingUpMode() {
if (mapEngine) {
mapEngine.handleSetHeadingUpMode()
}
},
handleCycleNorthReferenceMode() {
if (mapEngine) {
mapEngine.handleCycleNorthReferenceMode()
}
},
handleAutoRotateCalibrate() {
if (mapEngine) {
mapEngine.handleAutoRotateCalibrate()
}
},
handleToggleGpsTracking() {
if (mapEngine) {
mapEngine.handleToggleGpsTracking()
}
},
handleSetRealLocationMode() {
if (mapEngine) {
mapEngine.handleSetRealLocationMode()
}
},
handleSetMockLocationMode() {
if (mapEngine) {
mapEngine.handleSetMockLocationMode()
}
},
handleConnectMockLocationBridge() {
if (mapEngine) {
mapEngine.handleConnectMockLocationBridge()
}
},
handleConnectAllMockSources() {
if (!mapEngine) {
return
}
const channelId = (this.data.mockChannelIdDraft || '').trim() || 'default'
this.setData({
mockChannelIdDraft: channelId,
})
persistMockChannelId(channelId)
persistMockAutoConnectEnabled(true)
mapEngine.handleSetMockChannelId(channelId)
mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft)
mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft)
mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft)
mapEngine.handleConnectMockLocationBridge()
mapEngine.handleSetMockLocationMode()
mapEngine.handleSetMockHeartRateMode()
mapEngine.handleConnectMockHeartRateBridge()
mapEngine.handleConnectMockDebugLogBridge()
},
handleOpenWebViewTest() {
wx.navigateTo({
url: '/pages/webview-test/webview-test',
})
},
handleMockChannelIdInput(event: WechatMiniprogram.Input) {
this.setData({
mockChannelIdDraft: event.detail.value,
})
},
handleSaveMockChannelId() {
const channelId = (this.data.mockChannelIdDraft || '').trim() || 'default'
this.setData({
mockChannelIdDraft: channelId,
})
persistMockChannelId(channelId)
if (mapEngine) {
mapEngine.handleSetMockChannelId(channelId)
}
},
handleMockBridgeUrlInput(event: WechatMiniprogram.Input) {
this.setData({
mockBridgeUrlDraft: event.detail.value,
})
},
handleSaveMockBridgeUrl() {
if (mapEngine) {
mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft)
}
},
handleDisconnectMockLocationBridge() {
persistMockAutoConnectEnabled(false)
if (mapEngine) {
mapEngine.handleDisconnectMockLocationBridge()
}
},
handleSetRealHeartRateMode() {
if (mapEngine) {
mapEngine.handleSetRealHeartRateMode()
}
},
handleSetMockHeartRateMode() {
if (mapEngine) {
mapEngine.handleSetMockHeartRateMode()
}
},
handleMockHeartRateBridgeUrlInput(event: WechatMiniprogram.Input) {
this.setData({
mockHeartRateBridgeUrlDraft: event.detail.value,
})
},
handleSaveMockHeartRateBridgeUrl() {
if (mapEngine) {
mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft)
}
},
handleMockDebugLogBridgeUrlInput(event: WechatMiniprogram.Input) {
this.setData({
mockDebugLogBridgeUrlDraft: event.detail.value,
})
},
handleSaveMockDebugLogBridgeUrl() {
if (mapEngine) {
mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft)
}
},
handleConnectMockDebugLogBridge() {
if (mapEngine) {
mapEngine.handleConnectMockDebugLogBridge()
}
},
handleDisconnectMockDebugLogBridge() {
persistMockAutoConnectEnabled(false)
if (mapEngine) {
mapEngine.handleDisconnectMockDebugLogBridge()
}
},
handleConnectMockHeartRateBridge() {
if (mapEngine) {
mapEngine.handleConnectMockHeartRateBridge()
}
},
handleDisconnectMockHeartRateBridge() {
persistMockAutoConnectEnabled(false)
if (mapEngine) {
mapEngine.handleDisconnectMockHeartRateBridge()
}
},
handleConnectHeartRate() {
if (mapEngine) {
mapEngine.handleConnectHeartRate()
}
},
handleDisconnectHeartRate() {
if (mapEngine) {
mapEngine.handleDisconnectHeartRate()
}
},
handleConnectHeartRateDevice(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) {
if (mapEngine && event.currentTarget && event.currentTarget.dataset && event.currentTarget.dataset.deviceId) {
mapEngine.handleConnectHeartRateDevice(event.currentTarget.dataset.deviceId)
}
},
handleClearPreferredHeartRateDevice() {
if (this.data.lockHeartRateDevice) {
return
}
if (mapEngine) {
mapEngine.handleClearPreferredHeartRateDevice()
}
},
handleDebugHeartRateBlue() {
if (mapEngine) {
mapEngine.handleDebugHeartRateTone('blue')
}
},
handleDebugHeartRatePurple() {
if (mapEngine) {
mapEngine.handleDebugHeartRateTone('purple')
}
},
handleDebugHeartRateGreen() {
if (mapEngine) {
mapEngine.handleDebugHeartRateTone('green')
}
},
handleDebugHeartRateYellow() {
if (mapEngine) {
mapEngine.handleDebugHeartRateTone('yellow')
}
},
handleDebugHeartRateOrange() {
if (mapEngine) {
mapEngine.handleDebugHeartRateTone('orange')
}
},
handleDebugHeartRateRed() {
if (mapEngine) {
mapEngine.handleDebugHeartRateTone('red')
}
},
handleDebugSetSessionRemainingWarning() {
if (mapEngine) {
mapEngine.handleDebugSetSessionRemainingWarning()
}
},
handleDebugSetSessionRemainingOneMinute() {
if (mapEngine) {
mapEngine.handleDebugSetSessionRemainingOneMinute()
}
},
handleDebugTimeoutSession() {
if (mapEngine) {
mapEngine.handleDebugTimeoutSession()
}
},
handleClearDebugHeartRate() {
if (mapEngine) {
mapEngine.handleClearDebugHeartRate()
}
},
handleToggleOsmReference() {
if (mapEngine) {
mapEngine.handleToggleOsmReference()
}
},
handleStartGame() {
if (mapEngine) {
systemSettingsLockLifetimeActive = true
this.applyRuntimeSystemSettings(true)
mapEngine.handleStartGame()
}
},
handleLoadClassicConfig() {
currentGameLaunchEnvelope = getDemoGameLaunchEnvelope('classic')
this.loadGameLaunchEnvelope(currentGameLaunchEnvelope)
},
handleLoadScoreOConfig() {
currentGameLaunchEnvelope = getDemoGameLaunchEnvelope('score-o')
this.loadGameLaunchEnvelope(currentGameLaunchEnvelope)
},
handleForceExitGame() {
if (!mapEngine || this.data.gameSessionStatus !== 'running') {
return
}
wx.showModal({
title: '确认退出',
content: '确认强制结束当前对局并返回开始前状态?',
confirmText: '确认退出',
cancelText: '取消',
success: (result) => {
if (result.confirm && mapEngine) {
this.syncBackendSessionFinish('cancelled')
systemSettingsLockLifetimeActive = false
mapEngine.handleForceExitGame()
}
},
})
},
handleSkipAction() {
if (!mapEngine || !this.data.skipButtonEnabled) {
return
}
if (!mapEngine.shouldConfirmSkipAction()) {
mapEngine.handleSkipAction()
return
}
wx.showModal({
title: '确认跳点',
content: '确认跳过当前检查点并切换到下一个目标点?',
confirmText: '确认跳过',
cancelText: '取消',
success: (result) => {
if (result.confirm && mapEngine) {
mapEngine.handleSkipAction()
}
},
})
},
handleClearMapTestArtifacts() {
if (mapEngine) {
mapEngine.handleClearMapTestArtifacts()
}
},
syncGameInfoPanelSnapshot() {
if (!mapEngine) {
return
}
const snapshot = mapEngine.getGameInfoSnapshot()
const localRows = snapshot.localRows.concat([
{ label: '比例尺开关', value: this.data.showCenterScaleRuler ? '开启' : '关闭' },
{ label: '比例尺锚点', value: this.data.centerScaleRulerAnchorMode === 'compass-center' ? '指北针圆心' : '屏幕中心' },
{ label: '按钮习惯', value: this.data.sideButtonPlacement === 'right' ? '右手' : '左手' },
{ label: '比例尺可见', value: this.data.centerScaleRulerVisible ? 'true' : 'false' },
{ label: '比例尺中心X', value: `${this.data.centerScaleRulerCenterXPx}px` },
{ label: '比例尺零点Y', value: `${this.data.centerScaleRulerZeroYPx}px` },
{ label: '比例尺高度', value: `${this.data.centerScaleRulerHeightPx}px` },
{ label: '比例尺主刻度数', value: String(this.data.centerScaleRulerMajorMarks.length) },
])
this.setData({
gameInfoTitle: snapshot.title,
gameInfoSubtitle: snapshot.subtitle,
gameInfoLocalRows: localRows,
gameInfoGlobalRows: snapshot.globalRows,
})
},
syncResultSceneSnapshot() {
if (!mapEngine) {
return
}
const snapshot = mapEngine.getResultSceneSnapshot()
this.setData({
resultSceneTitle: snapshot.title,
resultSceneSubtitle: snapshot.subtitle,
resultSceneHeroLabel: snapshot.heroLabel,
resultSceneHeroValue: snapshot.heroValue,
resultSceneRows: snapshot.rows,
})
},
scheduleGameInfoPanelSnapshotSync() {
if (!this.data.showGameInfoPanel) {
clearGameInfoPanelSyncTimer()
return
}
if (gameInfoPanelSyncTimer) {
return
}
gameInfoPanelSyncTimer = setTimeout(() => {
gameInfoPanelSyncTimer = 0
if (this.data.showGameInfoPanel) {
this.syncGameInfoPanelSnapshot()
}
}, 400) as unknown as number
},
handleOpenGameInfoPanel() {
clearGameInfoPanelSyncTimer()
this.syncGameInfoPanelSnapshot()
this.setData({
showDebugPanel: false,
showSystemSettingsPanel: false,
showGameInfoPanel: true,
...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: true,
showSystemSettingsPanel: false,
showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
skipButtonEnabled: this.data.skipButtonEnabled,
gameSessionStatus: this.data.gameSessionStatus,
gpsLockEnabled: this.data.gpsLockEnabled,
gpsLockAvailable: this.data.gpsLockAvailable,
}),
})
},
handleCloseGameInfoPanel() {
clearGameInfoPanelSyncTimer()
this.setData({
showGameInfoPanel: false,
...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: false,
showSystemSettingsPanel: this.data.showSystemSettingsPanel,
showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
skipButtonEnabled: this.data.skipButtonEnabled,
gameSessionStatus: this.data.gameSessionStatus,
gpsLockEnabled: this.data.gpsLockEnabled,
gpsLockAvailable: this.data.gpsLockAvailable,
}),
})
},
handleGameInfoPanelTap() {},
handleResultSceneTap() {},
handleCloseResultScene() {
this.setData({
showResultScene: false,
})
},
handleRestartFromResult() {
if (!mapEngine) {
return
}
this.setData({
showResultScene: false,
}, () => {
if (mapEngine) {
systemSettingsLockLifetimeActive = true
this.applyRuntimeSystemSettings(true)
mapEngine.handleStartGame()
}
})
},
handleOpenSystemSettingsPanel() {
clearGameInfoPanelSyncTimer()
this.setData({
showDebugPanel: false,
showGameInfoPanel: false,
showSystemSettingsPanel: true,
...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: false,
showSystemSettingsPanel: true,
showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
skipButtonEnabled: this.data.skipButtonEnabled,
gameSessionStatus: this.data.gameSessionStatus,
gpsLockEnabled: this.data.gpsLockEnabled,
gpsLockAvailable: this.data.gpsLockAvailable,
}),
})
},
handleCloseSystemSettingsPanel() {
this.setData({
showSystemSettingsPanel: false,
...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: this.data.showGameInfoPanel,
showSystemSettingsPanel: false,
showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
skipButtonEnabled: this.data.skipButtonEnabled,
gameSessionStatus: this.data.gameSessionStatus,
gpsLockEnabled: this.data.gpsLockEnabled,
gpsLockAvailable: this.data.gpsLockAvailable,
}),
})
},
handleSystemSettingsPanelTap() {},
handleSetAnimationLevelStandard() {
if (this.data.lockAnimationLevel || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
animationLevel: 'standard',
})
},
handleSetAnimationLevelLite() {
if (this.data.lockAnimationLevel || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
animationLevel: 'lite',
})
},
handleSetTrackModeNone() {
if (this.data.lockTrackMode || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
trackDisplayMode: 'none',
})
},
handleSetTrackModeTail() {
if (this.data.lockTrackMode || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
trackDisplayMode: 'tail',
})
},
handleSetTrackModeFull() {
if (this.data.lockTrackMode || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
trackDisplayMode: 'full',
})
},
handleSetTrackTailLengthShort() {
if (this.data.lockTrackTailLength || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
trackTailLength: 'short',
})
},
handleSetTrackTailLengthMedium() {
if (this.data.lockTrackTailLength || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
trackTailLength: 'medium',
})
},
handleSetTrackTailLengthLong() {
if (this.data.lockTrackTailLength || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
trackTailLength: 'long',
})
},
handleSetTrackColorPreset(event: WechatMiniprogram.TouchEvent) {
if (this.data.lockTrackColor || !mapEngine) {
return
}
const color = event.currentTarget.dataset.color as TrackColorPreset | undefined
if (!color) {
return
}
this.persistAndApplySystemSettings({
trackColorPreset: color,
})
},
handleSetTrackStyleClassic() {
if (this.data.lockTrackStyle || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
trackStyleProfile: 'classic',
})
},
handleSetTrackStyleNeon() {
if (this.data.lockTrackStyle || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
trackStyleProfile: 'neon',
})
},
handleSetGpsMarkerVisibleOn() {
if (this.data.lockGpsMarkerVisible || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
gpsMarkerVisible: true,
})
},
handleSetGpsMarkerVisibleOff() {
if (this.data.lockGpsMarkerVisible || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
gpsMarkerVisible: false,
})
},
handleSetGpsMarkerStyleDot() {
if (this.data.lockGpsMarkerStyle || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
gpsMarkerStyle: 'dot',
})
},
handleSetGpsMarkerStyleBeacon() {
if (this.data.lockGpsMarkerStyle || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
gpsMarkerStyle: 'beacon',
})
},
handleSetGpsMarkerStyleDisc() {
if (this.data.lockGpsMarkerStyle || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
gpsMarkerStyle: 'disc',
})
},
handleSetGpsMarkerStyleBadge() {
if (this.data.lockGpsMarkerStyle || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
gpsMarkerStyle: 'badge',
})
},
handleSetGpsMarkerSizeSmall() {
if (this.data.lockGpsMarkerSize || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
gpsMarkerSize: 'small',
})
},
handleSetGpsMarkerSizeMedium() {
if (this.data.lockGpsMarkerSize || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
gpsMarkerSize: 'medium',
})
},
handleSetGpsMarkerSizeLarge() {
if (this.data.lockGpsMarkerSize || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
gpsMarkerSize: 'large',
})
},
handleSetGpsMarkerColorPreset(event: WechatMiniprogram.TouchEvent) {
if (this.data.lockGpsMarkerColor || !mapEngine) {
return
}
const color = event.currentTarget.dataset.color as GpsMarkerColorPreset | undefined
if (!color) {
return
}
this.persistAndApplySystemSettings({
gpsMarkerColorPreset: color,
})
},
handleSetSideButtonPlacementLeft() {
if (this.data.lockSideButtonPlacement) {
return
}
this.persistAndApplySystemSettings({
sideButtonPlacement: 'left',
})
},
handleSetSideButtonPlacementRight() {
if (this.data.lockSideButtonPlacement) {
return
}
this.persistAndApplySystemSettings({
sideButtonPlacement: 'right',
})
},
handleSetAutoRotateEnabledOn() {
if (this.data.lockAutoRotate || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
autoRotateEnabled: true,
})
},
handleSetAutoRotateEnabledOff() {
if (this.data.lockAutoRotate || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
autoRotateEnabled: false,
})
},
handleSetCompassTuningSmooth() {
if (this.data.lockCompassTuning || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
compassTuningProfile: 'smooth',
})
},
handleSetCompassTuningBalanced() {
if (this.data.lockCompassTuning || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
compassTuningProfile: 'balanced',
})
},
handleSetCompassTuningResponsive() {
if (this.data.lockCompassTuning || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
compassTuningProfile: 'responsive',
})
},
handleSetNorthReferenceMagnetic() {
if (this.data.lockNorthReference || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
northReferenceMode: 'magnetic',
})
},
handleSetNorthReferenceTrue() {
if (this.data.lockNorthReference || !mapEngine) {
return
}
this.persistAndApplySystemSettings({
northReferenceMode: 'true',
})
},
handleOverlayTouch() {},
handlePunchAction() {
if (!this.data.punchButtonEnabled) {
return
}
if (mapEngine) {
mapEngine.handlePunchAction()
}
},
handleOpenPendingContentCard() {
if (mapEngine) {
mapEngine.openPendingContentCard()
}
},
handleOpenContentCardAction(event: WechatMiniprogram.BaseEvent) {
if (!mapEngine) {
return
}
wx.showToast({
title: '点击CTA',
icon: 'none',
duration: 900,
})
const actionType = event.currentTarget.dataset.type
const action = typeof actionType === 'string' ? mapEngine.openCurrentContentCardAction(actionType) : null
if (action === 'detail') {
wx.showToast({
title: '打开详情',
icon: 'none',
duration: 900,
})
return
}
if (action === 'quiz') {
return
}
if (action === 'photo') {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['camera'],
success: () => {
if (mapEngine) {
mapEngine.handleContentCardPhotoCaptured()
}
},
})
return
}
if (action === 'audio') {
if (!contentAudioRecorder) {
contentAudioRecorder = wx.getRecorderManager()
contentAudioRecorder.onStop(() => {
contentAudioRecording = false
if (mapEngine) {
mapEngine.handleContentCardAudioRecorded()
}
})
}
const recorder = contentAudioRecorder
if (!contentAudioRecording) {
contentAudioRecording = true
recorder.start({
duration: 8000,
format: 'mp3',
} as any)
wx.showToast({
title: '开始录音',
icon: 'none',
duration: 800,
})
} else {
recorder.stop()
}
}
},
handleContentQuizAnswer(event: WechatMiniprogram.BaseEvent) {
if (!mapEngine) {
return
}
const optionKey = event.currentTarget.dataset.key
if (typeof optionKey === 'string') {
mapEngine.handleContentCardQuizAnswer(optionKey)
}
},
handleDismissTransientContentCard() {
if (mapEngine) {
mapEngine.closeContentCard()
}
},
handleContentCardTap() {
if (!mapEngine) {
return
}
if (!this.data.contentCardActions.length) {
mapEngine.closeContentCard()
}
},
openH5Experience(request: H5ExperienceRequest) {
wx.navigateTo({
url: '/pages/experience-webview/experience-webview',
success: (result) => {
const eventChannel = result.eventChannel
eventChannel.on('fallback', (payload: H5ExperienceFallbackPayload) => {
if (mapEngine) {
mapEngine.handleH5ExperienceFallback(payload)
}
})
eventChannel.on('close', () => {
if (mapEngine) {
mapEngine.handleH5ExperienceClosed()
}
})
eventChannel.on('submitResult', () => {
if (mapEngine) {
mapEngine.handleH5ExperienceClosed()
}
})
eventChannel.emit('init', request)
},
fail: () => {
if (mapEngine) {
mapEngine.handleH5ExperienceFallback(request.fallback)
}
},
})
},
handleCloseContentCard() {
if (mapEngine) {
mapEngine.closeContentCard()
}
},
handleClosePunchHint() {
clearPunchHintDismissTimer()
this.setData({
showPunchHintBanner: false,
})
},
handlePunchHintTap() {},
handleHudPanelChange(event: WechatMiniprogram.CustomEvent<{ current: number }>) {
this.setData({
hudPanelIndex: event.detail.current || 0,
})
},
handleCycleSideButtons() {
const nextMode = getNextSideButtonMode(this.data.sideButtonMode)
this.setData({
...buildSideButtonVisibility(nextMode),
...buildSideButtonState({
sideButtonMode: nextMode,
showGameInfoPanel: this.data.showGameInfoPanel,
showSystemSettingsPanel: this.data.showSystemSettingsPanel,
showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
skipButtonEnabled: this.data.skipButtonEnabled,
gameSessionStatus: this.data.gameSessionStatus,
gpsLockEnabled: this.data.gpsLockEnabled,
gpsLockAvailable: this.data.gpsLockAvailable,
}),
})
},
handleToggleGpsLock() {
if (mapEngine) {
mapEngine.handleToggleGpsLock()
}
},
handleToggleMapRotateMode() {
if (!mapEngine || this.data.lockAutoRotate) {
return
}
if (this.data.orientationMode === 'heading-up') {
this.persistAndApplySystemSettings({
autoRotateEnabled: false,
})
return
}
this.persistAndApplySystemSettings({
autoRotateEnabled: true,
})
},
handleToggleDebugPanel() {
const nextShowDebugPanel = !this.data.showDebugPanel
if (!nextShowDebugPanel) {
clearGameInfoPanelSyncTimer()
}
if (mapEngine) {
mapEngine.setDiagnosticUiEnabled(nextShowDebugPanel)
}
this.setData({
showDebugPanel: nextShowDebugPanel,
showGameInfoPanel: false,
showSystemSettingsPanel: false,
...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: false,
showSystemSettingsPanel: false,
showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
skipButtonEnabled: this.data.skipButtonEnabled,
gameSessionStatus: this.data.gameSessionStatus,
gpsLockEnabled: this.data.gpsLockEnabled,
gpsLockAvailable: this.data.gpsLockAvailable,
}),
})
},
handleCloseDebugPanel() {
if (mapEngine) {
mapEngine.setDiagnosticUiEnabled(false)
}
this.setData({
showDebugPanel: false,
...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: this.data.showGameInfoPanel,
showSystemSettingsPanel: this.data.showSystemSettingsPanel,
showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
skipButtonEnabled: this.data.skipButtonEnabled,
gameSessionStatus: this.data.gameSessionStatus,
gpsLockEnabled: this.data.gpsLockEnabled,
gpsLockAvailable: this.data.gpsLockAvailable,
}),
})
},
applyCenterScaleRulerSettings(nextEnabled: boolean, nextAnchorMode: CenterScaleRulerAnchorMode) {
this.data.showCenterScaleRuler = nextEnabled
this.data.centerScaleRulerAnchorMode = nextAnchorMode
clearCenterScaleRulerSyncTimer()
clearCenterScaleRulerUpdateTimer()
const syncRulerFromEngine = () => {
if (!mapEngine) {
return
}
const engineSnapshot = mapEngine.getInitialData() as Partial<MapPageData>
updateCenterScaleRulerInputCache(engineSnapshot)
const mergedData = {
...centerScaleRulerInputCache,
...this.data,
showCenterScaleRuler: nextEnabled,
centerScaleRulerAnchorMode: nextAnchorMode,
} as MapPageData
this.setData({
...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, nextEnabled),
showCenterScaleRuler: nextEnabled,
centerScaleRulerAnchorMode: nextAnchorMode,
...buildCenterScaleRulerPatch(mergedData),
...buildSideButtonState(mergedData),
})
}
if (!nextEnabled) {
syncRulerFromEngine()
return
}
this.setData({
showCenterScaleRuler: true,
centerScaleRulerAnchorMode: nextAnchorMode,
...buildSideButtonState({
...this.data,
showCenterScaleRuler: true,
centerScaleRulerAnchorMode: nextAnchorMode,
} as MapPageData),
})
this.measureStageAndCanvas(() => {
syncRulerFromEngine()
})
centerScaleRulerSyncTimer = setTimeout(() => {
centerScaleRulerSyncTimer = 0
if (!this.data.showCenterScaleRuler) {
return
}
syncRulerFromEngine()
}, 96) as unknown as number
},
handleSetCenterScaleRulerVisibleOn() {
if (this.data.lockScaleRulerVisible) {
return
}
this.persistAndApplySystemSettings({
showCenterScaleRuler: true,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
}, {
applyCenterScaleRuler: true,
})
},
handleSetCenterScaleRulerVisibleOff() {
if (this.data.lockScaleRulerVisible) {
return
}
this.persistAndApplySystemSettings({
showCenterScaleRuler: false,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
}, {
applyCenterScaleRuler: true,
})
},
handleSetCenterScaleRulerAnchorScreenCenter() {
if (this.data.lockScaleRulerAnchor) {
return
}
this.persistAndApplySystemSettings({
showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: 'screen-center',
}, {
applyCenterScaleRuler: true,
})
},
handleSetCenterScaleRulerAnchorCompassCenter() {
if (this.data.lockScaleRulerAnchor) {
return
}
this.persistAndApplySystemSettings({
showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: 'compass-center',
}, {
applyCenterScaleRuler: true,
})
},
handleToggleCenterScaleRulerAnchor() {
if (!this.data.showCenterScaleRuler || this.data.lockScaleRulerAnchor) {
return
}
const nextAnchorMode: CenterScaleRulerAnchorMode = this.data.centerScaleRulerAnchorMode === 'screen-center'
? 'compass-center'
: 'screen-center'
this.persistAndApplySystemSettings({
centerScaleRulerAnchorMode: nextAnchorMode,
showCenterScaleRuler: this.data.showCenterScaleRuler,
}, {
applyCenterScaleRuler: true,
})
},
handleDebugPanelTap() {},
})