Переглянути джерело

Add configurable game flow, finish punching, and audio cues

zhangyan 2 тижнів тому
батько
коміт
48159be900

BIN
miniprogram/assets/sounds/control-complete.wav


BIN
miniprogram/assets/sounds/finish-complete.wav


BIN
miniprogram/assets/sounds/session-start.wav


BIN
miniprogram/assets/sounds/start-complete.wav


BIN
miniprogram/assets/sounds/warning.wav


+ 352 - 35
miniprogram/engine/map/mapEngine.ts

@@ -6,12 +6,17 @@ import { type MapRendererStats } from '../renderer/mapRenderer'
 import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
 import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
 import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
+import { GameRuntime } from '../../game/core/gameRuntime'
+import { type GameEffect } from '../../game/core/gameResult'
+import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
+import { SoundDirector } from '../../game/audio/soundDirector'
+import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
 
 const RENDER_MODE = 'Single WebGL Pipeline'
 const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
 const MAP_NORTH_OFFSET_DEG = 0
 let MAGNETIC_DECLINATION_DEG = -6.91
-let MAGNETIC_DECLINATION_TEXT = '6.91° W'
+let MAGNETIC_DECLINATION_TEXT = '6.91 W'
 const MIN_ZOOM = 15
 const MAX_ZOOM = 20
 const DEFAULT_ZOOM = 17
@@ -112,6 +117,17 @@ export interface MapEngineViewState {
   gpsTracking: boolean
   gpsTrackingText: string
   gpsCoordText: string
+  gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
+  panelProgressText: string
+  punchButtonText: string
+  punchButtonEnabled: boolean
+  punchHintText: string
+  punchFeedbackVisible: boolean
+  punchFeedbackText: string
+  punchFeedbackTone: 'neutral' | 'success' | 'warning'
+  contentCardVisible: boolean
+  contentCardTitle: string
+  contentCardBody: string
   osmReferenceEnabled: boolean
   osmReferenceText: string
 }
@@ -158,6 +174,17 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'gpsTracking',
   'gpsTrackingText',
   'gpsCoordText',
+  'gameSessionStatus',
+  'panelProgressText',
+  'punchButtonText',
+  'punchButtonEnabled',
+  'punchHintText',
+  'punchFeedbackVisible',
+  'punchFeedbackText',
+  'punchFeedbackTone',
+  'contentCardVisible',
+  'contentCardTitle',
+  'contentCardBody',
   'osmReferenceEnabled',
   'osmReferenceText',
 ]
@@ -216,7 +243,7 @@ function formatHeadingText(headingDeg: number | null): string {
     return '--'
   }
 
-  return `${Math.round(normalizeRotationDeg(headingDeg))}°`
+  return `${Math.round(normalizeRotationDeg(headingDeg))}`
 }
 
 function formatOrientationModeText(mode: OrientationMode): string {
@@ -244,7 +271,7 @@ function formatRotationToggleText(mode: OrientationMode): string {
     return '切到朝向朝上'
   }
 
-  return '切到手动旋转'
+  return '鍒囧埌鎵嬪姩鏃嬭浆'
 }
 
 function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
@@ -324,7 +351,7 @@ function formatCompassDeclinationText(mode: NorthReferenceMode): string {
 }
 
 function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
-  return mode === 'magnetic' ? '北参考:磁北' : '北参考:真北'
+  return mode === 'magnetic' ? '鍖楀弬鑰冿細纾佸寳' : '鍖楀弬鑰冿細鐪熷寳'
 }
 
 function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
@@ -371,7 +398,7 @@ function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number |
     return base
   }
 
-  return `${base} / ±${Math.round(accuracyMeters)}m`
+  return `${base} / ${Math.round(accuracyMeters)}m`
 }
 
 function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
@@ -381,11 +408,22 @@ function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
   return Math.sqrt(dx * dx + dy * dy)
 }
 
+function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
+  const fromLatRad = from.lat * Math.PI / 180
+  const toLatRad = to.lat * Math.PI / 180
+  const deltaLonRad = (to.lon - from.lon) * Math.PI / 180
+  const y = Math.sin(deltaLonRad) * Math.cos(toLatRad)
+  const x = Math.cos(fromLatRad) * Math.sin(toLatRad) - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad)
+  const bearingDeg = Math.atan2(y, x) * 180 / Math.PI
+  return normalizeRotationDeg(bearingDeg)
+}
+
 export class MapEngine {
   buildVersion: string
   renderer: WebGLMapRenderer
   compassController: CompassHeadingController
   locationController: LocationController
+  soundDirector: SoundDirector
   onData: (patch: Partial<MapEngineViewState>) => void
   state: MapEngineViewState
   previewScale: number
@@ -430,6 +468,14 @@ export class MapEngine {
   currentGpsAccuracyMeters: number | null
   courseData: OrienteeringCourseData | null
   cpRadiusMeters: number
+  gameRuntime: GameRuntime
+  gamePresentation: GamePresentationState
+  gameMode: 'classic-sequential'
+  punchPolicy: 'enter' | 'enter-confirm'
+  punchRadiusMeters: number
+  autoFinishOnLastControl: boolean
+  punchFeedbackTimer: number
+  contentCardTimer: number
   hasGpsCenteredOnce: boolean
 
   constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
@@ -471,6 +517,7 @@ export class MapEngine {
         }, true)
       },
     })
+    this.soundDirector = new SoundDirector()
     this.minZoom = MIN_ZOOM
     this.maxZoom = MAX_ZOOM
     this.defaultZoom = DEFAULT_ZOOM
@@ -482,6 +529,14 @@ export class MapEngine {
     this.currentGpsAccuracyMeters = null
     this.courseData = null
     this.cpRadiusMeters = 5
+    this.gameRuntime = new GameRuntime()
+    this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
+    this.gameMode = 'classic-sequential'
+    this.punchPolicy = 'enter-confirm'
+    this.punchRadiusMeters = 5
+    this.autoFinishOnLastControl = true
+    this.punchFeedbackTimer = 0
+    this.contentCardTimer = 0
     this.hasGpsCenteredOnce = false
     this.state = {
       buildVersion: this.buildVersion,
@@ -489,7 +544,7 @@ export class MapEngine {
       projectionMode: PROJECTION_MODE,
       mapReady: false,
       mapReadyText: 'BOOTING',
-      mapName: 'LCX 测试地图',
+      mapName: 'LCX 娴嬭瘯鍦板浘',
       configStatusText: '远程配置待加载',
       zoom: DEFAULT_ZOOM,
       rotationDeg: 0,
@@ -502,7 +557,7 @@ export class MapEngine {
       sensorHeadingText: '--',
       compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
       northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
-      autoRotateSourceText: formatAutoRotateSourceText('fusion', false),
+      autoRotateSourceText: formatAutoRotateSourceText('sensor', false),
       autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
       northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
       compassNeedleDeg: 0,
@@ -526,10 +581,21 @@ export class MapEngine {
       stageHeight: 0,
       stageLeft: 0,
       stageTop: 0,
-      statusText: `单 WebGL 管线已准备接入方向传感器 (${this.buildVersion})`,
+      statusText: `鍗?WebGL 绠$嚎宸插噯澶囨帴鍏ユ柟鍚戜紶鎰熷櫒 (${this.buildVersion})`,
       gpsTracking: false,
       gpsTrackingText: '持续定位待启动',
       gpsCoordText: '--',
+      panelProgressText: '0/0',
+      punchButtonText: '鎵撶偣',
+      gameSessionStatus: 'idle',
+      punchButtonEnabled: false,
+      punchHintText: '绛夊緟杩涘叆妫€鏌ョ偣鑼冨洿',
+      punchFeedbackVisible: false,
+      punchFeedbackText: '',
+      punchFeedbackTone: 'neutral',
+      contentCardVisible: false,
+      contentCardTitle: '',
+      contentCardBody: '',
       osmReferenceEnabled: false,
       osmReferenceText: 'OSM参考:关',
     }
@@ -561,7 +627,7 @@ export class MapEngine {
     this.autoRotateHeadingDeg = null
     this.courseHeadingDeg = null
     this.targetAutoRotationDeg = null
-    this.autoRotateSourceMode = 'fusion'
+    this.autoRotateSourceMode = 'sensor'
     this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
     this.autoRotateCalibrationPending = false
   }
@@ -575,13 +641,222 @@ export class MapEngine {
     this.clearPreviewResetTimer()
     this.clearViewSyncTimer()
     this.clearAutoRotateTimer()
+    this.clearPunchFeedbackTimer()
+    this.clearContentCardTimer()
     this.compassController.destroy()
     this.locationController.destroy()
+    this.soundDirector.destroy()
     this.renderer.destroy()
     this.mounted = false
   }
 
 
+  clearGameRuntime(): void {
+    this.gameRuntime.clear()
+    this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
+    this.setCourseHeading(null)
+  }
+
+  loadGameDefinitionFromCourse(): GameEffect[] {
+    if (!this.courseData) {
+      this.clearGameRuntime()
+      return []
+    }
+
+    const definition = buildGameDefinitionFromCourse(
+      this.courseData,
+      this.cpRadiusMeters,
+      this.gameMode,
+      this.autoFinishOnLastControl,
+      this.punchPolicy,
+      this.punchRadiusMeters,
+    )
+    const result = this.gameRuntime.loadDefinition(definition)
+    this.gamePresentation = result.presentation
+    this.refreshCourseHeadingFromPresentation()
+    return result.effects
+  }
+
+  refreshCourseHeadingFromPresentation(): void {
+    if (!this.courseData || !this.gamePresentation.activeLegIndices.length) {
+      this.setCourseHeading(null)
+      return
+    }
+
+    const activeLegIndex = this.gamePresentation.activeLegIndices[0]
+    const activeLeg = this.courseData.layers.legs[activeLegIndex]
+    if (!activeLeg) {
+      this.setCourseHeading(null)
+      return
+    }
+
+    this.setCourseHeading(getInitialBearingDeg(activeLeg.fromPoint, activeLeg.toPoint))
+  }
+
+  resolveGameStatusText(effects: GameEffect[]): string | null {
+    const lastEffect = effects.length ? effects[effects.length - 1] : null
+    if (!lastEffect) {
+      return null
+    }
+
+    if (lastEffect.type === 'control_completed') {
+      const sequenceText = typeof lastEffect.sequence === 'number' ? String(lastEffect.sequence) : lastEffect.controlId
+      return `宸插畬鎴愭鏌ョ偣 ${sequenceText} (${this.buildVersion})`
+    }
+
+    if (lastEffect.type === 'session_finished') {
+      return `璺嚎宸插畬鎴?(${this.buildVersion})`
+    }
+
+    if (lastEffect.type === 'session_started') {
+      return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})`
+    }
+
+    return null
+  }
+  getGameViewPatch(statusText?: string | null): Partial<MapEngineViewState> {
+    const patch: Partial<MapEngineViewState> = {
+      gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
+      panelProgressText: this.gamePresentation.progressText,
+      punchButtonText: this.gamePresentation.punchButtonText,
+      punchButtonEnabled: this.gamePresentation.punchButtonEnabled,
+      punchHintText: this.gamePresentation.punchHintText,
+    }
+
+    if (statusText) {
+      patch.statusText = statusText
+    }
+
+    return patch
+  }
+
+  clearPunchFeedbackTimer(): void {
+    if (this.punchFeedbackTimer) {
+      clearTimeout(this.punchFeedbackTimer)
+      this.punchFeedbackTimer = 0
+    }
+  }
+
+  clearContentCardTimer(): void {
+    if (this.contentCardTimer) {
+      clearTimeout(this.contentCardTimer)
+      this.contentCardTimer = 0
+    }
+  }
+
+  showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning'): void {
+    this.clearPunchFeedbackTimer()
+    this.setState({
+      punchFeedbackVisible: true,
+      punchFeedbackText: text,
+      punchFeedbackTone: tone,
+    }, true)
+    this.punchFeedbackTimer = setTimeout(() => {
+      this.punchFeedbackTimer = 0
+      this.setState({
+        punchFeedbackVisible: false,
+      }, true)
+    }, 1400) as unknown as number
+  }
+
+  showContentCard(title: string, body: string): void {
+    this.clearContentCardTimer()
+    this.setState({
+      contentCardVisible: true,
+      contentCardTitle: title,
+      contentCardBody: body,
+    }, true)
+    this.contentCardTimer = setTimeout(() => {
+      this.contentCardTimer = 0
+      this.setState({
+        contentCardVisible: false,
+      }, true)
+    }, 2600) as unknown as number
+  }
+
+  closeContentCard(): void {
+    this.clearContentCardTimer()
+    this.setState({
+      contentCardVisible: false,
+    }, true)
+  }
+
+  applyGameEffects(effects: GameEffect[]): string | null {
+    this.soundDirector.handleEffects(effects)
+    const statusText = this.resolveGameStatusText(effects)
+    for (const effect of effects) {
+      if (effect.type === 'punch_feedback') {
+        this.showPunchFeedback(effect.text, effect.tone)
+      }
+
+      if (effect.type === 'control_completed') {
+        this.showPunchFeedback(`完成 ${typeof effect.sequence === 'number' ? effect.sequence : effect.label}`, 'success')
+        this.showContentCard(effect.displayTitle, effect.displayBody)
+      }
+
+      if (effect.type === 'session_finished' && this.locationController.listening) {
+        this.locationController.stop()
+      }
+    }
+
+    return statusText
+  }
+
+  handleStartGame(): void {
+    if (!this.gameRuntime.definition || !this.gameRuntime.state) {
+      this.setState({
+        statusText: `当前还没有可开始的路线 (${this.buildVersion})`,
+      }, true)
+      return
+    }
+
+    if (this.gameRuntime.state.status !== 'idle') {
+      return
+    }
+
+    if (!this.locationController.listening) {
+      this.locationController.start()
+    }
+
+    const startedAt = Date.now()
+    let gameResult = this.gameRuntime.startSession(startedAt)
+    if (this.currentGpsPoint) {
+      gameResult = this.gameRuntime.dispatch({
+        type: 'gps_updated',
+        at: Date.now(),
+        lon: this.currentGpsPoint.lon,
+        lat: this.currentGpsPoint.lat,
+        accuracyMeters: this.currentGpsAccuracyMeters,
+      })
+    }
+
+    this.gamePresentation = this.gameRuntime.getPresentation()
+    this.refreshCourseHeadingFromPresentation()
+    const defaultStatusText = this.currentGpsPoint
+      ? `顺序打点已开始 (${this.buildVersion})`
+      : `顺序打点已开始,GPS定位启动中 (${this.buildVersion})`
+    const gameStatusText = this.applyGameEffects(gameResult.effects) || defaultStatusText
+    this.setState({
+      ...this.getGameViewPatch(gameStatusText),
+    }, true)
+    this.syncRenderer()
+  }
+
+
+  handlePunchAction(): void {
+    const gameResult = this.gameRuntime.dispatch({
+      type: 'punch_requested',
+      at: Date.now(),
+    })
+    this.gamePresentation = gameResult.presentation
+    this.refreshCourseHeadingFromPresentation()
+    const gameStatusText = this.applyGameEffects(gameResult.effects)
+    this.setState({
+      ...this.getGameViewPatch(gameStatusText),
+    }, true)
+    this.syncRenderer()
+  }
+
   handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
     const nextPoint: LonLatPoint = { lon: longitude, lat: latitude }
     const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
@@ -596,6 +871,20 @@ 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)
+    let gameStatusText: string | null = null
+
+    if (this.courseData) {
+      const gameResult = this.gameRuntime.dispatch({
+        type: 'gps_updated',
+        at: Date.now(),
+        lon: longitude,
+        lat: latitude,
+        accuracyMeters,
+      })
+      this.gamePresentation = gameResult.presentation
+      this.refreshCourseHeadingFromPresentation()
+      gameStatusText = this.applyGameEffects(gameResult.effects)
+    }
 
     if (gpsInsideMap && !this.hasGpsCenteredOnce) {
       this.hasGpsCenteredOnce = true
@@ -607,7 +896,8 @@ export class MapEngine {
         gpsTracking: true,
         gpsTrackingText: '持续定位进行中',
         gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
-      }, `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true)
+        ...this.getGameViewPatch(),
+      }, gameStatusText || `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true)
       return
     }
 
@@ -615,7 +905,7 @@ export class MapEngine {
       gpsTracking: true,
       gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
       gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
-      statusText: gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`,
+      ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
     }, true)
     this.syncRenderer()
   }
@@ -649,7 +939,7 @@ export class MapEngine {
         stageLeft: rect.left,
         stageTop: rect.top,
       },
-      `地图视口与 WebGL 引擎已对齐 (${this.buildVersion})`,
+      `鍦板浘瑙嗗彛涓?WebGL 寮曟搸宸插榻?(${this.buildVersion})`,
       true,
     )
   }
@@ -662,7 +952,7 @@ export class MapEngine {
     this.onData({
       mapReady: true,
       mapReadyText: 'READY',
-      statusText: `单 WebGL 管线已就绪,可切换手动或自动朝向 (${this.buildVersion})`,
+      statusText: `鍗?WebGL 绠$嚎宸插氨缁紝鍙垏鎹㈡墜鍔ㄦ垨鑷姩鏈濆悜 (${this.buildVersion})`,
     })
     this.syncRenderer()
     this.compassController.start()
@@ -679,9 +969,15 @@ export class MapEngine {
     this.tileBoundsByZoom = config.tileBoundsByZoom
     this.courseData = config.course
     this.cpRadiusMeters = config.cpRadiusMeters
+    this.gameMode = config.gameMode
+    this.punchPolicy = config.punchPolicy
+    this.punchRadiusMeters = config.punchRadiusMeters
+    this.autoFinishOnLastControl = config.autoFinishOnLastControl
 
+    const gameEffects = this.loadGameDefinitionFromCourse()
+    const gameStatusText = this.applyGameEffects(gameEffects)
     const statePatch: Partial<MapEngineViewState> = {
-      configStatusText: `远程配置已载入 / ${config.courseStatusText}`,
+      configStatusText: `杩滅▼閰嶇疆宸茶浇鍏?/ ${config.courseStatusText}`,
       projectionMode: config.projectionModeText,
       tileSource: config.tileSource,
       sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
@@ -689,6 +985,7 @@ export class MapEngine {
       northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
       northReferenceText: formatNorthReferenceText(this.northReferenceMode),
       compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
+      ...this.getGameViewPatch(),
     }
 
     if (!this.state.stageWidth || !this.state.stageHeight) {
@@ -698,7 +995,7 @@ export class MapEngine {
         centerTileX: this.defaultCenterTileX,
         centerTileY: this.defaultCenterTileY,
         centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
-        statusText: `远程地图配置已载入 (${this.buildVersion})`,
+        statusText: gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`,
       }, true)
       return
     }
@@ -710,7 +1007,7 @@ export class MapEngine {
       centerTileY: this.defaultCenterTileY,
       tileTranslateX: 0,
       tileTranslateY: 0,
-    }, `远程地图配置已载入 (${this.buildVersion})`, true, () => {
+    }, gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => {
       this.resetPreviewState()
       this.syncRenderer()
       if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
@@ -722,7 +1019,6 @@ export class MapEngine {
   handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
     this.clearInertiaTimer()
     this.clearPreviewResetTimer()
-    this.renderer.setAnimationPaused(true)
     this.panVelocityX = 0
     this.panVelocityY = 0
 
@@ -787,8 +1083,8 @@ export class MapEngine {
           rotationText: formatRotationText(nextRotationDeg),
         },
         this.state.orientationMode === 'heading-up'
-          ? `双指缩放中,自动朝向保持开启 (${this.buildVersion})`
-          : `双指缩放与旋转中 (${this.buildVersion})`,
+          ? `鍙屾寚缂╂斁涓紝鑷姩鏈濆悜淇濇寔寮€鍚?(${this.buildVersion})`
+          : `鍙屾寚缂╂斁涓庢棆杞腑 (${this.buildVersion})`,
       )
       return
     }
@@ -813,7 +1109,7 @@ export class MapEngine {
     this.normalizeTranslate(
       this.state.tileTranslateX + deltaX,
       this.state.tileTranslateY + deltaY,
-      `已拖拽单 WebGL 地图引擎 (${this.buildVersion})`,
+      `宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`,
     )
   }
 
@@ -895,7 +1191,7 @@ export class MapEngine {
         tileTranslateX: 0,
         tileTranslateY: 0,
       },
-      `已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`,
+      `宸插洖鍒板崟 WebGL 寮曟搸榛樿棣栧睆 (${this.buildVersion})`,
       true,
       () => {
         this.resetPreviewState()
@@ -909,7 +1205,7 @@ export class MapEngine {
   handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
     if (this.state.rotationMode === 'auto') {
       this.setState({
-        statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
+        statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
       }, true)
       return
     }
@@ -929,7 +1225,7 @@ export class MapEngine {
         rotationDeg: nextRotationDeg,
         rotationText: formatRotationText(nextRotationDeg),
       },
-      `旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
+      `鏃嬭浆瑙掑害璋冩暣鍒?${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
       true,
       () => {
         this.resetPreviewState()
@@ -942,7 +1238,7 @@ export class MapEngine {
   handleRotationReset(): void {
     if (this.state.rotationMode === 'auto') {
       this.setState({
-        statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
+        statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
       }, true)
       return
     }
@@ -966,7 +1262,7 @@ export class MapEngine {
         rotationDeg: targetRotationDeg,
         rotationText: formatRotationText(targetRotationDeg),
       },
-      `旋转角度已回到真北参考 (${this.buildVersion})`,
+      `鏃嬭浆瑙掑害宸插洖鍒扮湡鍖楀弬鑰?(${this.buildVersion})`,
       true,
       () => {
         this.resetPreviewState()
@@ -1009,20 +1305,20 @@ export class MapEngine {
   handleAutoRotateCalibrate(): void {
     if (this.state.orientationMode !== 'heading-up') {
       this.setState({
-        statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`,
+        statusText: `璇峰厛鍒囧埌鏈濆悜鏈濅笂妯″紡鍐嶆牎鍑?(${this.buildVersion})`,
       }, true)
       return
     }
 
     if (!this.calibrateAutoRotateToCurrentOrientation()) {
       this.setState({
-        statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`,
+        statusText: `褰撳墠杩樻病鏈変紶鎰熷櫒鏂瑰悜鏁版嵁锛屾殏鏃舵棤娉曟牎鍑?(${this.buildVersion})`,
       }, true)
       return
     }
 
     this.setState({
-      statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`,
+      statusText: `宸叉寜褰撳墠鎸佹満鏂瑰悜瀹屾垚鏈濆悜鏍″噯 (${this.buildVersion})`,
     }, true)
     this.scheduleAutoRotate()
   }
@@ -1038,7 +1334,7 @@ export class MapEngine {
       orientationMode: 'manual',
       orientationModeText: formatOrientationModeText('manual'),
       autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
-      statusText: `已切回手动地图旋转 (${this.buildVersion})`,
+      statusText: `宸插垏鍥炴墜鍔ㄥ湴鍥炬棆杞?(${this.buildVersion})`,
     }, true)
   }
 
@@ -1065,7 +1361,7 @@ export class MapEngine {
         autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
         northReferenceText: formatNorthReferenceText(this.northReferenceMode),
       },
-      `地图已固定为真北朝上 (${this.buildVersion})`,
+      `鍦板浘宸插浐瀹氫负鐪熷寳鏈濅笂 (${this.buildVersion})`,
       true,
       () => {
         this.resetPreviewState()
@@ -1086,7 +1382,7 @@ export class MapEngine {
       orientationModeText: formatOrientationModeText('heading-up'),
       autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
       northReferenceText: formatNorthReferenceText(this.northReferenceMode),
-      statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
+      statusText: `姝e湪鍚敤鏈濆悜鏈濅笂妯″紡 (${this.buildVersion})`,
     }, true)
     if (this.refreshAutoRotateTarget()) {
       this.scheduleAutoRotate()
@@ -1409,6 +1705,15 @@ export class MapEngine {
       gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
       course: this.courseData,
       cpRadiusMeters: this.cpRadiusMeters,
+      activeControlSequences: this.gamePresentation.activeControlSequences,
+      activeStart: this.gamePresentation.activeStart,
+      completedStart: this.gamePresentation.completedStart,
+      activeFinish: this.gamePresentation.activeFinish,
+      completedFinish: this.gamePresentation.completedFinish,
+      revealFullCourse: this.gamePresentation.revealFullCourse,
+      activeLegIndices: this.gamePresentation.activeLegIndices,
+      completedLegIndices: this.gamePresentation.completedLegIndices,
+      completedControlSequences: this.gamePresentation.completedControlSequences,
       osmReferenceEnabled: this.state.osmReferenceEnabled,
       overlayOpacity: MAP_OVERLAY_OPACITY,
     }
@@ -1701,7 +2006,7 @@ export class MapEngine {
           tileTranslateX: 0,
           tileTranslateY: 0,
         },
-        `缩放级别调整到 ${nextZoom}`,
+        `缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
         true,
         () => {
           this.setPreviewState(residualScale, stageX, stageY)
@@ -1728,7 +2033,7 @@ export class MapEngine {
         zoom: nextZoom,
         ...resolvedViewport,
       },
-      `缩放级别调整到 ${nextZoom}`,
+      `缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
       true,
       () => {
         this.setPreviewState(residualScale, stageX, stageY)
@@ -1748,7 +2053,7 @@ export class MapEngine {
 
       if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
         this.setState({
-          statusText: `惯性滑动结束 (${this.buildVersion})`,
+          statusText: `鎯€ф粦鍔ㄧ粨鏉?(${this.buildVersion})`,
         })
         this.renderer.setAnimationPaused(false)
         this.inertiaTimer = 0
@@ -1759,7 +2064,7 @@ export class MapEngine {
       this.normalizeTranslate(
         this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
         this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS,
-        `惯性滑动中 (${this.buildVersion})`,
+        `鎯€ф粦鍔ㄤ腑 (${this.buildVersion})`,
       )
 
       this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
@@ -1805,6 +2110,18 @@ export class MapEngine {
 
 
 
+
+
+
+
+
+
+
+
+
+
+
+
 
 
 

+ 18 - 2
miniprogram/engine/renderer/courseLabelRenderer.ts

@@ -5,6 +5,9 @@ const EARTH_CIRCUMFERENCE_METERS = 40075016.686
 const LABEL_FONT_SIZE_RATIO = 1.08
 const LABEL_OFFSET_X_RATIO = 1.18
 const LABEL_OFFSET_Y_RATIO = -0.68
+const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)'
+const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)'
+const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)'
 
 export class CourseLabelRenderer {
   courseLayer: CourseLayer
@@ -49,7 +52,7 @@ export class CourseLabelRenderer {
     const ctx = this.ctx
     this.clearCanvas(ctx)
 
-    if (!course || !course.controls.length) {
+    if (!course || !course.controls.length || !scene.revealFullCourse) {
       return
     }
 
@@ -60,13 +63,13 @@ export class CourseLabelRenderer {
 
     this.applyPreviewTransform(ctx, scene)
     ctx.save()
-    ctx.fillStyle = 'rgba(204, 0, 107, 0.98)'
     ctx.textAlign = 'left'
     ctx.textBaseline = 'middle'
     ctx.font = `700 ${fontSizePx}px sans-serif`
 
     for (const control of course.controls) {
       ctx.save()
+      ctx.fillStyle = this.getLabelColor(scene, control.sequence)
       ctx.translate(control.point.x, control.point.y)
       ctx.rotate(scene.rotationRad)
       ctx.fillText(String(control.sequence), offsetX, offsetY)
@@ -76,6 +79,18 @@ export class CourseLabelRenderer {
     ctx.restore()
   }
 
+  getLabelColor(scene: MapScene, sequence: number): string {
+    if (scene.activeControlSequences.includes(sequence)) {
+      return ACTIVE_LABEL_COLOR
+    }
+
+    if (scene.completedControlSequences.includes(sequence)) {
+      return COMPLETED_LABEL_COLOR
+    }
+
+    return DEFAULT_LABEL_COLOR
+  }
+
   clearCanvas(ctx: any): void {
     ctx.setTransform(1, 0, 0, 1, 0, 0)
     ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
@@ -118,3 +133,4 @@ export class CourseLabelRenderer {
     return latRad * 180 / Math.PI
   }
 }
+

+ 11 - 0
miniprogram/engine/renderer/mapRenderer.ts

@@ -29,6 +29,15 @@ export interface MapScene {
   gpsCalibrationOrigin: LonLatPoint
   course: OrienteeringCourseData | null
   cpRadiusMeters: number
+  activeControlSequences: number[]
+  activeStart: boolean
+  completedStart: boolean
+  activeFinish: boolean
+  completedFinish: boolean
+  revealFullCourse: boolean
+  activeLegIndices: number[]
+  completedLegIndices: number[]
+  completedControlSequences: number[]
   osmReferenceEnabled: boolean
   overlayOpacity: number
 }
@@ -54,3 +63,5 @@ export function buildCamera(scene: MapScene): CameraState {
     rotationRad: scene.rotationRad,
   }
 }
+
+

+ 200 - 28
miniprogram/engine/renderer/webglVectorRenderer.ts

@@ -6,6 +6,9 @@ import { TrackLayer } from '../layer/trackLayer'
 import { GpsLayer } from '../layer/gpsLayer'
 
 const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96]
+const COMPLETED_ROUTE_COLOR: [number, number, number, number] = [0.48, 0.5, 0.54, 0.82]
+const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1]
+const ACTIVE_LEG_COLOR: [number, number, number, number] = [0.18, 1, 0.94, 0.5]
 const EARTH_CIRCUMFERENCE_METERS = 40075016.686
 const CONTROL_RING_WIDTH_RATIO = 0.2
 const FINISH_INNER_RADIUS_RATIO = 0.6
@@ -13,16 +16,19 @@ const FINISH_RING_WIDTH_RATIO = 0.2
 const START_RING_WIDTH_RATIO = 0.2
 const LEG_WIDTH_RATIO = 0.2
 const LEG_TRIM_TO_RING_CENTER_RATIO = 1 - CONTROL_RING_WIDTH_RATIO / 2
+const ACTIVE_CONTROL_PULSE_SPEED = 0.18
+const ACTIVE_CONTROL_PULSE_MIN_SCALE = 1.12
+const ACTIVE_CONTROL_PULSE_MAX_SCALE = 1.46
+const ACTIVE_CONTROL_PULSE_WIDTH_RATIO = 0.12
+const GUIDE_FLOW_COUNT = 5
+const GUIDE_FLOW_SPEED = 0.02
+const GUIDE_FLOW_TRAIL = 0.16
+const GUIDE_FLOW_MIN_WIDTH_RATIO = 0.12
+const GUIDE_FLOW_MAX_WIDTH_RATIO = 0.22
+const GUIDE_FLOW_HEAD_RADIUS_RATIO = 0.18
 
 type RgbaColor = [number, number, number, number]
 
-const GUIDE_FLOW_COUNT = 6
-const GUIDE_FLOW_SPEED = 0.022
-const GUIDE_FLOW_MIN_RADIUS_RATIO = 0.14
-const GUIDE_FLOW_MAX_RADIUS_RATIO = 0.34
-const GUIDE_FLOW_OUTER_SCALE = 1.45
-const GUIDE_FLOW_INNER_SCALE = 0.56
-
 function createShader(gl: any, type: number, source: string): any {
   const shader = gl.createShader(type)
   if (!shader) {
@@ -225,20 +231,36 @@ export class WebGLVectorRenderer {
   ): void {
     const controlRadiusMeters = this.getControlRadiusMeters(scene)
 
-    for (const leg of course.legs) {
-      this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, scene)
+    if (scene.revealFullCourse) {
+    for (let index = 0; index < course.legs.length; index += 1) {
+      const leg = course.legs[index]
+      this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, this.getLegColor(scene, index), scene)
+      if (scene.activeLegIndices.includes(index)) {
+        this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene)
+      }
     }
 
-    const guideLeg = this.getGuideLeg(course)
+    const guideLeg = this.getGuideLeg(course, scene)
     if (guideLeg) {
       this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame)
     }
+    }
 
     for (const start of course.starts) {
-      this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene)
+      if (scene.activeStart) {
+        this.pushActiveStartPulse(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene, pulseFrame)
+      }
+      this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, this.getStartColor(scene), scene)
+    }
+    if (!scene.revealFullCourse) {
+      return
     }
 
     for (const control of course.controls) {
+      if (scene.activeControlSequences.includes(control.sequence)) {
+        this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame)
+      }
+
       this.pushRing(
         positions,
         colors,
@@ -246,12 +268,17 @@ export class WebGLVectorRenderer {
         control.point.y,
         this.getMetric(scene, controlRadiusMeters),
         this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)),
-        COURSE_COLOR,
+        this.getControlColor(scene, control.sequence),
         scene,
       )
     }
 
     for (const finish of course.finishes) {
+      if (scene.activeFinish) {
+        this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, scene, pulseFrame)
+      }
+
+      const finishColor = this.getFinishColor(scene)
       this.pushRing(
         positions,
         colors,
@@ -259,7 +286,7 @@ export class WebGLVectorRenderer {
         finish.point.y,
         this.getMetric(scene, controlRadiusMeters),
         this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)),
-        COURSE_COLOR,
+        finishColor,
         scene,
       )
       this.pushRing(
@@ -269,17 +296,46 @@ export class WebGLVectorRenderer {
         finish.point.y,
         this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO),
         this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)),
-        COURSE_COLOR,
+        finishColor,
         scene,
       )
     }
   }
 
-  getGuideLeg(course: ProjectedCourseLayers): ProjectedCourseLeg | null {
-    return course.legs.length ? course.legs[0] : null
+  getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null {
+    const activeIndex = scene.activeLegIndices.length ? scene.activeLegIndices[0] : -1
+    if (activeIndex >= 0 && activeIndex < course.legs.length) {
+      return course.legs[activeIndex]
+    }
+
+    return null
+  }
+
+  getLegColor(scene: MapScene, index: number): RgbaColor {
+    return this.isCompletedLeg(scene, index) ? COMPLETED_ROUTE_COLOR : COURSE_COLOR
+  }
+
+  isCompletedLeg(scene: MapScene, index: number): boolean {
+    return scene.completedLegIndices.includes(index)
   }
 
   pushCourseLeg(
+    positions: number[],
+    colors: number[],
+    leg: ProjectedCourseLeg,
+    controlRadiusMeters: number,
+    color: RgbaColor,
+    scene: MapScene,
+  ): void {
+    const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
+    if (!trimmed) {
+      return
+    }
+
+    this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), color, scene)
+  }
+
+  pushCourseLegHighlight(
     positions: number[],
     colors: number[],
     leg: ProjectedCourseLeg,
@@ -291,7 +347,110 @@ export class WebGLVectorRenderer {
       return
     }
 
-    this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), COURSE_COLOR, scene)
+    this.pushSegment(
+      positions,
+      colors,
+      trimmed.from,
+      trimmed.to,
+      this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO * 1.5),
+      ACTIVE_LEG_COLOR,
+      scene,
+    )
+  }
+
+  pushActiveControlPulse(
+    positions: number[],
+    colors: number[],
+    centerX: number,
+    centerY: number,
+    controlRadiusMeters: number,
+    scene: MapScene,
+    pulseFrame: number,
+  ): void {
+    const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2
+    const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse
+    const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO
+    const glowAlpha = 0.24 + pulse * 0.34
+    const glowColor: RgbaColor = [0.36, 1, 0.96, glowAlpha]
+
+    this.pushRing(
+      positions,
+      colors,
+      centerX,
+      centerY,
+      this.getMetric(scene, controlRadiusMeters * pulseScale),
+      this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)),
+      glowColor,
+      scene,
+    )
+  }
+
+  pushActiveStartPulse(
+    positions: number[],
+    colors: number[],
+    centerX: number,
+    centerY: number,
+    headingDeg: number | null,
+    controlRadiusMeters: number,
+    scene: MapScene,
+    pulseFrame: number,
+  ): void {
+    const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2
+    const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse
+    const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO
+    const glowAlpha = 0.24 + pulse * 0.34
+    const glowColor: RgbaColor = [0.36, 1, 0.96, glowAlpha]
+    const headingRad = ((headingDeg === null ? 0 : headingDeg) - 90) * Math.PI / 180
+    const ringCenterX = centerX + Math.cos(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04)
+    const ringCenterY = centerY + Math.sin(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04)
+
+    this.pushRing(
+      positions,
+      colors,
+      ringCenterX,
+      ringCenterY,
+      this.getMetric(scene, controlRadiusMeters * pulseScale),
+      this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)),
+      glowColor,
+      scene,
+    )
+  }
+
+  getStartColor(scene: MapScene): RgbaColor {
+    if (scene.activeStart) {
+      return ACTIVE_CONTROL_COLOR
+    }
+
+    if (scene.completedStart) {
+      return COMPLETED_ROUTE_COLOR
+    }
+
+    return COURSE_COLOR
+  }
+
+  getControlColor(scene: MapScene, sequence: number): RgbaColor {
+    if (scene.activeControlSequences.includes(sequence)) {
+      return ACTIVE_CONTROL_COLOR
+    }
+
+    if (scene.completedControlSequences.includes(sequence)) {
+      return COMPLETED_ROUTE_COLOR
+    }
+
+    return COURSE_COLOR
+  }
+
+
+  getFinishColor(scene: MapScene): RgbaColor {
+    if (scene.activeFinish) {
+      return ACTIVE_CONTROL_COLOR
+    }
+
+    if (scene.completedFinish) {
+      return COMPLETED_ROUTE_COLOR
+    }
+
+    return COURSE_COLOR
   }
 
   pushGuidanceFlow(
@@ -316,18 +475,28 @@ export class WebGLVectorRenderer {
 
     for (let index = 0; index < GUIDE_FLOW_COUNT; index += 1) {
       const progress = (pulseFrame * GUIDE_FLOW_SPEED + index / GUIDE_FLOW_COUNT) % 1
+      const tailProgress = Math.max(0, progress - GUIDE_FLOW_TRAIL)
+      const head = {
+        x: trimmed.from.x + dx * progress,
+        y: trimmed.from.y + dy * progress,
+      }
+      const tail = {
+        x: trimmed.from.x + dx * tailProgress,
+        y: trimmed.from.y + dy * tailProgress,
+      }
       const eased = progress * progress
-      const x = trimmed.from.x + dx * progress
-      const y = trimmed.from.y + dy * progress
-      const radius = this.getMetric(
+      const width = this.getMetric(
         scene,
-        controlRadiusMeters * (GUIDE_FLOW_MIN_RADIUS_RATIO + (GUIDE_FLOW_MAX_RADIUS_RATIO - GUIDE_FLOW_MIN_RADIUS_RATIO) * eased),
+        controlRadiusMeters * (GUIDE_FLOW_MIN_WIDTH_RATIO + (GUIDE_FLOW_MAX_WIDTH_RATIO - GUIDE_FLOW_MIN_WIDTH_RATIO) * eased),
       )
       const outerColor = this.getGuideFlowOuterColor(eased)
       const innerColor = this.getGuideFlowInnerColor(eased)
+      const headRadius = this.getMetric(scene, controlRadiusMeters * GUIDE_FLOW_HEAD_RADIUS_RATIO * (0.72 + eased * 0.42))
 
-      this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_OUTER_SCALE, outerColor, scene)
-      this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_INNER_SCALE, innerColor, scene)
+      this.pushSegment(positions, colors, tail, head, width * 1.9, outerColor, scene)
+      this.pushSegment(positions, colors, tail, head, width, innerColor, scene)
+      this.pushCircle(positions, colors, head.x, head.y, headRadius * 1.35, outerColor, scene)
+      this.pushCircle(positions, colors, head.x, head.y, headRadius, innerColor, scene)
     }
   }
 
@@ -345,11 +514,11 @@ export class WebGLVectorRenderer {
   }
 
   getGuideFlowOuterColor(progress: number): RgbaColor {
-    return [1, 0.18, 0.6, 0.16 + progress * 0.34]
+    return [0.28, 0.92, 1, 0.14 + progress * 0.22]
   }
 
   getGuideFlowInnerColor(progress: number): RgbaColor {
-    return [1, 0.95, 0.98, 0.3 + progress * 0.54]
+    return [0.94, 0.99, 1, 0.38 + progress * 0.42]
   }
 
   getLegTrim(kind: ProjectedCourseLeg['fromKind'], controlRadiusMeters: number, scene: MapScene): number {
@@ -398,6 +567,7 @@ export class WebGLVectorRenderer {
     centerY: number,
     headingDeg: number | null,
     controlRadiusMeters: number,
+    color: RgbaColor,
     scene: MapScene,
   ): void {
     const startRadius = this.getMetric(scene, controlRadiusMeters)
@@ -411,9 +581,9 @@ export class WebGLVectorRenderer {
       }
     })
 
-    this.pushSegment(positions, colors, vertices[0], vertices[1], startRingWidth, COURSE_COLOR, scene)
-    this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, COURSE_COLOR, scene)
-    this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, COURSE_COLOR, scene)
+    this.pushSegment(positions, colors, vertices[0], vertices[1], startRingWidth, color, scene)
+    this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, color, scene)
+    this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, color, scene)
   }
 
   pushRing(
@@ -515,3 +685,5 @@ export class WebGLVectorRenderer {
 }
 
 
+
+

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

@@ -0,0 +1,100 @@
+import { type GameEffect } from '../core/gameResult'
+
+type SoundKey = 'session-start' | 'start-complete' | 'control-complete' | 'finish-complete' | 'warning'
+
+const SOUND_SRC: Record<SoundKey, string> = {
+  'session-start': '/assets/sounds/session-start.wav',
+  'start-complete': '/assets/sounds/start-complete.wav',
+  'control-complete': '/assets/sounds/control-complete.wav',
+  'finish-complete': '/assets/sounds/finish-complete.wav',
+  warning: '/assets/sounds/warning.wav',
+}
+
+export class SoundDirector {
+  enabled: boolean
+  contexts: Partial<Record<SoundKey, WechatMiniprogram.InnerAudioContext>>
+
+  constructor() {
+    this.enabled = true
+    this.contexts = {}
+  }
+
+  setEnabled(enabled: boolean): void {
+    this.enabled = enabled
+  }
+
+  destroy(): void {
+    const keys = Object.keys(this.contexts) as SoundKey[]
+    for (const key of keys) {
+      const context = this.contexts[key]
+      if (!context) {
+        continue
+      }
+      context.stop()
+      context.destroy()
+    }
+    this.contexts = {}
+  }
+
+  handleEffects(effects: GameEffect[]): void {
+    if (!this.enabled || !effects.length) {
+      return
+    }
+
+    const hasFinishCompletion = effects.some((effect) => effect.type === 'control_completed' && effect.controlKind === 'finish')
+
+    for (const effect of effects) {
+      if (effect.type === 'session_started') {
+        this.play('session-start')
+        continue
+      }
+
+      if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
+        this.play('warning')
+        continue
+      }
+
+      if (effect.type === 'control_completed') {
+        if (effect.controlKind === 'start') {
+          this.play('start-complete')
+          continue
+        }
+
+        if (effect.controlKind === 'finish') {
+          this.play('finish-complete')
+          continue
+        }
+
+        this.play('control-complete')
+        continue
+      }
+
+      if (effect.type === 'session_finished' && !hasFinishCompletion) {
+        this.play('finish-complete')
+      }
+    }
+  }
+
+  play(key: SoundKey): void {
+    const context = this.getContext(key)
+    context.stop()
+    context.seek(0)
+    context.play()
+  }
+
+  getContext(key: SoundKey): WechatMiniprogram.InnerAudioContext {
+    const existing = this.contexts[key]
+    if (existing) {
+      return existing
+    }
+
+    const context = wx.createInnerAudioContext()
+    context.src = SOUND_SRC[key]
+    context.autoplay = false
+    context.loop = false
+    context.obeyMuteSwitch = true
+    context.volume = 1
+    this.contexts[key] = context
+    return context
+  }
+}

+ 76 - 0
miniprogram/game/content/courseToGameDefinition.ts

@@ -0,0 +1,76 @@
+import { type GameDefinition, type GameControl, type PunchPolicyType } from '../core/gameDefinition'
+import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
+
+function sortBySequence<T extends { sequence: number | null }>(items: T[]): T[] {
+  return [...items].sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
+}
+
+function buildDisplayBody(label: string, sequence: number | null): string {
+  if (typeof sequence === 'number') {
+    return `检查点 ${sequence} · ${label || String(sequence)}`
+  }
+
+  return label
+}
+
+export function buildGameDefinitionFromCourse(
+  course: OrienteeringCourseData,
+  controlRadiusMeters: number,
+  mode: GameDefinition['mode'] = 'classic-sequential',
+  autoFinishOnLastControl = true,
+  punchPolicy: PunchPolicyType = 'enter-confirm',
+  punchRadiusMeters = 5,
+): GameDefinition {
+  const controls: GameControl[] = []
+
+  for (const start of course.layers.starts) {
+    controls.push({
+      id: `start-${controls.length + 1}`,
+      code: start.label || 'S',
+      label: start.label || 'Start',
+      kind: 'start',
+      point: start.point,
+      sequence: null,
+      displayContent: null,
+    })
+  }
+
+  for (const control of sortBySequence(course.layers.controls)) {
+    const label = control.label || String(control.sequence)
+    controls.push({
+      id: `control-${control.sequence}`,
+      code: label,
+      label,
+      kind: 'control',
+      point: control.point,
+      sequence: control.sequence,
+      displayContent: {
+        title: `收集 ${label}`,
+        body: buildDisplayBody(label, control.sequence),
+      },
+    })
+  }
+
+  for (const finish of course.layers.finishes) {
+    controls.push({
+      id: `finish-${controls.length + 1}`,
+      code: finish.label || 'F',
+      label: finish.label || 'Finish',
+      kind: 'finish',
+      point: finish.point,
+      sequence: null,
+      displayContent: null,
+    })
+  }
+
+  return {
+    id: `course-${course.title || 'default'}`,
+    mode,
+    title: course.title || 'Classic Sequential',
+    controlRadiusMeters,
+    punchRadiusMeters,
+    punchPolicy,
+    controls,
+    autoFinishOnLastControl,
+  }
+}

+ 31 - 0
miniprogram/game/core/gameDefinition.ts

@@ -0,0 +1,31 @@
+import { type LonLatPoint } from '../../utils/projection'
+
+export type GameMode = 'classic-sequential'
+export type GameControlKind = 'start' | 'control' | 'finish'
+export type PunchPolicyType = 'enter' | 'enter-confirm'
+
+export interface GameControlDisplayContent {
+  title: string
+  body: string
+}
+
+export interface GameControl {
+  id: string
+  code: string
+  label: string
+  kind: GameControlKind
+  point: LonLatPoint
+  sequence: number | null
+  displayContent: GameControlDisplayContent | null
+}
+
+export interface GameDefinition {
+  id: string
+  mode: GameMode
+  title: string
+  controlRadiusMeters: number
+  punchRadiusMeters: number
+  punchPolicy: PunchPolicyType
+  controls: GameControl[]
+  autoFinishOnLastControl: boolean
+}

+ 5 - 0
miniprogram/game/core/gameEvent.ts

@@ -0,0 +1,5 @@
+export type GameEvent =
+  | { type: 'session_started'; at: number }
+  | { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
+  | { type: 'punch_requested'; at: number }
+  | { type: 'session_ended'; at: number }

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

@@ -0,0 +1,14 @@
+import { type GameSessionState } from './gameSessionState'
+import { type GamePresentationState } from '../presentation/presentationState'
+
+export type GameEffect =
+  | { type: 'session_started' }
+  | { 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: 'session_finished' }
+
+export interface GameResult {
+  nextState: GameSessionState
+  presentation: GamePresentationState
+  effects: GameEffect[]
+}

+ 89 - 0
miniprogram/game/core/gameRuntime.ts

@@ -0,0 +1,89 @@
+import { type GameDefinition } from './gameDefinition'
+import { type GameEvent } from './gameEvent'
+import { type GameResult } from './gameResult'
+import { type GameSessionState } from './gameSessionState'
+import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
+import { ClassicSequentialRule } from '../rules/classicSequentialRule'
+import { type RulePlugin } from '../rules/rulePlugin'
+
+export class GameRuntime {
+  definition: GameDefinition | null
+  plugin: RulePlugin | null
+  state: GameSessionState | null
+  presentation: GamePresentationState
+  lastResult: GameResult | null
+
+  constructor() {
+    this.definition = null
+    this.plugin = null
+    this.state = null
+    this.presentation = EMPTY_GAME_PRESENTATION_STATE
+    this.lastResult = null
+  }
+
+  clear(): void {
+    this.definition = null
+    this.plugin = null
+    this.state = null
+    this.presentation = EMPTY_GAME_PRESENTATION_STATE
+    this.lastResult = null
+  }
+
+  loadDefinition(definition: GameDefinition): GameResult {
+    this.definition = definition
+    this.plugin = this.resolvePlugin(definition)
+    this.state = this.plugin.initialize(definition)
+    const result: GameResult = {
+      nextState: this.state,
+      presentation: this.plugin.buildPresentation(definition, this.state),
+      effects: [],
+    }
+    this.presentation = result.presentation
+    this.lastResult = result
+    return result
+  }
+
+  startSession(startAt = Date.now()): GameResult {
+    return this.dispatch({ type: 'session_started', at: startAt })
+  }
+
+  dispatch(event: GameEvent): GameResult {
+    if (!this.definition || !this.plugin || !this.state) {
+      const emptyState: GameSessionState = {
+        status: 'idle',
+        startedAt: null,
+        endedAt: null,
+        completedControlIds: [],
+        currentTargetControlId: null,
+        inRangeControlId: null,
+        score: 0,
+      }
+      const result: GameResult = {
+        nextState: emptyState,
+        presentation: EMPTY_GAME_PRESENTATION_STATE,
+        effects: [],
+      }
+      this.lastResult = result
+      this.presentation = result.presentation
+      return result
+    }
+
+    const result = this.plugin.reduce(this.definition, this.state, event)
+    this.state = result.nextState
+    this.presentation = result.presentation
+    this.lastResult = result
+    return result
+  }
+
+  getPresentation(): GamePresentationState {
+    return this.presentation
+  }
+
+  resolvePlugin(definition: GameDefinition): RulePlugin {
+    if (definition.mode === 'classic-sequential') {
+      return new ClassicSequentialRule()
+    }
+
+    throw new Error(`未支持的玩法模式: ${definition.mode}`)
+  }
+}

+ 11 - 0
miniprogram/game/core/gameSessionState.ts

@@ -0,0 +1,11 @@
+export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
+
+export interface GameSessionState {
+  status: GameSessionStatus
+  startedAt: number | null
+  endedAt: number | null
+  completedControlIds: string[]
+  currentTargetControlId: string | null
+  inRangeControlId: string | null
+  score: number
+}

+ 39 - 0
miniprogram/game/presentation/presentationState.ts

@@ -0,0 +1,39 @@
+export interface GamePresentationState {
+  activeControlIds: string[]
+  activeControlSequences: number[]
+  activeStart: boolean
+  completedStart: boolean
+  activeFinish: boolean
+  completedFinish: boolean
+  revealFullCourse: boolean
+  activeLegIndices: number[]
+  completedLegIndices: number[]
+  completedControlIds: string[]
+  completedControlSequences: number[]
+  progressText: string
+  punchableControlId: string | null
+  punchButtonEnabled: boolean
+  punchButtonText: string
+  punchHintText: string
+}
+
+export const EMPTY_GAME_PRESENTATION_STATE: GamePresentationState = {
+  activeControlIds: [],
+  activeControlSequences: [],
+  activeStart: false,
+  completedStart: false,
+  activeFinish: false,
+  completedFinish: false,
+  revealFullCourse: false,
+  activeLegIndices: [],
+  completedLegIndices: [],
+  completedControlIds: [],
+  completedControlSequences: [],
+  progressText: '0/0',
+  punchableControlId: null,
+  punchButtonEnabled: false,
+  punchButtonText: '打点',
+  punchHintText: '等待进入检查点范围',
+}
+
+

+ 330 - 0
miniprogram/game/rules/classicSequentialRule.ts

@@ -0,0 +1,330 @@
+import { type LonLatPoint } from '../../utils/projection'
+import { type GameControl, type GameDefinition } from '../core/gameDefinition'
+import { type GameEvent } from '../core/gameEvent'
+import { type GameEffect, type GameResult } from '../core/gameResult'
+import { type GameSessionState } from '../core/gameSessionState'
+import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
+import { type RulePlugin } from './rulePlugin'
+
+function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
+  const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
+  const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
+  const dy = (b.lat - a.lat) * 110540
+  return Math.sqrt(dx * dx + dy * dy)
+}
+
+function getScoringControls(definition: GameDefinition): GameControl[] {
+  return definition.controls.filter((control) => control.kind === 'control')
+}
+
+function getSequentialTargets(definition: GameDefinition): GameControl[] {
+  return definition.controls
+}
+
+function getCompletedControlSequences(definition: GameDefinition, state: GameSessionState): number[] {
+  return getScoringControls(definition)
+    .filter((control) => state.completedControlIds.includes(control.id) && typeof control.sequence === 'number')
+    .map((control) => control.sequence as number)
+}
+
+function getCurrentTarget(definition: GameDefinition, state: GameSessionState): GameControl | null {
+  return getSequentialTargets(definition).find((control) => control.id === state.currentTargetControlId) || null
+}
+
+function getCompletedLegIndices(definition: GameDefinition, state: GameSessionState): number[] {
+  const targets = getSequentialTargets(definition)
+  const completedLegIndices: number[] = []
+
+  for (let index = 1; index < targets.length; index += 1) {
+    if (state.completedControlIds.includes(targets[index].id)) {
+      completedLegIndices.push(index - 1)
+    }
+  }
+
+  return completedLegIndices
+}
+
+function getTargetText(control: GameControl): string {
+  if (control.kind === 'start') {
+    return '开始点'
+  }
+
+  if (control.kind === 'finish') {
+    return '终点'
+  }
+
+  return '目标圈'
+}
+
+
+function buildPunchHintText(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl | null): string {
+  if (state.status === 'idle') {
+    return '点击开始后先打开始点'
+  }
+
+  if (state.status === 'finished') {
+    return '本局已完成'
+  }
+
+  if (!currentTarget) {
+    return '本局已完成'
+  }
+
+  const targetText = getTargetText(currentTarget)
+  if (state.inRangeControlId !== currentTarget.id) {
+    return definition.punchPolicy === 'enter'
+      ? `进入${targetText}自动打点`
+      : `进入${targetText}后点击打点`
+  }
+
+  return definition.punchPolicy === 'enter'
+    ? `${targetText}内,自动打点中`
+    : `${targetText}内,可点击打点`
+}
+
+function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
+  const scoringControls = getScoringControls(definition)
+  const sequentialTargets = getSequentialTargets(definition)
+  const currentTarget = getCurrentTarget(definition, state)
+  const currentTargetIndex = currentTarget ? sequentialTargets.findIndex((control) => control.id === currentTarget.id) : -1
+  const completedControls = scoringControls.filter((control) => state.completedControlIds.includes(control.id))
+  const running = state.status === 'running'
+  const activeLegIndices = running && currentTargetIndex > 0
+    ? [currentTargetIndex - 1]
+    : []
+  const completedLegIndices = getCompletedLegIndices(definition, state)
+  const punchButtonEnabled = running && !!currentTarget && state.inRangeControlId === currentTarget.id && definition.punchPolicy === 'enter-confirm'
+  const activeStart = running && !!currentTarget && currentTarget.kind === 'start'
+  const completedStart = definition.controls.some((control) => control.kind === 'start' && state.completedControlIds.includes(control.id))
+  const activeFinish = running && !!currentTarget && currentTarget.kind === 'finish'
+  const completedFinish = definition.controls.some((control) => control.kind === 'finish' && state.completedControlIds.includes(control.id))
+  const punchButtonText = currentTarget
+    ? currentTarget.kind === 'start'
+      ? '开始打卡'
+      : currentTarget.kind === 'finish'
+        ? '结束打卡'
+        : '打点'
+    : '打点'
+  const revealFullCourse = completedStart
+
+  if (!scoringControls.length) {
+    return {
+      ...EMPTY_GAME_PRESENTATION_STATE,
+      activeStart,
+      completedStart,
+      activeFinish,
+      completedFinish,
+      revealFullCourse,
+      activeLegIndices,
+      completedLegIndices,
+      progressText: '0/0',
+      punchButtonText,
+      punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
+      punchButtonEnabled,
+      punchHintText: buildPunchHintText(definition, state, currentTarget),
+    }
+  }
+
+  return {
+    activeControlIds: running && currentTarget ? [currentTarget.id] : [],
+    activeControlSequences: running && currentTarget && currentTarget.kind === 'control' && typeof currentTarget.sequence === 'number' ? [currentTarget.sequence] : [],
+    activeStart,
+    completedStart,
+    activeFinish,
+    completedFinish,
+    revealFullCourse,
+    activeLegIndices,
+    completedLegIndices,
+    completedControlIds: completedControls.map((control) => control.id),
+    completedControlSequences: getCompletedControlSequences(definition, state),
+    progressText: `${completedControls.length}/${scoringControls.length}`,
+    punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
+    punchButtonEnabled,
+    punchButtonText,
+    punchHintText: buildPunchHintText(definition, state, currentTarget),
+  }
+}
+
+function getInitialTargetId(definition: GameDefinition): string | null {
+  const firstTarget = getSequentialTargets(definition)[0]
+  return firstTarget ? firstTarget.id : null
+}
+
+function buildCompletedEffect(control: GameControl): GameEffect {
+  if (control.kind === 'start') {
+    return {
+      type: 'control_completed',
+      controlId: control.id,
+      controlKind: 'start',
+      sequence: null,
+      label: control.label,
+      displayTitle: '比赛开始',
+      displayBody: '已完成开始点打卡,前往 1 号点。',
+    }
+  }
+
+  if (control.kind === 'finish') {
+    return {
+      type: 'control_completed',
+      controlId: control.id,
+      controlKind: 'finish',
+      sequence: null,
+      label: control.label,
+      displayTitle: '比赛结束',
+      displayBody: '已完成终点打卡,本局结束。',
+    }
+  }
+
+  const sequenceText = typeof control.sequence === 'number' ? String(control.sequence) : control.label
+  const displayTitle = control.displayContent ? control.displayContent.title : `完成 ${sequenceText}`
+  const displayBody = control.displayContent ? control.displayContent.body : control.label
+
+  return {
+    type: 'control_completed',
+    controlId: control.id,
+    controlKind: 'control',
+    sequence: control.sequence,
+    label: control.label,
+    displayTitle,
+    displayBody,
+  }
+}
+
+function applyCompletion(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl, at: number): GameResult {
+  const targets = getSequentialTargets(definition)
+  const currentIndex = targets.findIndex((control) => control.id === currentTarget.id)
+  const completedControlIds = state.completedControlIds.includes(currentTarget.id)
+    ? state.completedControlIds
+    : [...state.completedControlIds, currentTarget.id]
+  const nextTarget = currentIndex >= 0 && currentIndex < targets.length - 1
+    ? targets[currentIndex + 1]
+    : null
+  const nextState: GameSessionState = {
+    ...state,
+    completedControlIds,
+    currentTargetControlId: nextTarget ? nextTarget.id : null,
+    inRangeControlId: null,
+    score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
+    status: nextTarget || !definition.autoFinishOnLastControl ? state.status : 'finished',
+    endedAt: nextTarget || !definition.autoFinishOnLastControl ? state.endedAt : at,
+  }
+  const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
+
+  if (!nextTarget && definition.autoFinishOnLastControl) {
+    effects.push({ type: 'session_finished' })
+  }
+
+  return {
+    nextState,
+    presentation: buildPresentation(definition, nextState),
+    effects,
+  }
+}
+
+export class ClassicSequentialRule implements RulePlugin {
+  get mode(): 'classic-sequential' {
+    return 'classic-sequential'
+  }
+
+  initialize(definition: GameDefinition): GameSessionState {
+    return {
+      status: 'idle',
+      startedAt: null,
+      endedAt: null,
+      completedControlIds: [],
+      currentTargetControlId: getInitialTargetId(definition),
+      inRangeControlId: null,
+      score: 0,
+    }
+  }
+
+  buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
+    return buildPresentation(definition, state)
+  }
+
+  reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult {
+    if (event.type === 'session_started') {
+      const nextState: GameSessionState = {
+        ...state,
+        status: 'running',
+        startedAt: event.at,
+        endedAt: null,
+        inRangeControlId: null,
+      }
+      return {
+        nextState,
+        presentation: buildPresentation(definition, nextState),
+        effects: [{ type: 'session_started' }],
+      }
+    }
+
+    if (event.type === 'session_ended') {
+      const nextState: GameSessionState = {
+        ...state,
+        status: 'finished',
+        endedAt: event.at,
+      }
+      return {
+        nextState,
+        presentation: buildPresentation(definition, nextState),
+        effects: [{ type: 'session_finished' }],
+      }
+    }
+
+    if (state.status !== 'running' || !state.currentTargetControlId) {
+      return {
+        nextState: state,
+        presentation: buildPresentation(definition, state),
+        effects: [],
+      }
+    }
+
+    const currentTarget = getCurrentTarget(definition, state)
+    if (!currentTarget) {
+      return {
+        nextState: state,
+        presentation: buildPresentation(definition, state),
+        effects: [],
+      }
+    }
+
+    if (event.type === 'gps_updated') {
+      const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
+      const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? currentTarget.id : null
+      const nextState: GameSessionState = {
+        ...state,
+        inRangeControlId,
+      }
+
+      if (definition.punchPolicy === 'enter' && inRangeControlId === currentTarget.id) {
+        return applyCompletion(definition, nextState, currentTarget, event.at)
+      }
+
+      return {
+        nextState,
+        presentation: buildPresentation(definition, nextState),
+        effects: [],
+      }
+    }
+
+    if (event.type === 'punch_requested') {
+      if (state.inRangeControlId !== currentTarget.id) {
+        return {
+          nextState: state,
+          presentation: buildPresentation(definition, state),
+          effects: [{ type: 'punch_feedback', text: currentTarget.kind === 'start' ? '未进入开始点打卡范围' : currentTarget.kind === 'finish' ? '未进入终点打卡范围' : '未进入目标打点范围', tone: 'warning' }],
+        }
+      }
+
+      return applyCompletion(definition, state, currentTarget, event.at)
+    }
+
+    return {
+      nextState: state,
+      presentation: buildPresentation(definition, state),
+      effects: [],
+    }
+  }
+}
+
+

+ 11 - 0
miniprogram/game/rules/rulePlugin.ts

@@ -0,0 +1,11 @@
+import { type GameDefinition } from '../core/gameDefinition'
+import { type GameEvent } from '../core/gameEvent'
+import { type GameResult } from '../core/gameResult'
+import { type GameSessionState } from '../core/gameSessionState'
+
+export interface RulePlugin {
+  readonly mode: GameDefinition['mode']
+  initialize(definition: GameDefinition): GameSessionState
+  buildPresentation(definition: GameDefinition, state: GameSessionState): GameResult['presentation']
+  reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult
+}

+ 49 - 2
miniprogram/pages/map/map.ts

@@ -97,8 +97,18 @@ Page({
     panelTimerText: '00:00:00',
     panelMileageText: '0m',
     panelDistanceValueText: '108',
-    panelProgressText: '0/14',
+    panelProgressText: '0/0',
+    gameSessionStatus: 'idle',
     panelSpeedValueText: '0',
+    punchButtonText: '打点',
+    punchButtonEnabled: false,
+    punchHintText: '等待进入检查点范围',
+    punchFeedbackVisible: false,
+    punchFeedbackText: '',
+    punchFeedbackTone: 'neutral',
+    contentCardVisible: false,
+    contentCardTitle: '',
+    contentCardBody: '',
     compassTicks: buildCompassTicks(),
     compassLabels: buildCompassLabels(),
     ...buildSideButtonVisibility('left'),
@@ -124,8 +134,18 @@ Page({
       panelTimerText: '00:00:00',
       panelMileageText: '0m',
       panelDistanceValueText: '108',
-      panelProgressText: '0/14',
+      panelProgressText: '0/0',
+      gameSessionStatus: 'idle',
       panelSpeedValueText: '0',
+      punchButtonText: '打点',
+      punchButtonEnabled: false,
+      punchHintText: '等待进入检查点范围',
+      punchFeedbackVisible: false,
+      punchFeedbackText: '',
+      punchFeedbackTone: 'neutral',
+      contentCardVisible: false,
+      contentCardTitle: '',
+      contentCardBody: '',
       compassTicks: buildCompassTicks(),
       compassLabels: buildCompassLabels(),
       ...buildSideButtonVisibility('left'),
@@ -311,6 +331,30 @@ Page({
     }
   },
 
+  handleStartGame() {
+    if (mapEngine) {
+      mapEngine.handleStartGame()
+    }
+  },
+
+  handleOverlayTouch() {},
+
+  handlePunchAction() {
+    if (!this.data.punchButtonEnabled) {
+      return
+    }
+
+    if (mapEngine) {
+      mapEngine.handlePunchAction()
+    }
+  },
+
+  handleCloseContentCard() {
+    if (mapEngine) {
+      mapEngine.closeContentCard()
+    }
+  },
+
   handleCycleSideButtons() {
     this.setData(buildSideButtonVisibility(getNextSideButtonMode(this.data.sideButtonMode)))
   },
@@ -378,6 +422,9 @@ Page({
 
 
 
+
+
+
 
 
 

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

@@ -23,6 +23,15 @@
 
     <view class="map-stage__crosshair"></view>
 
+    <view class="game-punch-hint" wx:if="{{punchHintText}}">{{punchHintText}}</view>
+    <view class="game-punch-feedback game-punch-feedback--{{punchFeedbackTone}}" wx:if="{{punchFeedbackVisible}}">{{punchFeedbackText}}</view>
+    <view class="game-content-card" wx:if="{{contentCardVisible}}" bindtap="handleCloseContentCard">
+      <view class="game-content-card__title">{{contentCardTitle}}</view>
+      <view class="game-content-card__body">{{contentCardBody}}</view>
+      <view class="game-content-card__hint">点击关闭</view>
+    </view>
+
+
     <view class="map-stage__overlay">
       <view class="map-stage__bottom">
         <view class="compass-widget">
@@ -87,6 +96,14 @@
     <cover-view class="map-side-button"><cover-view class="map-side-button__text">USER</cover-view></cover-view>
   </cover-view>
 
+  <cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}}" wx:if="{{!showDebugPanel}}" bindtap="handlePunchAction">
+    <cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view>
+  </cover-view>
+
+  <cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && showBottomDebugButton && gameSessionStatus === 'idle'}}" bindtap="handleStartGame">
+    <cover-view class="screen-button-layer__text screen-button-layer__text--start">开始</cover-view>
+  </cover-view>
+
   <cover-view class="screen-button-layer screen-button-layer--bottom-left" wx:if="{{!showDebugPanel && showBottomDebugButton}}" bindtap="handleToggleDebugPanel">
     <cover-view class="screen-button-layer__icon">
       <cover-view class="screen-button-layer__line"></cover-view>
@@ -111,7 +128,9 @@
 
     <view class="race-panel__grid">
       <view class="race-panel__cell race-panel__cell--action">
-        <view class="race-panel__play"></view>
+        <view class="race-panel__action-button"><!-- status only -->
+          <view class="race-panel__action-button-text">{{punchButtonText}}</view>
+        </view>
       </view>
       <view class="race-panel__cell race-panel__cell--timer">
         <text class="race-panel__timer">{{panelTimerText}}</text>
@@ -291,3 +310,5 @@
 
 
 
+
+

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

@@ -189,6 +189,23 @@
   bottom: 244rpx;
 }
 
+.screen-button-layer--start-left {
+  left: 24rpx;
+  bottom: 378rpx;
+  min-height: 96rpx;
+  padding: 0 18rpx;
+  background: rgba(255, 226, 88, 0.96);
+  box-shadow: 0 14rpx 36rpx rgba(120, 89, 0, 0.2), 0 0 0 3rpx rgba(255, 246, 186, 0.38);
+}
+
+.screen-button-layer__text--start {
+  margin-top: 0;
+  font-size: 30rpx;
+  font-weight: 800;
+  color: #6d4b00;
+  letter-spacing: 2rpx;
+}
+
 .map-side-toggle {
   position: absolute;
   left: 24rpx;
@@ -685,6 +702,36 @@
   right: 0;
   bottom: 0;
 }
+.map-punch-button {
+  position: absolute;
+  right: 24rpx;
+  bottom: 244rpx;
+  width: 92rpx;
+  height: 92rpx;
+  border-radius: 50%;
+  background: rgba(78, 92, 106, 0.82);
+  box-shadow: 0 12rpx 28rpx rgba(22, 34, 46, 0.22), inset 0 0 0 2rpx rgba(255, 255, 255, 0.08);
+  z-index: 18;
+}
+
+.map-punch-button__text {
+  font-size: 20rpx;
+  line-height: 92rpx;
+  font-weight: 800;
+  text-align: center;
+  color: rgba(236, 241, 246, 0.88);
+}
+
+.map-punch-button--active {
+  background: rgba(92, 255, 237, 0.96);
+  box-shadow: 0 0 0 5rpx rgba(149, 255, 244, 0.18), 0 0 30rpx rgba(92, 255, 237, 0.5);
+  animation: punch-button-ready 1s ease-in-out infinite;
+}
+
+.map-punch-button--active .map-punch-button__text {
+  color: #064d46;
+}
+
 
 .race-panel__line {
   position: absolute;
@@ -979,6 +1026,139 @@
 
 
 
+
+
+
+
+.game-punch-hint {
+  position: absolute;
+  left: 50%;
+  bottom: 280rpx;
+  transform: translateX(-50%);
+  max-width: 72vw;
+  padding: 14rpx 24rpx;
+  border-radius: 999rpx;
+  background: rgba(18, 33, 24, 0.78);
+  color: #f7fbf2;
+  font-size: 24rpx;
+  line-height: 1.2;
+  text-align: center;
+  z-index: 16;
+  pointer-events: none;
+}
+
+.game-punch-feedback {
+  position: absolute;
+  left: 50%;
+  top: 18%;
+  transform: translateX(-50%);
+  min-width: 240rpx;
+  padding: 20rpx 28rpx;
+  border-radius: 24rpx;
+  color: #ffffff;
+  font-size: 24rpx;
+  font-weight: 700;
+  text-align: center;
+  box-shadow: 0 16rpx 36rpx rgba(0, 0, 0, 0.18);
+  z-index: 17;
+  pointer-events: none;
+}
+
+.game-punch-feedback--neutral {
+  background: rgba(27, 109, 189, 0.92);
+}
+
+.game-punch-feedback--success {
+  background: rgba(37, 134, 88, 0.94);
+}
+
+.game-punch-feedback--warning {
+  background: rgba(196, 117, 18, 0.94);
+}
+
+.game-content-card {
+  position: absolute;
+  left: 50%;
+  top: 26%;
+  width: 440rpx;
+  max-width: calc(100vw - 72rpx);
+  transform: translateX(-50%);
+  padding: 28rpx 28rpx 24rpx;
+  border-radius: 28rpx;
+  background: rgba(248, 251, 244, 0.96);
+  box-shadow: 0 18rpx 48rpx rgba(22, 48, 32, 0.18);
+  box-sizing: border-box;
+  z-index: 17;
+}
+
+.game-content-card__title {
+  font-size: 34rpx;
+  line-height: 1.2;
+  font-weight: 700;
+  color: #163020;
+}
+
+.game-content-card__body {
+  margin-top: 12rpx;
+  font-size: 24rpx;
+  line-height: 1.5;
+  color: #45624b;
+}
+
+.game-content-card__hint {
+  margin-top: 16rpx;
+  font-size: 20rpx;
+  color: #809284;
+}
+
+.race-panel__action-button {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 116rpx;
+  min-height: 72rpx;
+  padding: 0 20rpx;
+  border-radius: 999rpx;
+  background: rgba(78, 92, 106, 0.54);
+  border: 2rpx solid rgba(210, 220, 228, 0.18);
+  box-sizing: border-box;
+  box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.06);
+}
+
+.race-panel__action-button--active {
+  background: rgba(255, 226, 88, 0.98);
+  border-color: rgba(255, 247, 194, 0.98);
+  box-shadow: 0 0 0 4rpx rgba(255, 241, 158, 0.18), 0 0 28rpx rgba(255, 239, 122, 0.42);
+  animation: punch-button-ready 1s ease-in-out infinite;
+}
+
+.race-panel__action-button-text {
+  font-size: 24rpx;
+  line-height: 1;
+  font-weight: 700;
+  color: rgba(236, 241, 246, 0.86);
+}
+
+.race-panel__action-button--active .race-panel__action-button-text {
+  color: #775000;
+}
+
+@keyframes punch-button-ready {
+  0% {
+    transform: scale(1);
+    box-shadow: 0 0 0 0 rgba(255, 241, 158, 0.22), 0 0 18rpx rgba(255, 239, 122, 0.28);
+  }
+
+  50% {
+    transform: scale(1.06);
+    box-shadow: 0 0 0 8rpx rgba(255, 241, 158, 0.08), 0 0 34rpx rgba(255, 239, 122, 0.52);
+  }
+
+  100% {
+    transform: scale(1);
+    box-shadow: 0 0 0 0 rgba(255, 241, 158, 0.22), 0 0 18rpx rgba(255, 239, 122, 0.28);
+  }
+}
 
 
 

+ 82 - 0
miniprogram/utils/remoteMapConfig.ts

@@ -29,6 +29,10 @@ export interface RemoteMapConfig {
   course: OrienteeringCourseData | null
   courseStatusText: string
   cpRadiusMeters: number
+  gameMode: 'classic-sequential'
+  punchPolicy: 'enter' | 'enter-confirm'
+  punchRadiusMeters: number
+  autoFinishOnLastControl: boolean
 }
 
 interface ParsedGameConfig {
@@ -36,6 +40,10 @@ interface ParsedGameConfig {
   mapMeta: string
   course: string | null
   cpRadiusMeters: number
+  gameMode: 'classic-sequential'
+  punchPolicy: 'enter' | 'enter-confirm'
+  punchRadiusMeters: number
+  autoFinishOnLastControl: boolean
   declinationDeg: number
 }
 
@@ -158,6 +166,28 @@ function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number {
   return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
 }
 
+function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean {
+  if (typeof rawValue === 'boolean') {
+    return rawValue
+  }
+
+  if (typeof rawValue === 'string') {
+    const normalized = rawValue.trim().toLowerCase()
+    if (normalized === 'true') {
+      return true
+    }
+    if (normalized === 'false') {
+      return false
+    }
+  }
+
+  return fallbackValue
+}
+
+function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
+  return rawValue === 'enter' ? 'enter' : 'enter-confirm'
+}
+
 function parseLooseJsonObject(text: string): Record<string, unknown> {
   const parsed: Record<string, unknown> = {}
   const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
@@ -198,17 +228,50 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig {
     normalized[key.toLowerCase()] = parsed[key]
   }
 
+  const rawGame = parsed.game && typeof parsed.game === 'object' && !Array.isArray(parsed.game)
+    ? parsed.game as Record<string, unknown>
+    : null
+  const normalizedGame: Record<string, unknown> = {}
+  if (rawGame) {
+    const gameKeys = Object.keys(rawGame)
+    for (const key of gameKeys) {
+      normalizedGame[key.toLowerCase()] = rawGame[key]
+    }
+  }
+
   const mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
   const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
   if (!mapRoot || !mapMeta) {
     throw new Error('game.json 缺少 map 或 mapmeta 字段')
   }
 
+  const gameMode = 'classic-sequential' as const
+  const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
+  if (typeof modeValue === 'string' && modeValue !== gameMode) {
+    throw new Error(`暂不支持的 game.mode: ${modeValue}`)
+  }
+
   return {
     mapRoot,
     mapMeta,
     course: typeof normalized.course === 'string' ? normalized.course : null,
     cpRadiusMeters: parsePositiveNumber(normalized.cpradius, 5),
+    gameMode,
+    punchPolicy: parsePunchPolicy(normalizedGame.punchpolicy !== undefined ? normalizedGame.punchpolicy : normalized.punchpolicy),
+    punchRadiusMeters: parsePositiveNumber(
+      normalizedGame.punchradiusmeters !== undefined
+        ? normalizedGame.punchradiusmeters
+        : normalizedGame.punchradius !== undefined
+          ? normalizedGame.punchradius
+          : normalized.punchradiusmeters !== undefined
+            ? normalized.punchradiusmeters
+            : normalized.punchradius,
+      5,
+    ),
+    autoFinishOnLastControl: parseBoolean(
+      normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
+      true,
+    ),
     declinationDeg: parseDeclinationValue(normalized.declination),
   }
 }
@@ -237,11 +300,23 @@ function parseGameConfigFromYaml(text: string): ParsedGameConfig {
     throw new Error('game.yaml 缺少 map 或 mapmeta 字段')
   }
 
+  const gameMode = 'classic-sequential' as const
+  if (config.gamemode && config.gamemode !== gameMode) {
+    throw new Error(`暂不支持的 game.mode: ${config.gamemode}`)
+  }
+
   return {
     mapRoot,
     mapMeta,
     course: typeof config.course === 'string' ? config.course : null,
     cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
+    gameMode,
+    punchPolicy: parsePunchPolicy(config.punchpolicy),
+    punchRadiusMeters: parsePositiveNumber(
+      config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
+      5,
+    ),
+    autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
     declinationDeg: parseDeclinationValue(config.declination),
   }
 }
@@ -459,5 +534,12 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
     course,
     courseStatusText,
     cpRadiusMeters: gameConfig.cpRadiusMeters,
+    gameMode: gameConfig.gameMode,
+    punchPolicy: gameConfig.punchPolicy,
+    punchRadiusMeters: gameConfig.punchRadiusMeters,
+    autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
   }
 }
+
+
+