Przeglądaj źródła

Improve map lock and smart heading behavior

zhangyan 1 tydzień temu
rodzic
commit
a19342d61f

+ 237 - 19
miniprogram/engine/map/mapEngine.ts

@@ -54,6 +54,10 @@ 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 SMART_HEADING_BLEND_START_SPEED_KMH = 1.2
+const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0
+const SMART_HEADING_MIN_DISTANCE_METERS = 8
+const SMART_HEADING_MAX_ACCURACY_METERS = 25
 const GPS_TRACK_MAX_POINTS = 200
 const GPS_TRACK_MIN_STEP_METERS = 3
 const MAP_TAP_MOVE_THRESHOLD_PX = 14
@@ -64,7 +68,8 @@ 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 AutoRotateSourceMode = 'sensor' | 'course' | 'fusion' | 'smart'
+type SmartHeadingSource = 'sensor' | 'blended' | 'movement'
 type NorthReferenceMode = 'magnetic' | 'true'
 
 const DEFAULT_NORTH_REFERENCE_MODE: NorthReferenceMode = 'magnetic'
@@ -122,6 +127,8 @@ export interface MapEngineViewState {
   statusText: string
   gpsTracking: boolean
   gpsTrackingText: string
+  gpsLockEnabled: boolean
+  gpsLockAvailable: boolean
   locationSourceMode: 'real' | 'mock'
   locationSourceText: string
   mockBridgeConnected: boolean
@@ -244,6 +251,8 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'statusText',
   'gpsTracking',
   'gpsTrackingText',
+  'gpsLockEnabled',
+  'gpsLockAvailable',
   'locationSourceMode',
   'locationSourceText',
   'mockBridgeConnected',
@@ -406,6 +415,10 @@ function formatRotationToggleText(mode: OrientationMode): string {
 }
 
 function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
+  if (mode === 'smart') {
+    return 'Smart / 手机朝向'
+  }
+
   if (mode === 'sensor') {
     return 'Sensor Only'
   }
@@ -417,6 +430,18 @@ function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading
   return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
 }
 
+function formatSmartHeadingSourceText(source: SmartHeadingSource): string {
+  if (source === 'movement') {
+    return 'Smart / 前进方向'
+  }
+
+  if (source === 'blended') {
+    return 'Smart / 融合'
+  }
+
+  return 'Smart / 手机朝向'
+}
+
 function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string {
   if (pending) {
     return 'Pending'
@@ -539,6 +564,18 @@ function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
   return Math.sqrt(dx * dx + dy * dy)
 }
 
+function resolveSmartHeadingSource(speedKmh: number | null, movementReliable: boolean): SmartHeadingSource {
+  if (!movementReliable || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
+    return 'sensor'
+  }
+
+  if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
+    return 'movement'
+  }
+
+  return 'blended'
+}
+
 function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
   const fromLatRad = from.lat * Math.PI / 180
   const toLatRad = to.lat * Math.PI / 180
@@ -601,6 +638,7 @@ export class MapEngine {
   currentGpsPoint: LonLatPoint | null
   currentGpsTrack: LonLatPoint[]
   currentGpsAccuracyMeters: number | null
+  currentGpsInsideMap: boolean
   courseData: OrienteeringCourseData | null
   courseOverlayVisible: boolean
   cpRadiusMeters: number
@@ -626,6 +664,7 @@ export class MapEngine {
   stageFxTimer: number
   sessionTimerInterval: number
   hasGpsCenteredOnce: boolean
+  gpsLockEnabled: boolean
 
   constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
     this.buildVersion = buildVersion
@@ -767,6 +806,7 @@ export class MapEngine {
     this.currentGpsPoint = null
     this.currentGpsTrack = []
     this.currentGpsAccuracyMeters = null
+    this.currentGpsInsideMap = false
     this.courseData = null
     this.courseOverlayVisible = false
     this.cpRadiusMeters = 5
@@ -787,6 +827,7 @@ export class MapEngine {
     this.skipRadiusMeters = 30
     this.skipRequiresConfirm = true
     this.autoFinishOnLastControl = true
+    this.gpsLockEnabled = false
     this.punchFeedbackTimer = 0
     this.contentCardTimer = 0
     this.mapPulseTimer = 0
@@ -812,7 +853,7 @@ export class MapEngine {
       sensorHeadingText: '--',
       compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
       northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
-      autoRotateSourceText: formatAutoRotateSourceText('sensor', false),
+      autoRotateSourceText: formatAutoRotateSourceText('smart', false),
       autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
       northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
       compassNeedleDeg: 0,
@@ -839,6 +880,8 @@ export class MapEngine {
       statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
       gpsTracking: false,
       gpsTrackingText: '持续定位待启动',
+      gpsLockEnabled: false,
+      gpsLockAvailable: false,
       locationSourceMode: 'real',
       locationSourceText: '真实定位',
       mockBridgeConnected: false,
@@ -932,7 +975,7 @@ export class MapEngine {
     this.autoRotateHeadingDeg = null
     this.courseHeadingDeg = null
     this.targetAutoRotationDeg = null
-    this.autoRotateSourceMode = 'sensor'
+    this.autoRotateSourceMode = 'smart'
     this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
     this.autoRotateCalibrationPending = false
   }
@@ -1052,6 +1095,7 @@ export class MapEngine {
     this.currentGpsPoint = null
     this.currentGpsTrack = []
     this.currentGpsAccuracyMeters = null
+    this.currentGpsInsideMap = false
     this.courseOverlayVisible = false
     this.setCourseHeading(null)
   }
@@ -1104,6 +1148,8 @@ export class MapEngine {
     const debugState = this.locationController.getDebugState()
     return {
       gpsTracking: debugState.listening,
+      gpsLockEnabled: this.gpsLockEnabled,
+      gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
       locationSourceMode: debugState.sourceMode,
       locationSourceText: debugState.sourceModeText,
       mockBridgeConnected: debugState.mockBridgeConnected,
@@ -1224,6 +1270,8 @@ export class MapEngine {
       punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
       skipButtonEnabled: this.isSkipAvailable(),
       punchHintText: this.gamePresentation.hud.punchHintText,
+      gpsLockEnabled: this.gpsLockEnabled,
+      gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
     }
 
     if (statusText) {
@@ -1510,15 +1558,21 @@ export class MapEngine {
     }
 
     const startedAt = Date.now()
-    let gameResult = this.gameRuntime.startSession(startedAt)
+    const startResult = this.gameRuntime.startSession(startedAt)
+    let gameResult = startResult
     if (this.currentGpsPoint) {
-      gameResult = this.gameRuntime.dispatch({
+      const gpsResult = this.gameRuntime.dispatch({
         type: 'gps_updated',
         at: Date.now(),
         lon: this.currentGpsPoint.lon,
         lat: this.currentGpsPoint.lat,
         accuracyMeters: this.currentGpsAccuracyMeters,
       })
+      gameResult = {
+        nextState: gpsResult.nextState,
+        presentation: gpsResult.presentation,
+        effects: [...startResult.effects, ...gpsResult.effects],
+      }
     }
 
     this.courseOverlayVisible = true
@@ -1534,6 +1588,7 @@ export class MapEngine {
     if (!this.courseData) {
       this.clearGameRuntime()
       this.resetTransientGameUiState()
+      this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
       this.setState({
         ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
       }, true)
@@ -1543,6 +1598,7 @@ export class MapEngine {
 
     this.loadGameDefinitionFromCourse()
     this.resetTransientGameUiState()
+    this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
     this.setState({
       ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
     }, true)
@@ -1572,8 +1628,14 @@ export class MapEngine {
     const gpsTileX = Math.floor(gpsWorldPoint.x)
     const gpsTileY = Math.floor(gpsWorldPoint.y)
     const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
+    this.currentGpsInsideMap = gpsInsideMap
     let gameStatusText: string | null = null
 
+    if (!gpsInsideMap && this.gpsLockEnabled) {
+      this.gpsLockEnabled = false
+      gameStatusText = `GPS已超出地图范围,锁定已关闭 (${this.buildVersion})`
+    }
+
     if (this.courseData) {
       const eventAt = Date.now()
       const gameResult = this.gameRuntime.dispatch({
@@ -1594,18 +1656,23 @@ export class MapEngine {
       gameStatusText = this.resolveAppliedGameStatusText(gameResult)
     }
 
-    if (gpsInsideMap && !this.hasGpsCenteredOnce) {
+    if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
+      this.scheduleAutoRotate()
+    }
+
+    if (gpsInsideMap && (this.gpsLockEnabled || !this.hasGpsCenteredOnce)) {
       this.hasGpsCenteredOnce = true
+      const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
       this.commitViewport({
-        centerTileX: gpsWorldPoint.x,
-        centerTileY: gpsWorldPoint.y,
-        tileTranslateX: 0,
-        tileTranslateY: 0,
+        ...lockedViewport,
         gpsTracking: true,
         gpsTrackingText: '持续定位进行中',
         gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
+        autoRotateSourceText: this.getAutoRotateSourceText(),
+        gpsLockEnabled: this.gpsLockEnabled,
+        gpsLockAvailable: true,
         ...this.getGameViewPatch(),
-      }, gameStatusText || `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true)
+      }, gameStatusText || (this.gpsLockEnabled ? `GPS锁定跟随中 (${this.buildVersion})` : `GPS定位成功,已定位到当前位置 (${this.buildVersion})`), true)
       return
     }
 
@@ -1613,11 +1680,62 @@ export class MapEngine {
       gpsTracking: true,
       gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
       gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
+      autoRotateSourceText: this.getAutoRotateSourceText(),
+      gpsLockEnabled: this.gpsLockEnabled,
+      gpsLockAvailable: gpsInsideMap,
       ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
     }, true)
     this.syncRenderer()
   }
 
+  handleToggleGpsLock(): void {
+    if (!this.currentGpsPoint || !this.currentGpsInsideMap) {
+      this.setState({
+        gpsLockEnabled: false,
+        gpsLockAvailable: false,
+        statusText: this.currentGpsPoint
+          ? `当前位置不在地图范围内,无法锁定 (${this.buildVersion})`
+          : `当前还没有可锁定的GPS位置 (${this.buildVersion})`,
+      }, true)
+      return
+    }
+
+    const nextEnabled = !this.gpsLockEnabled
+    this.gpsLockEnabled = nextEnabled
+
+    if (nextEnabled) {
+      const gpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
+      const gpsTileX = Math.floor(gpsWorldPoint.x)
+      const gpsTileY = Math.floor(gpsWorldPoint.y)
+      const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
+      if (gpsInsideMap) {
+        this.hasGpsCenteredOnce = true
+        const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
+        this.commitViewport({
+          ...lockedViewport,
+          gpsLockEnabled: true,
+          gpsLockAvailable: true,
+        }, `GPS已锁定在屏幕中央 (${this.buildVersion})`, true)
+        return
+      }
+
+      this.setState({
+        gpsLockEnabled: true,
+        gpsLockAvailable: true,
+        statusText: `GPS锁定已开启,等待进入地图范围 (${this.buildVersion})`,
+      }, true)
+      this.syncRenderer()
+      return
+    }
+
+    this.setState({
+      gpsLockEnabled: false,
+      gpsLockAvailable: true,
+      statusText: `GPS锁定已关闭 (${this.buildVersion})`,
+    }, true)
+    this.syncRenderer()
+  }
+
   handleToggleOsmReference(): void {
     const nextEnabled = !this.state.osmReferenceEnabled
     this.setState({
@@ -1906,13 +2024,17 @@ export class MapEngine {
     this.panVelocityY = 0
 
     if (event.touches.length >= 2) {
-      const origin = this.getStagePoint(event.touches)
+      const origin = this.gpsLockEnabled
+        ? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
+        : this.getStagePoint(event.touches)
       this.gestureMode = 'pinch'
       this.pinchStartDistance = this.getTouchDistance(event.touches)
       this.pinchStartScale = this.previewScale || 1
       this.pinchStartAngle = this.getTouchAngle(event.touches)
       this.pinchStartRotationDeg = this.state.rotationDeg
-      const anchorWorld = screenToWorld(this.getCameraState(), origin, true)
+      const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
+        ? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
+        : screenToWorld(this.getCameraState(), origin, true)
       this.pinchAnchorWorldX = anchorWorld.x
       this.pinchAnchorWorldY = anchorWorld.y
       this.setPreviewState(this.pinchStartScale, origin.x, origin.y)
@@ -1936,13 +2058,17 @@ export class MapEngine {
     if (event.touches.length >= 2) {
       const distance = this.getTouchDistance(event.touches)
       const angle = this.getTouchAngle(event.touches)
-      const origin = this.getStagePoint(event.touches)
+      const origin = this.gpsLockEnabled
+        ? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
+        : this.getStagePoint(event.touches)
       if (!this.pinchStartDistance) {
         this.pinchStartDistance = distance
         this.pinchStartScale = this.previewScale || 1
         this.pinchStartAngle = angle
         this.pinchStartRotationDeg = this.state.rotationDeg
-        const anchorWorld = screenToWorld(this.getCameraState(), origin, true)
+        const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
+          ? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
+          : screenToWorld(this.getCameraState(), origin, true)
         this.pinchAnchorWorldX = anchorWorld.x
         this.pinchAnchorWorldY = anchorWorld.y
       }
@@ -1992,6 +2118,12 @@ export class MapEngine {
     this.panLastY = touch.pageY
     this.panLastTimestamp = nextTimestamp
 
+    if (this.gpsLockEnabled) {
+      this.panVelocityX = 0
+      this.panVelocityY = 0
+      return
+    }
+
     this.normalizeTranslate(
       this.state.tileTranslateX + deltaX,
       this.state.tileTranslateY + deltaY,
@@ -2011,8 +2143,8 @@ export class MapEngine {
     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
+      const originX = this.gpsLockEnabled ? this.state.stageWidth / 2 : (this.previewOriginX || this.state.stageWidth / 2)
+      const originY = this.gpsLockEnabled ? this.state.stageHeight / 2 : (this.previewOriginY || this.state.stageHeight / 2)
 
       if (zoomDelta) {
         const residualScale = gestureScale / Math.pow(2, zoomDelta)
@@ -2350,6 +2482,7 @@ export class MapEngine {
       rotationToggleText: formatRotationToggleText('heading-up'),
       orientationMode: 'heading-up',
       orientationModeText: formatOrientationModeText('heading-up'),
+      autoRotateSourceText: this.getAutoRotateSourceText(),
       autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
       northReferenceText: formatNorthReferenceText(this.northReferenceMode),
       statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
@@ -2376,7 +2509,7 @@ export class MapEngine {
       sensorHeadingText: formatHeadingText(compassHeadingDeg),
       compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
       northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
-      autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
+      autoRotateSourceText: this.getAutoRotateSourceText(),
       compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
       northReferenceText: formatNorthReferenceText(this.northReferenceMode),
     })
@@ -2454,7 +2587,7 @@ export class MapEngine {
   setCourseHeading(headingDeg: number | null): void {
     this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg)
     this.setState({
-      autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
+      autoRotateSourceText: this.getAutoRotateSourceText(),
     })
 
     if (this.refreshAutoRotateTarget()) {
@@ -2462,7 +2595,72 @@ export class MapEngine {
     }
   }
 
+  getMovementHeadingDeg(): number | null {
+    if (!this.currentGpsInsideMap) {
+      return null
+    }
+
+    if (this.currentGpsAccuracyMeters !== null && this.currentGpsAccuracyMeters > SMART_HEADING_MAX_ACCURACY_METERS) {
+      return null
+    }
+
+    if (this.currentGpsTrack.length < 2) {
+      return null
+    }
+
+    const lastPoint = this.currentGpsTrack[this.currentGpsTrack.length - 1]
+    let accumulatedDistanceMeters = 0
+
+    for (let index = this.currentGpsTrack.length - 2; index >= 0; index -= 1) {
+      const nextPoint = this.currentGpsTrack[index + 1]
+      const point = this.currentGpsTrack[index]
+      accumulatedDistanceMeters += getApproxDistanceMeters(point, nextPoint)
+
+      if (accumulatedDistanceMeters >= SMART_HEADING_MIN_DISTANCE_METERS) {
+        return getInitialBearingDeg(point, lastPoint)
+      }
+    }
+
+    return null
+  }
+
+  getSmartAutoRotateHeadingDeg(): number | null {
+    const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
+      ? null
+      : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
+    const movementHeadingDeg = this.getMovementHeadingDeg()
+    const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
+    const smartSource = resolveSmartHeadingSource(speedKmh, movementHeadingDeg !== null)
+
+    if (smartSource === 'movement') {
+      return movementHeadingDeg === null ? sensorHeadingDeg : movementHeadingDeg
+    }
+
+    if (smartSource === 'blended' && sensorHeadingDeg !== null && movementHeadingDeg !== null && speedKmh !== null) {
+      const blend = Math.max(0, Math.min(1, (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH) / (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH)))
+      return interpolateAngleDeg(sensorHeadingDeg, movementHeadingDeg, blend)
+    }
+
+    return sensorHeadingDeg === null ? movementHeadingDeg : sensorHeadingDeg
+  }
+
+  getAutoRotateSourceText(): string {
+    if (this.autoRotateSourceMode !== 'smart') {
+      return formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null)
+    }
+
+    const smartSource = resolveSmartHeadingSource(
+      this.telemetryRuntime.state.currentSpeedKmh,
+      this.getMovementHeadingDeg() !== null,
+    )
+    return formatSmartHeadingSourceText(smartSource)
+  }
+
   resolveAutoRotateInputHeadingDeg(): number | null {
+    if (this.autoRotateSourceMode === 'smart') {
+      return this.getSmartAutoRotateHeadingDeg()
+    }
+
     const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
       ? null
       : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
@@ -2976,6 +3174,26 @@ export class MapEngine {
       return
     }
 
+    if (this.gpsLockEnabled && this.currentGpsPoint) {
+      const nextGpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, nextZoom)
+      const resolvedViewport = this.resolveViewportForExactCenter(nextGpsWorldPoint.x, nextGpsWorldPoint.y)
+      this.commitViewport(
+        {
+          zoom: nextZoom,
+          ...resolvedViewport,
+        },
+        `缩放级别调整到 ${nextZoom}`,
+        true,
+        () => {
+          this.setPreviewState(residualScale, this.state.stageWidth / 2, this.state.stageHeight / 2)
+          this.syncRenderer()
+          this.compassController.start()
+          this.animatePreviewToRest()
+        },
+      )
+      return
+    }
+
     if (!this.state.stageWidth || !this.state.stageHeight) {
       this.commitViewport(
         {

+ 6 - 0
miniprogram/game/audio/soundDirector.ts

@@ -73,6 +73,12 @@ export class SoundDirector {
         continue
       }
 
+      if (effect.type === 'session_cancelled') {
+        this.stopGuidanceLoop()
+        this.play('control_completed:finish')
+        continue
+      }
+
       if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
         this.play('punch_feedback:warning')
         continue

+ 1 - 0
miniprogram/game/core/gameResult.ts

@@ -3,6 +3,7 @@ import { type GamePresentationState } from '../presentation/presentationState'
 
 export type GameEffect =
   | { type: 'session_started' }
+  | { type: 'session_cancelled' }
   | { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' }
   | { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string }
   | { type: 'guidance_state_changed'; guidanceState: GuidanceState; controlId: string | null }

+ 5 - 0
miniprogram/game/feedback/hapticsDirector.ts

@@ -51,6 +51,11 @@ export class HapticsDirector {
         continue
       }
 
+      if (effect.type === 'session_cancelled') {
+        this.trigger('session_finished')
+        continue
+      }
+
       if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
         this.trigger('punch_feedback:warning')
         continue

+ 4 - 0
miniprogram/game/feedback/uiEffectDirector.ts

@@ -189,6 +189,10 @@ export class UiEffectDirector {
       if (effect.type === 'session_finished') {
         this.clearPunchButtonMotion()
       }
+
+      if (effect.type === 'session_cancelled') {
+        this.clearPunchButtonMotion()
+      }
     }
   }
 }

+ 46 - 4
miniprogram/pages/map/map.ts

@@ -42,6 +42,7 @@ type MapPageData = MapEngineViewState & {
   compassLabels: CompassLabelData[]
   sideButtonMode: SideButtonMode
   sideToggleIconSrc: string
+  sideButton2Class: string
   sideButton4Class: string
   sideButton11Class: string
   sideButton16Class: string
@@ -49,7 +50,7 @@ type MapPageData = MapEngineViewState & {
   showRightButtonGroups: boolean
   showBottomDebugButton: boolean
 }
-const INTERNAL_BUILD_VERSION = 'map-build-207'
+const INTERNAL_BUILD_VERSION = 'map-build-213'
 const CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json'
 const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json'
 let mapEngine: MapEngine | null = null
@@ -131,13 +132,19 @@ function getSideActionButtonClass(state: SideActionButtonState): string {
   return 'map-side-button map-side-button--default'
 }
 
-function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGameInfoPanel' | 'skipButtonEnabled' | 'gameSessionStatus'>) {
+function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGameInfoPanel' | 'skipButtonEnabled' | 'gameSessionStatus' | 'gpsLockEnabled' | 'gpsLockAvailable'>) {
+  const sideButton2State: SideActionButtonState = !data.gpsLockAvailable
+    ? 'muted'
+    : data.gpsLockEnabled
+      ? 'active'
+      : 'default'
   const sideButton4State: SideActionButtonState = data.gameSessionStatus === 'idle' ? 'default' : 'active'
   const sideButton11State: SideActionButtonState = data.showGameInfoPanel ? 'active' : 'default'
   const sideButton16State: SideActionButtonState = data.skipButtonEnabled ? 'default' : 'muted'
 
   return {
     sideToggleIconSrc: getSideToggleIconSrc(data.sideButtonMode),
+    sideButton2Class: getSideActionButtonClass(sideButton2State),
     sideButton4Class: getSideActionButtonClass(sideButton4State),
     sideButton11Class: getSideActionButtonClass(sideButton11State),
     sideButton16Class: getSideActionButtonClass(sideButton16State),
@@ -180,6 +187,8 @@ Page({
     panelProgressText: '0/0',
     gameSessionStatus: 'idle',
     gameModeText: '顺序赛',
+    gpsLockEnabled: false,
+    gpsLockAvailable: false,
     locationSourceMode: 'real',
     locationSourceText: '真实定位',
     mockBridgeConnected: false,
@@ -239,6 +248,8 @@ Page({
       showGameInfoPanel: false,
       skipButtonEnabled: false,
       gameSessionStatus: 'idle',
+      gpsLockEnabled: false,
+      gpsLockAvailable: false,
     }),
   } as unknown as MapPageData,
 
@@ -306,6 +317,8 @@ Page({
       panelProgressText: '0/0',
       gameSessionStatus: 'idle',
       gameModeText: '顺序赛',
+      gpsLockEnabled: false,
+      gpsLockAvailable: false,
       locationSourceMode: 'real',
       locationSourceText: '真实定位',
       mockBridgeConnected: false,
@@ -363,6 +376,8 @@ Page({
         showGameInfoPanel: false,
         skipButtonEnabled: false,
         gameSessionStatus: 'idle',
+        gpsLockEnabled: false,
+        gpsLockAvailable: false,
       }),
     })
   },
@@ -723,9 +738,21 @@ Page({
   },
 
   handleForceExitGame() {
-    if (mapEngine) {
-      mapEngine.handleForceExitGame()
+    if (!mapEngine || this.data.gameSessionStatus === 'idle') {
+      return
     }
+
+    wx.showModal({
+      title: '确认退出',
+      content: '确认强制结束当前对局并返回开始前状态?',
+      confirmText: '确认退出',
+      cancelText: '取消',
+      success: (result) => {
+        if (result.confirm && mapEngine) {
+          mapEngine.handleForceExitGame()
+        }
+      },
+    })
   },
 
   handleSkipAction() {
@@ -781,6 +808,8 @@ Page({
         showGameInfoPanel: true,
         skipButtonEnabled: this.data.skipButtonEnabled,
         gameSessionStatus: this.data.gameSessionStatus,
+        gpsLockEnabled: this.data.gpsLockEnabled,
+        gpsLockAvailable: this.data.gpsLockAvailable,
       }),
     })
   },
@@ -793,6 +822,8 @@ Page({
         showGameInfoPanel: false,
         skipButtonEnabled: this.data.skipButtonEnabled,
         gameSessionStatus: this.data.gameSessionStatus,
+        gpsLockEnabled: this.data.gpsLockEnabled,
+        gpsLockAvailable: this.data.gpsLockAvailable,
       }),
     })
   },
@@ -832,9 +863,16 @@ Page({
         showGameInfoPanel: this.data.showGameInfoPanel,
         skipButtonEnabled: this.data.skipButtonEnabled,
         gameSessionStatus: this.data.gameSessionStatus,
+        gpsLockEnabled: this.data.gpsLockEnabled,
+        gpsLockAvailable: this.data.gpsLockAvailable,
       }),
     })
   },
+  handleToggleGpsLock() {
+    if (mapEngine) {
+      mapEngine.handleToggleGpsLock()
+    }
+  },
   handleToggleMapRotateMode() {
     if (!mapEngine) {
       return
@@ -856,6 +894,8 @@ Page({
         showGameInfoPanel: false,
         skipButtonEnabled: this.data.skipButtonEnabled,
         gameSessionStatus: this.data.gameSessionStatus,
+        gpsLockEnabled: this.data.gpsLockEnabled,
+        gpsLockAvailable: this.data.gpsLockAvailable,
       }),
     })
   },
@@ -868,6 +908,8 @@ Page({
         showGameInfoPanel: this.data.showGameInfoPanel,
         skipButtonEnabled: this.data.skipButtonEnabled,
         gameSessionStatus: this.data.gameSessionStatus,
+        gpsLockEnabled: this.data.gpsLockEnabled,
+        gpsLockAvailable: this.data.gpsLockAvailable,
       }),
     })
   },

+ 1 - 2
miniprogram/pages/map/map.wxml

@@ -25,7 +25,6 @@
       ></canvas>
     </view>
 
-    <view class="map-stage__crosshair"></view>
     <view class="map-stage__map-pulse {{mapPulseFxClass}}" wx:if="{{mapPulseVisible}}" style="left: {{mapPulseLeftPx}}px; top: {{mapPulseTopPx}}px;"></view>
     <view class="map-stage__stage-fx {{stageFxClass}}" wx:if="{{stageFxVisible}}"></view>
 
@@ -77,7 +76,7 @@
   <cover-view class="map-side-column map-side-column--left map-side-column--left-group" wx:if="{{!showDebugPanel && !showGameInfoPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
     <cover-view class="map-side-button map-side-button--icon" bindtap="handleToggleMapRotateMode"><cover-image class="map-side-button__rotate-image {{orientationMode === 'heading-up' ? 'map-side-button__rotate-image--active' : ''}}" src="../../assets/btn_map_rotate_cropped.png"></cover-image></cover-view>
     <cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">1</cover-view></cover-view>
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">2</cover-view></cover-view>
+    <cover-view class="{{sideButton2Class}}" bindtap="handleToggleGpsLock"><cover-view class="map-side-button__text">2</cover-view></cover-view>
     <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">3</cover-view></cover-view>
     <cover-view class="{{sideButton4Class}}" bindtap="handleForceExitGame"><cover-image class="map-side-button__action-image" src="../../assets/btn_exit.png"></cover-image></cover-view>
   </cover-view>

+ 0 - 37
miniprogram/pages/map/map.wxss

@@ -51,43 +51,6 @@
   pointer-events: none;
 }
 
-.map-stage__crosshair {
-  position: absolute;
-  left: 50%;
-  top: 50%;
-  width: 44rpx;
-  height: 44rpx;
-  transform: translate(-50%, -50%);
-  border: 3rpx solid rgba(255, 255, 255, 0.95);
-  border-radius: 50%;
-  box-shadow: 0 0 0 4rpx rgba(22, 48, 32, 0.2);
-  pointer-events: none;
-  z-index: 3;
-}
-
-.map-stage__crosshair::before,
-.map-stage__crosshair::after {
-  content: '';
-  position: absolute;
-  background: rgba(255, 255, 255, 0.95);
-}
-
-.map-stage__crosshair::before {
-  left: 50%;
-  top: -18rpx;
-  width: 2rpx;
-  height: 76rpx;
-  transform: translateX(-50%);
-}
-
-.map-stage__crosshair::after {
-  left: -18rpx;
-  top: 50%;
-  width: 76rpx;
-  height: 2rpx;
-  transform: translateY(-50%);
-}
-
 .map-stage__map-pulse {
   position: absolute;
   width: 44rpx;