Просмотр исходного кода

Add score-o mode and split game map HUD presentation

zhangyan 2 недель назад
Родитель
Сommit
0295893b56

+ 163 - 18
miniprogram/engine/map/mapEngine.ts

@@ -55,6 +55,8 @@ const AUTO_ROTATE_HEADING_SMOOTHING = 0.32
 const COMPASS_NEEDLE_SMOOTHING = 0.12
 const GPS_TRACK_MAX_POINTS = 200
 const GPS_TRACK_MIN_STEP_METERS = 3
+const MAP_TAP_MOVE_THRESHOLD_PX = 14
+const MAP_TAP_DURATION_MS = 280
 
 type TouchPoint = WechatMiniprogram.TouchDetail
 
@@ -124,8 +126,11 @@ export interface MapEngineViewState {
   heartRateStatusText: string
   heartRateDeviceText: string
   gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
+  gameModeText: string
   panelTimerText: string
   panelMileageText: string
+  panelActionTagText: string
+  panelDistanceTagText: string
   panelDistanceValueText: string
   panelDistanceUnitText: string
   panelProgressText: string
@@ -209,8 +214,11 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'heartRateStatusText',
   'heartRateDeviceText',
   'gameSessionStatus',
+  'gameModeText',
   'panelTimerText',
   'panelMileageText',
+  'panelActionTagText',
+  'panelDistanceTagText',
   'panelDistanceValueText',
   'panelDistanceUnitText',
   'panelProgressText',
@@ -492,6 +500,9 @@ export class MapEngine {
   panLastX: number
   panLastY: number
   panLastTimestamp: number
+  tapStartX: number
+  tapStartY: number
+  tapStartAt: number
   panVelocityX: number
   panVelocityY: number
   pinchStartDistance: number
@@ -531,7 +542,7 @@ export class MapEngine {
   gameRuntime: GameRuntime
   telemetryRuntime: TelemetryRuntime
   gamePresentation: GamePresentationState
-  gameMode: 'classic-sequential'
+  gameMode: 'classic-sequential' | 'score-o'
   punchPolicy: 'enter' | 'enter-confirm'
   punchRadiusMeters: number
   autoFinishOnLastControl: boolean
@@ -711,6 +722,8 @@ export class MapEngine {
       heartRateDeviceText: '--',
       panelTimerText: '00:00:00',
       panelMileageText: '0m',
+      panelActionTagText: '目标',
+      panelDistanceTagText: '点距',
       panelDistanceValueText: '--',
       panelDistanceUnitText: '',
       panelProgressText: '0/0',
@@ -728,6 +741,7 @@ export class MapEngine {
       panelAccuracyUnitText: '',
       punchButtonText: '打点',
       gameSessionStatus: 'idle',
+      gameModeText: '顺序赛',
       punchButtonEnabled: false,
       punchHintText: '等待进入检查点范围',
       punchFeedbackVisible: false,
@@ -754,6 +768,9 @@ export class MapEngine {
     this.panLastX = 0
     this.panLastY = 0
     this.panLastTimestamp = 0
+    this.tapStartX = 0
+    this.tapStartY = 0
+    this.tapStartAt = 0
     this.panVelocityX = 0
     this.panVelocityY = 0
     this.pinchStartDistance = 0
@@ -812,6 +829,14 @@ export class MapEngine {
     this.setCourseHeading(null)
   }
 
+  getHudTargetControlId(): string | null {
+    return this.gamePresentation.hud.hudTargetControlId
+  }
+
+  getGameModeText(): string {
+    return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
+  }
+
   loadGameDefinitionFromCourse(): GameEffect[] {
     if (!this.courseData) {
       this.clearGameRuntime()
@@ -828,20 +853,23 @@ export class MapEngine {
     )
     const result = this.gameRuntime.loadDefinition(definition)
     this.telemetryRuntime.loadDefinition(definition)
-    this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState)
     this.gamePresentation = result.presentation
+    this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, this.getHudTargetControlId())
     this.refreshCourseHeadingFromPresentation()
     this.updateSessionTimerLoop()
+    this.setState({
+      gameModeText: this.getGameModeText(),
+    })
     return result.effects
   }
 
   refreshCourseHeadingFromPresentation(): void {
-    if (!this.courseData || !this.gamePresentation.activeLegIndices.length) {
+    if (!this.courseData || !this.gamePresentation.map.activeLegIndices.length) {
       this.setCourseHeading(null)
       return
     }
 
-    const activeLegIndex = this.gamePresentation.activeLegIndices[0]
+    const activeLegIndex = this.gamePresentation.map.activeLegIndices[0]
     const activeLeg = this.courseData.layers.legs[activeLegIndex]
     if (!activeLeg) {
       this.setCourseHeading(null)
@@ -876,8 +904,11 @@ export class MapEngine {
     const telemetryPresentation = this.telemetryRuntime.getPresentation()
     const patch: Partial<MapEngineViewState> = {
       gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
+      gameModeText: this.getGameModeText(),
       panelTimerText: telemetryPresentation.timerText,
       panelMileageText: telemetryPresentation.mileageText,
+      panelActionTagText: this.gamePresentation.hud.actionTagText,
+      panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
       panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
       panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
       panelSpeedValueText: telemetryPresentation.speedText,
@@ -892,10 +923,10 @@ export class MapEngine {
       panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
       panelAccuracyValueText: telemetryPresentation.accuracyValueText,
       panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
-      panelProgressText: this.gamePresentation.progressText,
-      punchButtonText: this.gamePresentation.punchButtonText,
-      punchButtonEnabled: this.gamePresentation.punchButtonEnabled,
-      punchHintText: this.gamePresentation.punchHintText,
+      panelProgressText: this.gamePresentation.hud.progressText,
+      punchButtonText: this.gamePresentation.hud.punchButtonText,
+      punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
+      punchHintText: this.gamePresentation.hud.punchHintText,
     }
 
     if (statusText) {
@@ -945,6 +976,8 @@ export class MapEngine {
     this.setState({
       panelTimerText: telemetryPresentation.timerText,
       panelMileageText: telemetryPresentation.mileageText,
+      panelActionTagText: this.gamePresentation.hud.actionTagText,
+      panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
       panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
       panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
       panelSpeedValueText: telemetryPresentation.speedText,
@@ -1099,7 +1132,7 @@ export class MapEngine {
 
   applyGameEffects(effects: GameEffect[]): string | null {
     this.feedbackDirector.handleEffects(effects)
-    this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state)
+    this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId())
     this.updateSessionTimerLoop()
     return this.resolveGameStatusText(effects)
   }
@@ -1239,6 +1272,22 @@ export class MapEngine {
     this.locationController.start()
   }
 
+  handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void {
+    if (this.gameMode === nextMode) {
+      return
+    }
+
+    this.gameMode = nextMode
+    const effects = this.loadGameDefinitionFromCourse()
+    const modeText = this.getGameModeText()
+    const statusText = this.applyGameEffects(effects) || `已切换到${modeText} (${this.buildVersion})`
+    this.setState({
+      ...this.getGameViewPatch(statusText),
+      gameModeText: modeText,
+    }, true)
+    this.syncRenderer()
+  }
+
   handleConnectHeartRate(): void {
     this.heartRateController.startScanAndConnect()
   }
@@ -1392,6 +1441,9 @@ export class MapEngine {
       this.panLastX = event.touches[0].pageX
       this.panLastY = event.touches[0].pageY
       this.panLastTimestamp = event.timeStamp || Date.now()
+      this.tapStartX = event.touches[0].pageX
+      this.tapStartY = event.touches[0].pageY
+      this.tapStartAt = event.timeStamp || Date.now()
     }
   }
 
@@ -1463,6 +1515,14 @@ export class MapEngine {
   }
 
   handleTouchEnd(event: WechatMiniprogram.TouchEvent): void {
+    const changedTouch = event.changedTouches && event.changedTouches.length ? event.changedTouches[0] : null
+    const endedAsTap = changedTouch
+      && this.gestureMode === 'pan'
+      && event.touches.length === 0
+      && Math.abs(changedTouch.pageX - this.tapStartX) <= MAP_TAP_MOVE_THRESHOLD_PX
+      && Math.abs(changedTouch.pageY - this.tapStartY) <= MAP_TAP_MOVE_THRESHOLD_PX
+      && ((event.timeStamp || Date.now()) - this.tapStartAt) <= MAP_TAP_DURATION_MS
+
     if (this.gestureMode === 'pinch' && event.touches.length < 2) {
       const gestureScale = this.previewScale || 1
       const zoomDelta = Math.round(Math.log2(gestureScale))
@@ -1509,6 +1569,10 @@ export class MapEngine {
       return
     }
 
+    if (endedAsTap && changedTouch) {
+      this.handleMapTap(changedTouch.pageX - this.state.stageLeft, changedTouch.pageY - this.state.stageTop)
+    }
+
     this.gestureMode = 'idle'
     this.resetPinchState()
     this.renderer.setAnimationPaused(false)
@@ -1526,6 +1590,80 @@ export class MapEngine {
     this.scheduleAutoRotate()
   }
 
+  handleMapTap(stageX: number, stageY: number): void {
+    if (!this.gameRuntime.definition || !this.gameRuntime.state || this.gameRuntime.definition.mode !== 'score-o') {
+      return
+    }
+
+    const focusedControlId = this.findFocusableControlAt(stageX, stageY)
+    if (focusedControlId === undefined) {
+      return
+    }
+
+    const gameResult = this.gameRuntime.dispatch({
+      type: 'control_focused',
+      at: Date.now(),
+      controlId: focusedControlId,
+    })
+    this.gamePresentation = gameResult.presentation
+    this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId())
+    this.setState({
+      ...this.getGameViewPatch(focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`),
+    }, true)
+    this.syncRenderer()
+  }
+
+  findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
+    if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
+      return undefined
+    }
+
+    const focusableControls = this.gameRuntime.definition.controls.filter((control) => (
+      this.gamePresentation.map.focusableControlIds.includes(control.id)
+    ))
+
+    let matchedControlId: string | null | undefined
+    let matchedDistance = Number.POSITIVE_INFINITY
+    const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())
+
+    for (const control of focusableControls) {
+      const screenPoint = this.getControlScreenPoint(control.id)
+      if (!screenPoint) {
+        continue
+      }
+
+      const distancePx = Math.sqrt(
+        Math.pow(screenPoint.x - stageX, 2)
+        + Math.pow(screenPoint.y - stageY, 2),
+      )
+      if (distancePx <= hitRadiusPx && distancePx < matchedDistance) {
+        matchedDistance = distancePx
+        matchedControlId = control.id
+      }
+    }
+
+    if (matchedControlId === undefined) {
+      return undefined
+    }
+
+    return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId
+  }
+
+  getControlHitRadiusPx(): number {
+    if (!this.state.tileSizePx) {
+      return 28
+    }
+
+    const centerLonLat = worldTileToLonLat({ x: this.state.centerTileX + 0.5, y: this.state.centerTileY + 0.5 }, this.state.zoom)
+    const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * 40075016.686 / Math.pow(2, this.state.zoom)
+    if (!metersPerTile) {
+      return 28
+    }
+
+    const pixelsPerMeter = this.state.tileSizePx / metersPerTile
+    return Math.max(28, this.cpRadiusMeters * pixelsPerMeter * 1.6)
+  }
+
   handleRecenter(): void {
     this.clearInertiaTimer()
     this.clearPreviewResetTimer()
@@ -2054,15 +2192,22 @@ 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,
+      controlVisualMode: this.gamePresentation.map.controlVisualMode,
+      showCourseLegs: this.gamePresentation.map.showCourseLegs,
+      guidanceLegAnimationEnabled: this.gamePresentation.map.guidanceLegAnimationEnabled,
+      focusableControlIds: this.gamePresentation.map.focusableControlIds,
+      focusedControlId: this.gamePresentation.map.focusedControlId,
+      focusedControlSequences: this.gamePresentation.map.focusedControlSequences,
+      activeControlSequences: this.gamePresentation.map.activeControlSequences,
+      activeStart: this.gamePresentation.map.activeStart,
+      completedStart: this.gamePresentation.map.completedStart,
+      activeFinish: this.gamePresentation.map.activeFinish,
+      focusedFinish: this.gamePresentation.map.focusedFinish,
+      completedFinish: this.gamePresentation.map.completedFinish,
+      revealFullCourse: this.gamePresentation.map.revealFullCourse,
+      activeLegIndices: this.gamePresentation.map.activeLegIndices,
+      completedLegIndices: this.gamePresentation.map.completedLegIndices,
+      completedControlSequences: this.gamePresentation.map.completedControlSequences,
       osmReferenceEnabled: this.state.osmReferenceEnabled,
       overlayOpacity: MAP_OVERLAY_OPACITY,
     }

+ 51 - 12
miniprogram/engine/renderer/courseLabelRenderer.ts

@@ -5,9 +5,15 @@ 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 SCORE_LABEL_FONT_SIZE_RATIO = 0.7
+const SCORE_LABEL_OFFSET_Y_RATIO = 0.06
 const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)'
 const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)'
+const MULTI_ACTIVE_LABEL_COLOR = 'rgba(255, 202, 72, 0.96)'
+const FOCUSED_LABEL_COLOR = 'rgba(255, 252, 255, 0.98)'
 const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)'
+const SCORE_LABEL_COLOR = 'rgba(255, 252, 242, 0.98)'
+const SCORE_COMPLETED_LABEL_COLOR = 'rgba(214, 218, 224, 0.94)'
 
 export class CourseLabelRenderer {
   courseLayer: CourseLayer
@@ -58,30 +64,51 @@ export class CourseLabelRenderer {
 
     const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
     const fontSizePx = this.getMetric(scene, controlRadiusMeters * LABEL_FONT_SIZE_RATIO)
+    const scoreFontSizePx = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_FONT_SIZE_RATIO)
+    const scoreOffsetY = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_OFFSET_Y_RATIO)
     const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO)
     const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO)
 
     this.applyPreviewTransform(ctx, scene)
     ctx.save()
-    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)
-      ctx.restore()
+    if (scene.controlVisualMode === 'multi-target') {
+      ctx.textAlign = 'center'
+      ctx.textBaseline = 'middle'
+      ctx.font = `900 ${scoreFontSizePx}px sans-serif`
+
+      for (const control of course.controls) {
+        ctx.save()
+        ctx.fillStyle = this.getScoreLabelColor(scene, control.sequence)
+        ctx.translate(control.point.x, control.point.y)
+        ctx.rotate(scene.rotationRad)
+        ctx.fillText(String(control.sequence), 0, scoreOffsetY)
+        ctx.restore()
+      }
+    } else {
+      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)
+        ctx.restore()
+      }
     }
 
     ctx.restore()
   }
 
   getLabelColor(scene: MapScene, sequence: number): string {
+    if (scene.focusedControlSequences.includes(sequence)) {
+      return FOCUSED_LABEL_COLOR
+    }
+
     if (scene.activeControlSequences.includes(sequence)) {
-      return ACTIVE_LABEL_COLOR
+      return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_LABEL_COLOR : ACTIVE_LABEL_COLOR
     }
 
     if (scene.completedControlSequences.includes(sequence)) {
@@ -91,6 +118,18 @@ export class CourseLabelRenderer {
     return DEFAULT_LABEL_COLOR
   }
 
+  getScoreLabelColor(scene: MapScene, sequence: number): string {
+    if (scene.focusedControlSequences.includes(sequence)) {
+      return FOCUSED_LABEL_COLOR
+    }
+
+    if (scene.completedControlSequences.includes(sequence)) {
+      return SCORE_COMPLETED_LABEL_COLOR
+    }
+
+    return SCORE_LABEL_COLOR
+  }
+
   clearCanvas(ctx: any): void {
     ctx.setTransform(1, 0, 0, 1, 0, 0)
     ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)

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

@@ -29,10 +29,17 @@ export interface MapScene {
   gpsCalibrationOrigin: LonLatPoint
   course: OrienteeringCourseData | null
   cpRadiusMeters: number
+  controlVisualMode: 'single-target' | 'multi-target'
+  showCourseLegs: boolean
+  guidanceLegAnimationEnabled: boolean
+  focusableControlIds: string[]
+  focusedControlId: string | null
+  focusedControlSequences: number[]
   activeControlSequences: number[]
   activeStart: boolean
   completedStart: boolean
   activeFinish: boolean
+  focusedFinish: boolean
   completedFinish: boolean
   revealFullCourse: boolean
   activeLegIndices: number[]

+ 53 - 15
miniprogram/engine/renderer/webglVectorRenderer.ts

@@ -8,6 +8,10 @@ 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 MULTI_ACTIVE_CONTROL_COLOR: [number, number, number, number] = [1, 0.8, 0.2, 0.98]
+const FOCUSED_CONTROL_COLOR: [number, number, number, number] = [0.98, 0.96, 0.98, 1]
+const MULTI_ACTIVE_PULSE_COLOR: [number, number, number, number] = [0.18, 1, 0.96, 0.86]
+const FOCUSED_PULSE_COLOR: [number, number, number, number] = [1, 0.36, 0.84, 0.88]
 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
@@ -231,19 +235,19 @@ export class WebGLVectorRenderer {
   ): void {
     const controlRadiusMeters = this.getControlRadiusMeters(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)
+    if (scene.revealFullCourse && scene.showCourseLegs) {
+      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.guidanceLegAnimationEnabled && scene.activeLegIndices.includes(index)) {
+          this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene)
+        }
       }
-    }
 
-    const guideLeg = this.getGuideLeg(course, scene)
-    if (guideLeg) {
-      this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame)
-    }
+      const guideLeg = this.getGuideLeg(course, scene)
+      if (guideLeg) {
+        this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame)
+      }
     }
 
     for (const start of course.starts) {
@@ -258,7 +262,12 @@ export class WebGLVectorRenderer {
 
     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)
+        if (scene.controlVisualMode === 'single-target') {
+          this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame)
+        } else {
+          this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame, MULTI_ACTIVE_PULSE_COLOR)
+          this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.2, scene, pulseFrame + 9, [0.9, 1, 1, 0.52])
+        }
       }
 
       this.pushRing(
@@ -271,12 +280,31 @@ export class WebGLVectorRenderer {
         this.getControlColor(scene, control.sequence),
         scene,
       )
+
+      if (scene.focusedControlSequences.includes(control.sequence)) {
+        this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.02, scene, pulseFrame, FOCUSED_PULSE_COLOR)
+        this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5])
+        this.pushRing(
+          positions,
+          colors,
+          control.point.x,
+          control.point.y,
+          this.getMetric(scene, controlRadiusMeters * 1.24),
+          this.getMetric(scene, controlRadiusMeters * 1.06),
+          FOCUSED_CONTROL_COLOR,
+          scene,
+        )
+      }
     }
 
     for (const finish of course.finishes) {
       if (scene.activeFinish) {
         this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, scene, pulseFrame)
       }
+      if (scene.focusedFinish) {
+        this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, FOCUSED_PULSE_COLOR)
+        this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.34, scene, pulseFrame + 12, [1, 0.86, 0.94, 0.46])
+      }
 
       const finishColor = this.getFinishColor(scene)
       this.pushRing(
@@ -303,6 +331,10 @@ export class WebGLVectorRenderer {
   }
 
   getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null {
+    if (!scene.guidanceLegAnimationEnabled) {
+      return null
+    }
+
     const activeIndex = scene.activeLegIndices.length ? scene.activeLegIndices[0] : -1
     if (activeIndex >= 0 && activeIndex < course.legs.length) {
       return course.legs[activeIndex]
@@ -366,12 +398,14 @@ export class WebGLVectorRenderer {
     controlRadiusMeters: number,
     scene: MapScene,
     pulseFrame: number,
+    pulseColor?: RgbaColor,
   ): 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 baseColor = pulseColor || ACTIVE_CONTROL_COLOR
+    const glowAlpha = Math.min(1, baseColor[3] * (0.46 + pulse * 0.5))
+    const glowColor: RgbaColor = [baseColor[0], baseColor[1], baseColor[2], glowAlpha]
 
     this.pushRing(
       positions,
@@ -430,7 +464,7 @@ export class WebGLVectorRenderer {
 
   getControlColor(scene: MapScene, sequence: number): RgbaColor {
     if (scene.activeControlSequences.includes(sequence)) {
-      return ACTIVE_CONTROL_COLOR
+      return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR
     }
 
     if (scene.completedControlSequences.includes(sequence)) {
@@ -442,6 +476,10 @@ export class WebGLVectorRenderer {
 
 
   getFinishColor(scene: MapScene): RgbaColor {
+    if (scene.focusedFinish) {
+      return FOCUSED_CONTROL_COLOR
+    }
+
     if (scene.activeFinish) {
       return ACTIVE_CONTROL_COLOR
     }

+ 1 - 1
miniprogram/game/content/courseToGameDefinition.ts

@@ -66,7 +66,7 @@ export function buildGameDefinitionFromCourse(
   return {
     id: `course-${course.title || 'default'}`,
     mode,
-    title: course.title || 'Classic Sequential',
+    title: course.title || (mode === 'score-o' ? 'Score-O' : 'Classic Sequential'),
     controlRadiusMeters,
     punchRadiusMeters,
     punchPolicy,

+ 1 - 1
miniprogram/game/core/gameDefinition.ts

@@ -1,7 +1,7 @@
 import { type LonLatPoint } from '../../utils/projection'
 import { type GameAudioConfig } from '../audio/audioConfig'
 
-export type GameMode = 'classic-sequential'
+export type GameMode = 'classic-sequential' | 'score-o'
 export type GameControlKind = 'start' | 'control' | 'finish'
 export type PunchPolicyType = 'enter' | 'enter-confirm'
 

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

@@ -2,4 +2,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: 'control_focused'; at: number; controlId: string | null }
   | { type: 'session_ended'; at: number }

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

@@ -3,7 +3,10 @@ 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 { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from '../presentation/hudPresentationState'
+import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from '../presentation/mapPresentationState'
 import { ClassicSequentialRule } from '../rules/classicSequentialRule'
+import { ScoreORule } from '../rules/scoreORule'
 import { type RulePlugin } from '../rules/rulePlugin'
 
 export class GameRuntime {
@@ -11,6 +14,8 @@ export class GameRuntime {
   plugin: RulePlugin | null
   state: GameSessionState | null
   presentation: GamePresentationState
+  mapPresentation: MapPresentationState
+  hudPresentation: HudPresentationState
   lastResult: GameResult | null
 
   constructor() {
@@ -18,6 +23,8 @@ export class GameRuntime {
     this.plugin = null
     this.state = null
     this.presentation = EMPTY_GAME_PRESENTATION_STATE
+    this.mapPresentation = EMPTY_MAP_PRESENTATION_STATE
+    this.hudPresentation = EMPTY_HUD_PRESENTATION_STATE
     this.lastResult = null
   }
 
@@ -26,6 +33,8 @@ export class GameRuntime {
     this.plugin = null
     this.state = null
     this.presentation = EMPTY_GAME_PRESENTATION_STATE
+    this.mapPresentation = EMPTY_MAP_PRESENTATION_STATE
+    this.hudPresentation = EMPTY_HUD_PRESENTATION_STATE
     this.lastResult = null
   }
 
@@ -39,6 +48,8 @@ export class GameRuntime {
       effects: [],
     }
     this.presentation = result.presentation
+    this.mapPresentation = result.presentation.map
+    this.hudPresentation = result.presentation.hud
     this.lastResult = result
     return result
   }
@@ -58,6 +69,7 @@ export class GameRuntime {
         inRangeControlId: null,
         score: 0,
         guidanceState: 'searching',
+        modeState: null,
       }
       const result: GameResult = {
         nextState: emptyState,
@@ -66,12 +78,16 @@ export class GameRuntime {
       }
       this.lastResult = result
       this.presentation = result.presentation
+      this.mapPresentation = result.presentation.map
+      this.hudPresentation = result.presentation.hud
       return result
     }
 
     const result = this.plugin.reduce(this.definition, this.state, event)
     this.state = result.nextState
     this.presentation = result.presentation
+    this.mapPresentation = result.presentation.map
+    this.hudPresentation = result.presentation.hud
     this.lastResult = result
     return result
   }
@@ -80,11 +96,23 @@ export class GameRuntime {
     return this.presentation
   }
 
+  getMapPresentation(): MapPresentationState {
+    return this.mapPresentation
+  }
+
+  getHudPresentation(): HudPresentationState {
+    return this.hudPresentation
+  }
+
   resolvePlugin(definition: GameDefinition): RulePlugin {
     if (definition.mode === 'classic-sequential') {
       return new ClassicSequentialRule()
     }
 
+    if (definition.mode === 'score-o') {
+      return new ScoreORule()
+    }
+
     throw new Error(`未支持的玩法模式: ${definition.mode}`)
   }
 }

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

@@ -1,5 +1,6 @@
 export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
 export type GuidanceState = 'searching' | 'approaching' | 'ready'
+export type GameModeState = Record<string, unknown> | null
 
 export interface GameSessionState {
   status: GameSessionStatus
@@ -10,4 +11,5 @@ export interface GameSessionState {
   inRangeControlId: string | null
   score: number
   guidanceState: GuidanceState
+  modeState: GameModeState
 }

+ 21 - 0
miniprogram/game/presentation/hudPresentationState.ts

@@ -0,0 +1,21 @@
+export interface HudPresentationState {
+  actionTagText: string
+  distanceTagText: string
+  hudTargetControlId: string | null
+  progressText: string
+  punchableControlId: string | null
+  punchButtonEnabled: boolean
+  punchButtonText: string
+  punchHintText: string
+}
+
+export const EMPTY_HUD_PRESENTATION_STATE: HudPresentationState = {
+  actionTagText: '目标',
+  distanceTagText: '点距',
+  hudTargetControlId: null,
+  progressText: '0/0',
+  punchableControlId: null,
+  punchButtonEnabled: false,
+  punchButtonText: '打点',
+  punchHintText: '等待进入检查点范围',
+}

+ 41 - 0
miniprogram/game/presentation/mapPresentationState.ts

@@ -0,0 +1,41 @@
+export interface MapPresentationState {
+  controlVisualMode: 'single-target' | 'multi-target'
+  showCourseLegs: boolean
+  guidanceLegAnimationEnabled: boolean
+  focusableControlIds: string[]
+  focusedControlId: string | null
+  focusedControlSequences: number[]
+  activeControlIds: string[]
+  activeControlSequences: number[]
+  activeStart: boolean
+  completedStart: boolean
+  activeFinish: boolean
+  focusedFinish: boolean
+  completedFinish: boolean
+  revealFullCourse: boolean
+  activeLegIndices: number[]
+  completedLegIndices: number[]
+  completedControlIds: string[]
+  completedControlSequences: number[]
+}
+
+export const EMPTY_MAP_PRESENTATION_STATE: MapPresentationState = {
+  controlVisualMode: 'single-target',
+  showCourseLegs: true,
+  guidanceLegAnimationEnabled: true,
+  focusableControlIds: [],
+  focusedControlId: null,
+  focusedControlSequences: [],
+  activeControlIds: [],
+  activeControlSequences: [],
+  activeStart: false,
+  completedStart: false,
+  activeFinish: false,
+  focusedFinish: false,
+  completedFinish: false,
+  revealFullCourse: false,
+  activeLegIndices: [],
+  completedLegIndices: [],
+  completedControlIds: [],
+  completedControlSequences: [],
+}

+ 7 - 32
miniprogram/game/presentation/presentationState.ts

@@ -1,39 +1,14 @@
+import { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from './hudPresentationState'
+import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from './mapPresentationState'
+
 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
+  map: MapPresentationState
+  hud: HudPresentationState
 }
 
 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: '等待进入检查点范围',
+  map: EMPTY_MAP_PRESENTATION_STATE,
+  hud: EMPTY_HUD_PRESENTATION_STATE,
 }
 
 

+ 83 - 19
miniprogram/game/rules/classicSequentialRule.ts

@@ -5,8 +5,15 @@ 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 HudPresentationState } from '../presentation/hudPresentationState'
+import { type MapPresentationState } from '../presentation/mapPresentationState'
 import { type RulePlugin } from './rulePlugin'
 
+type ClassicSequentialModeState = {
+  mode: 'classic-sequential'
+  phase: 'start' | 'course' | 'finish' | 'done'
+}
+
 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)
@@ -132,43 +139,84 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
         : '打点'
     : '打点'
   const revealFullCourse = completedStart
+  const hudPresentation: HudPresentationState = {
+    actionTagText: '目标',
+    distanceTagText: '点距',
+    hudTargetControlId: currentTarget ? currentTarget.id : null,
+    progressText: '0/0',
+    punchButtonText,
+    punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
+    punchButtonEnabled,
+    punchHintText: buildPunchHintText(definition, state, currentTarget),
+  }
 
   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),
+      map: {
+        ...EMPTY_GAME_PRESENTATION_STATE.map,
+        controlVisualMode: 'single-target',
+        showCourseLegs: true,
+        guidanceLegAnimationEnabled: true,
+        focusableControlIds: [],
+        focusedControlId: null,
+        focusedControlSequences: [],
+        activeStart,
+        completedStart,
+        activeFinish,
+        focusedFinish: false,
+        completedFinish,
+        revealFullCourse,
+        activeLegIndices,
+        completedLegIndices,
+      },
+      hud: hudPresentation,
     }
   }
 
-  return {
+  const mapPresentation: MapPresentationState = {
+    controlVisualMode: 'single-target',
+    showCourseLegs: true,
+    guidanceLegAnimationEnabled: true,
+    focusableControlIds: [],
+    focusedControlId: null,
+    focusedControlSequences: [],
     activeControlIds: running && currentTarget ? [currentTarget.id] : [],
     activeControlSequences: running && currentTarget && currentTarget.kind === 'control' && typeof currentTarget.sequence === 'number' ? [currentTarget.sequence] : [],
     activeStart,
     completedStart,
     activeFinish,
+    focusedFinish: false,
     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),
   }
+
+  return {
+    map: mapPresentation,
+    hud: {
+      ...hudPresentation,
+      progressText: `${completedControls.length}/${scoringControls.length}`,
+    },
+  }
+}
+
+function resolveClassicPhase(nextTarget: GameControl | null, currentTarget: GameControl, finished: boolean): ClassicSequentialModeState['phase'] {
+  if (finished || currentTarget.kind === 'finish') {
+    return 'done'
+  }
+
+  if (currentTarget.kind === 'start') {
+    return nextTarget && nextTarget.kind === 'finish' ? 'finish' : 'course'
+  }
+
+  if (nextTarget && nextTarget.kind === 'finish') {
+    return 'finish'
+  }
+
+  return 'course'
 }
 
 function getInitialTargetId(definition: GameDefinition): string | null {
@@ -237,6 +285,10 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
     status: finished ? 'finished' : state.status,
     endedAt: finished ? at : state.endedAt,
     guidanceState: nextTarget ? 'searching' : 'searching',
+    modeState: {
+      mode: 'classic-sequential',
+      phase: resolveClassicPhase(nextTarget, currentTarget, finished),
+    },
   }
   const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
 
@@ -266,6 +318,10 @@ export class ClassicSequentialRule implements RulePlugin {
       inRangeControlId: null,
       score: 0,
       guidanceState: 'searching',
+      modeState: {
+        mode: 'classic-sequential',
+        phase: 'start',
+      },
     }
   }
 
@@ -282,6 +338,10 @@ export class ClassicSequentialRule implements RulePlugin {
         endedAt: null,
         inRangeControlId: null,
         guidanceState: 'searching',
+        modeState: {
+          mode: 'classic-sequential',
+          phase: 'start',
+        },
       }
       return {
         nextState,
@@ -296,6 +356,10 @@ export class ClassicSequentialRule implements RulePlugin {
         status: 'finished',
         endedAt: event.at,
         guidanceState: 'searching',
+        modeState: {
+          mode: 'classic-sequential',
+          phase: 'done',
+        },
       }
       return {
         nextState,

+ 609 - 0
miniprogram/game/rules/scoreORule.ts

@@ -0,0 +1,609 @@
+import { type LonLatPoint } from '../../utils/projection'
+import { DEFAULT_GAME_AUDIO_CONFIG } from '../audio/audioConfig'
+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 { type GamePresentationState } from '../presentation/presentationState'
+import { type HudPresentationState } from '../presentation/hudPresentationState'
+import { type MapPresentationState } from '../presentation/mapPresentationState'
+import { type RulePlugin } from './rulePlugin'
+
+type ScoreOModeState = {
+  phase: 'start' | 'controls' | 'finish' | 'done'
+  focusedControlId: string | null
+}
+
+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 getStartControl(definition: GameDefinition): GameControl | null {
+  return definition.controls.find((control) => control.kind === 'start') || null
+}
+
+function getFinishControl(definition: GameDefinition): GameControl | null {
+  return definition.controls.find((control) => control.kind === 'finish') || null
+}
+
+function getScoreControls(definition: GameDefinition): GameControl[] {
+  return definition.controls.filter((control) => control.kind === 'control')
+}
+
+function getRemainingScoreControls(definition: GameDefinition, state: GameSessionState): GameControl[] {
+  return getScoreControls(definition).filter((control) => !state.completedControlIds.includes(control.id))
+}
+
+function getModeState(state: GameSessionState): ScoreOModeState {
+  const rawModeState = state.modeState as Partial<ScoreOModeState> | null
+  return {
+    phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start',
+    focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null,
+  }
+}
+
+function withModeState(state: GameSessionState, modeState: ScoreOModeState): GameSessionState {
+  return {
+    ...state,
+    modeState,
+  }
+}
+
+function canFocusFinish(definition: GameDefinition, state: GameSessionState): boolean {
+  const startControl = getStartControl(definition)
+  const finishControl = getFinishControl(definition)
+  const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
+  const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
+  return completedStart && !completedFinish
+}
+
+function getNearestRemainingControl(
+  definition: GameDefinition,
+  state: GameSessionState,
+  referencePoint?: LonLatPoint | null,
+): GameControl | null {
+  const remainingControls = getRemainingScoreControls(definition, state)
+  if (!remainingControls.length) {
+    return getFinishControl(definition)
+  }
+
+  if (!referencePoint) {
+    return remainingControls[0]
+  }
+
+  let nearestControl = remainingControls[0]
+  let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point)
+  for (let index = 1; index < remainingControls.length; index += 1) {
+    const control = remainingControls[index]
+    const distance = getApproxDistanceMeters(referencePoint, control.point)
+    if (distance < nearestDistance) {
+      nearestControl = control
+      nearestDistance = distance
+    }
+  }
+  return nearestControl
+}
+
+function getFocusedTarget(
+  definition: GameDefinition,
+  state: GameSessionState,
+  remainingControls?: GameControl[],
+): GameControl | null {
+  const modeState = getModeState(state)
+  if (!modeState.focusedControlId) {
+    return null
+  }
+
+  const controls = remainingControls || getRemainingScoreControls(definition, state)
+  for (const control of controls) {
+    if (control.id === modeState.focusedControlId) {
+      return control
+    }
+  }
+
+  const finishControl = getFinishControl(definition)
+  if (finishControl && canFocusFinish(definition, state) && finishControl.id === modeState.focusedControlId) {
+    return finishControl
+  }
+
+  return null
+}
+
+function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
+  if (distanceMeters <= definition.punchRadiusMeters) {
+    return 'ready'
+  }
+
+  const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
+  if (distanceMeters <= approachDistanceMeters) {
+    return 'approaching'
+  }
+
+  return 'searching'
+}
+
+function getGuidanceEffects(
+  previousState: GameSessionState['guidanceState'],
+  nextState: GameSessionState['guidanceState'],
+  controlId: string | null,
+): GameEffect[] {
+  if (previousState === nextState) {
+    return []
+  }
+
+  return [{ type: 'guidance_state_changed', guidanceState: nextState, controlId }]
+}
+
+function getDisplayTargetLabel(control: GameControl | null): string {
+  if (!control) {
+    return '目标点'
+  }
+  if (control.kind === 'start') {
+    return '开始点'
+  }
+  if (control.kind === 'finish') {
+    return '终点'
+  }
+  return '目标点'
+}
+
+function buildPunchHintText(
+  definition: GameDefinition,
+  state: GameSessionState,
+  primaryTarget: GameControl | null,
+  focusedTarget: GameControl | null,
+): string {
+  if (state.status === 'idle') {
+    return '点击开始后先打开始点'
+  }
+
+  if (state.status === 'finished') {
+    return '本局已完成'
+  }
+
+  const modeState = getModeState(state)
+  if (modeState.phase === 'controls' || modeState.phase === 'finish') {
+    if (!focusedTarget) {
+      return modeState.phase === 'finish'
+        ? '点击地图选中终点后结束比赛'
+        : '点击地图选中一个目标点'
+    }
+
+    const targetLabel = getDisplayTargetLabel(focusedTarget)
+    if (state.inRangeControlId === focusedTarget.id) {
+      return definition.punchPolicy === 'enter'
+        ? `${targetLabel}内,自动打点中`
+        : `${targetLabel}内,可点击打点`
+    }
+
+    return definition.punchPolicy === 'enter'
+      ? `进入${targetLabel}自动打点`
+      : `进入${targetLabel}后点击打点`
+  }
+
+  const targetLabel = getDisplayTargetLabel(primaryTarget)
+  if (state.inRangeControlId && primaryTarget && state.inRangeControlId === primaryTarget.id) {
+    return definition.punchPolicy === 'enter'
+      ? `${targetLabel}内,自动打点中`
+      : `${targetLabel}内,可点击打点`
+  }
+
+  return definition.punchPolicy === 'enter'
+    ? `进入${targetLabel}自动打点`
+    : `进入${targetLabel}后点击打点`
+}
+
+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: '已完成开始点打卡,开始自由打点。',
+    }
+  }
+
+  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
+  return {
+    type: 'control_completed',
+    controlId: control.id,
+    controlKind: 'control',
+    sequence: control.sequence,
+    label: control.label,
+    displayTitle: control.displayContent ? control.displayContent.title : `收集 ${sequenceText}`,
+    displayBody: control.displayContent ? control.displayContent.body : control.label,
+  }
+}
+
+function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
+  const modeState = getModeState(state)
+  const running = state.status === 'running'
+  const startControl = getStartControl(definition)
+  const finishControl = getFinishControl(definition)
+  const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
+  const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
+  const remainingControls = getRemainingScoreControls(definition, state)
+  const scoreControls = getScoreControls(definition)
+  const primaryTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null
+  const focusedTarget = getFocusedTarget(definition, state, remainingControls)
+  const canSelectFinish = running && completedStart && !completedFinish && !!finishControl
+  const activeControlIds = running && modeState.phase === 'controls'
+    ? remainingControls.map((control) => control.id)
+    : []
+  const activeControlSequences = running && modeState.phase === 'controls'
+    ? remainingControls
+      .filter((control) => typeof control.sequence === 'number')
+      .map((control) => control.sequence as number)
+    : []
+  const completedControls = scoreControls.filter((control) => state.completedControlIds.includes(control.id))
+  const completedControlSequences = completedControls
+    .filter((control) => typeof control.sequence === 'number')
+    .map((control) => control.sequence as number)
+  const revealFullCourse = completedStart
+  const interactiveTarget = modeState.phase === 'start' ? primaryTarget : focusedTarget
+  const punchButtonEnabled = running
+    && definition.punchPolicy === 'enter-confirm'
+    && !!interactiveTarget
+    && state.inRangeControlId === interactiveTarget.id
+
+  const mapPresentation: MapPresentationState = {
+    controlVisualMode: modeState.phase === 'controls' ? 'multi-target' : 'single-target',
+    showCourseLegs: false,
+    guidanceLegAnimationEnabled: false,
+    focusableControlIds: canSelectFinish
+      ? [...activeControlIds, finishControl!.id]
+      : activeControlIds.slice(),
+    focusedControlId: focusedTarget ? focusedTarget.id : null,
+    focusedControlSequences: focusedTarget && focusedTarget.kind === 'control' && typeof focusedTarget.sequence === 'number'
+      ? [focusedTarget.sequence]
+      : [],
+    activeControlIds,
+    activeControlSequences,
+    activeStart: running && modeState.phase === 'start',
+    completedStart,
+    activeFinish: running && modeState.phase === 'finish',
+    focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish',
+    completedFinish,
+    revealFullCourse,
+    activeLegIndices: [],
+    completedLegIndices: [],
+    completedControlIds: completedControls.map((control) => control.id),
+    completedControlSequences,
+  }
+
+  const hudPresentation: HudPresentationState = {
+    actionTagText: modeState.phase === 'start'
+      ? '目标'
+      : focusedTarget && focusedTarget.kind === 'finish'
+        ? '终点'
+        : modeState.phase === 'finish'
+          ? '终点'
+          : '自由',
+    distanceTagText: modeState.phase === 'start'
+      ? '点距'
+      : focusedTarget && focusedTarget.kind === 'finish'
+        ? '终点距'
+        : focusedTarget
+          ? '选中点距'
+          : modeState.phase === 'finish'
+            ? '终点距'
+            : '最近点距',
+    hudTargetControlId: focusedTarget
+      ? focusedTarget.id
+      : primaryTarget
+        ? primaryTarget.id
+        : null,
+    progressText: `已收集 ${completedControls.length}/${scoreControls.length}`,
+    punchableControlId: punchButtonEnabled && interactiveTarget ? interactiveTarget.id : null,
+    punchButtonEnabled,
+    punchButtonText: modeState.phase === 'start'
+      ? '开始打卡'
+      : focusedTarget && focusedTarget.kind === 'finish'
+        ? '结束打卡'
+        : modeState.phase === 'finish'
+          ? '结束打卡'
+          : '打点',
+    punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget),
+  }
+
+  return {
+    map: mapPresentation,
+    hud: hudPresentation,
+  }
+}
+
+function applyCompletion(
+  definition: GameDefinition,
+  state: GameSessionState,
+  control: GameControl,
+  at: number,
+  referencePoint: LonLatPoint | null,
+): GameResult {
+  const completedControlIds = state.completedControlIds.includes(control.id)
+    ? state.completedControlIds
+    : [...state.completedControlIds, control.id]
+  const previousModeState = getModeState(state)
+  const nextStateDraft: GameSessionState = {
+    ...state,
+    startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt,
+    endedAt: control.kind === 'finish' ? at : state.endedAt,
+    completedControlIds,
+    currentTargetControlId: null,
+    inRangeControlId: null,
+    score: getScoreControls(definition).filter((item) => completedControlIds.includes(item.id)).length,
+    status: control.kind === 'finish' ? 'finished' : state.status,
+    guidanceState: 'searching',
+  }
+
+  const remainingControls = getRemainingScoreControls(definition, nextStateDraft)
+  let phase: ScoreOModeState['phase']
+  if (control.kind === 'finish') {
+    phase = 'done'
+  } else if (control.kind === 'start') {
+    phase = remainingControls.length ? 'controls' : 'finish'
+  } else {
+    phase = remainingControls.length ? 'controls' : 'finish'
+  }
+
+  const nextModeState: ScoreOModeState = {
+    phase,
+    focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId,
+  }
+  const nextPrimaryTarget = phase === 'controls'
+    ? getNearestRemainingControl(definition, nextStateDraft, referencePoint)
+    : phase === 'finish'
+      ? getFinishControl(definition)
+      : null
+  const nextState = withModeState({
+    ...nextStateDraft,
+    currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
+  }, nextModeState)
+
+  const effects: GameEffect[] = [buildCompletedEffect(control)]
+  if (control.kind === 'finish') {
+    effects.push({ type: 'session_finished' })
+  }
+
+  return {
+    nextState,
+    presentation: buildPresentation(definition, nextState),
+    effects,
+  }
+}
+
+export class ScoreORule implements RulePlugin {
+  get mode(): 'score-o' {
+    return 'score-o'
+  }
+
+  initialize(definition: GameDefinition): GameSessionState {
+    const startControl = getStartControl(definition)
+    return {
+      status: 'idle',
+      startedAt: null,
+      endedAt: null,
+      completedControlIds: [],
+      currentTargetControlId: startControl ? startControl.id : null,
+      inRangeControlId: null,
+      score: 0,
+      guidanceState: 'searching',
+      modeState: {
+        phase: 'start',
+        focusedControlId: null,
+      },
+    }
+  }
+
+  buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
+    return buildPresentation(definition, state)
+  }
+
+  reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult {
+    if (event.type === 'session_started') {
+      const startControl = getStartControl(definition)
+      const nextState = withModeState({
+        ...state,
+        status: 'running',
+        startedAt: null,
+        endedAt: null,
+        currentTargetControlId: startControl ? startControl.id : null,
+        inRangeControlId: null,
+        guidanceState: 'searching',
+      }, {
+        phase: 'start',
+        focusedControlId: null,
+      })
+      return {
+        nextState,
+        presentation: buildPresentation(definition, nextState),
+        effects: [{ type: 'session_started' }],
+      }
+    }
+
+    if (event.type === 'session_ended') {
+      const nextState = withModeState({
+        ...state,
+        status: 'finished',
+        endedAt: event.at,
+        guidanceState: 'searching',
+      }, {
+        phase: 'done',
+        focusedControlId: null,
+      })
+      return {
+        nextState,
+        presentation: buildPresentation(definition, nextState),
+        effects: [{ type: 'session_finished' }],
+      }
+    }
+
+    if (state.status !== 'running') {
+      return {
+        nextState: state,
+        presentation: buildPresentation(definition, state),
+        effects: [],
+      }
+    }
+
+    const modeState = getModeState(state)
+    const targetControl = state.currentTargetControlId
+      ? definition.controls.find((control) => control.id === state.currentTargetControlId) || null
+      : null
+
+    if (event.type === 'gps_updated') {
+      const referencePoint = { lon: event.lon, lat: event.lat }
+      const remainingControls = getRemainingScoreControls(definition, state)
+      const focusedTarget = getFocusedTarget(definition, state, remainingControls)
+      let nextPrimaryTarget = targetControl
+      let guidanceTarget = targetControl
+      let punchTarget: GameControl | null = null
+
+      if (modeState.phase === 'controls') {
+        nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint)
+        guidanceTarget = focusedTarget || nextPrimaryTarget
+        if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
+          punchTarget = focusedTarget
+        }
+      } else if (modeState.phase === 'finish') {
+        nextPrimaryTarget = getFinishControl(definition)
+        guidanceTarget = focusedTarget || nextPrimaryTarget
+        if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
+          punchTarget = focusedTarget
+        }
+      } else if (targetControl) {
+        guidanceTarget = targetControl
+        if (getApproxDistanceMeters(targetControl.point, referencePoint) <= definition.punchRadiusMeters) {
+          punchTarget = targetControl
+        }
+      }
+
+      const guidanceState = guidanceTarget
+        ? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint))
+        : 'searching'
+      const nextState: GameSessionState = {
+        ...state,
+        currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
+        inRangeControlId: punchTarget ? punchTarget.id : null,
+        guidanceState,
+      }
+      const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null)
+
+      if (definition.punchPolicy === 'enter' && punchTarget) {
+        const completionResult = applyCompletion(definition, nextState, punchTarget, event.at, referencePoint)
+        return {
+          ...completionResult,
+          effects: [...guidanceEffects, ...completionResult.effects],
+        }
+      }
+
+      return {
+        nextState,
+        presentation: buildPresentation(definition, nextState),
+        effects: guidanceEffects,
+      }
+    }
+
+    if (event.type === 'control_focused') {
+      if (modeState.phase !== 'controls' && modeState.phase !== 'finish') {
+        return {
+          nextState: state,
+          presentation: buildPresentation(definition, state),
+          effects: [],
+        }
+      }
+
+      const focusableControlIds = getRemainingScoreControls(definition, state).map((control) => control.id)
+      const finishControl = getFinishControl(definition)
+      if (finishControl && canFocusFinish(definition, state)) {
+        focusableControlIds.push(finishControl.id)
+      }
+
+      const nextFocusedControlId = event.controlId && focusableControlIds.includes(event.controlId)
+        ? modeState.focusedControlId === event.controlId
+          ? null
+          : event.controlId
+        : null
+      const nextState = withModeState({
+        ...state,
+      }, {
+        ...modeState,
+        focusedControlId: nextFocusedControlId,
+      })
+      return {
+        nextState,
+        presentation: buildPresentation(definition, nextState),
+        effects: [],
+      }
+    }
+
+    if (event.type === 'punch_requested') {
+      const focusedTarget = getFocusedTarget(definition, state)
+      if ((modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
+        return {
+          nextState: state,
+          presentation: buildPresentation(definition, state),
+          effects: [{ type: 'punch_feedback', text: modeState.phase === 'finish' ? '请先选中终点' : '请先选中目标点', tone: 'warning' }],
+        }
+      }
+
+      let controlToPunch: GameControl | null = null
+      if (state.inRangeControlId) {
+        controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
+      }
+
+      if (!controlToPunch || (focusedTarget && controlToPunch.id !== focusedTarget.id)) {
+        return {
+          nextState: state,
+          presentation: buildPresentation(definition, state),
+          effects: [{
+            type: 'punch_feedback',
+            text: focusedTarget
+              ? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围`
+              : modeState.phase === 'start'
+                ? '未进入开始点打卡范围'
+                : '未进入目标打点范围',
+            tone: 'warning',
+          }],
+        }
+      }
+
+      return applyCompletion(definition, state, controlToPunch, event.at, this.getReferencePoint(definition, state, controlToPunch))
+    }
+
+    return {
+      nextState: state,
+      presentation: buildPresentation(definition, state),
+      effects: [],
+    }
+  }
+
+  private getReferencePoint(definition: GameDefinition, state: GameSessionState, completedControl: GameControl): LonLatPoint | null {
+    if (completedControl.kind === 'control') {
+      const remaining = getRemainingScoreControls(definition, {
+        ...state,
+        completedControlIds: [...state.completedControlIds, completedControl.id],
+      })
+      return remaining.length ? completedControl.point : (getFinishControl(definition) ? getFinishControl(definition)!.point : completedControl.point)
+    }
+
+    return completedControl.point
+  }
+}

+ 4 - 3
miniprogram/game/telemetry/telemetryRuntime.ts

@@ -268,14 +268,15 @@ export class TelemetryRuntime {
     this.reset()
   }
 
-  syncGameState(definition: GameDefinition | null, state: GameSessionState | null): void {
+  syncGameState(definition: GameDefinition | null, state: GameSessionState | null, hudTargetControlId?: string | null): void {
     if (!definition || !state) {
       this.dispatch({ type: 'reset' })
       return
     }
 
-    const targetControl = state.currentTargetControlId
-      ? definition.controls.find((control) => control.id === state.currentTargetControlId) || null
+    const targetControlId = hudTargetControlId !== undefined ? hudTargetControlId : state.currentTargetControlId
+    const targetControl = targetControlId
+      ? definition.controls.find((control) => control.id === targetControlId) || null
       : null
 
     this.dispatch({

+ 19 - 1
miniprogram/pages/map/map.ts

@@ -30,7 +30,7 @@ type MapPageData = MapEngineViewState & {
   showRightButtonGroups: boolean
   showBottomDebugButton: boolean
 }
-const INTERNAL_BUILD_VERSION = 'map-build-157'
+const INTERNAL_BUILD_VERSION = 'map-build-166'
 const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
 let mapEngine: MapEngine | null = null
 function buildSideButtonVisibility(mode: SideButtonMode) {
@@ -98,10 +98,13 @@ Page({
     hudPanelIndex: 0,
     panelTimerText: '00:00:00',
     panelMileageText: '0m',
+    panelActionTagText: '目标',
+    panelDistanceTagText: '点距',
     panelDistanceValueText: '--',
     panelDistanceUnitText: '',
     panelProgressText: '0/0',
     gameSessionStatus: 'idle',
+    gameModeText: '顺序赛',
     panelSpeedValueText: '0',
     panelTelemetryTone: 'blue',
     panelHeartRateZoneNameText: '--',
@@ -160,10 +163,13 @@ Page({
       hudPanelIndex: 0,
       panelTimerText: '00:00:00',
       panelMileageText: '0m',
+      panelActionTagText: '目标',
+      panelDistanceTagText: '点距',
       panelDistanceValueText: '--',
       panelDistanceUnitText: '',
       panelProgressText: '0/0',
       gameSessionStatus: 'idle',
+      gameModeText: '顺序赛',
       panelSpeedValueText: '0',
       panelTelemetryTone: 'blue',
       panelHeartRateZoneNameText: '--',
@@ -442,6 +448,18 @@ Page({
     }
   },
 
+  handleSetClassicMode() {
+    if (mapEngine) {
+      mapEngine.handleSetGameMode('classic-sequential')
+    }
+  },
+
+  handleSetScoreOMode() {
+    if (mapEngine) {
+      mapEngine.handleSetGameMode('score-o')
+    }
+  },
+
   handleOverlayTouch() {},
 
   handlePunchAction() {

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

@@ -121,9 +121,9 @@
   <swiper class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
     <swiper-item>
       <view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
-        <view class="race-panel__tag race-panel__tag--top-left">目标</view>
+        <view class="race-panel__tag race-panel__tag--top-left">{{panelActionTagText}}</view>
         <view class="race-panel__tag race-panel__tag--top-right">里程</view>
-        <view class="race-panel__tag race-panel__tag--bottom-left">点距</view>
+        <view class="race-panel__tag race-panel__tag--bottom-left">{{panelDistanceTagText}}</view>
         <view class="race-panel__tag race-panel__tag--bottom-right">速度</view>
 
         <view class="race-panel__line race-panel__line--center"></view>
@@ -246,6 +246,10 @@
             <view class="debug-section__title">Session</view>
             <view class="debug-section__desc">当前局状态与主流程控制</view>
           </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Mode</text>
+            <text class="info-panel__value">{{gameModeText}}</text>
+          </view>
           <view class="info-panel__row">
             <text class="info-panel__label">Game</text>
             <text class="info-panel__value">{{gameSessionStatus}}</text>
@@ -262,6 +266,10 @@
             <text class="info-panel__label">Punch Hint</text>
             <text class="info-panel__value">{{punchHintText}}</text>
           </view>
+          <view class="control-row">
+            <view class="control-chip {{gameModeText === '顺序赛' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetClassicMode">顺序赛</view>
+            <view class="control-chip {{gameModeText === '积分赛' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetScoreOMode">积分赛</view>
+          </view>
           <view class="control-row">
             <view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>
             <view class="control-chip control-chip--secondary" bindtap="handleRotationReset">旋转归零</view>

+ 21 - 10
miniprogram/utils/remoteMapConfig.ts

@@ -42,7 +42,7 @@ export interface RemoteMapConfig {
   course: OrienteeringCourseData | null
   courseStatusText: string
   cpRadiusMeters: number
-  gameMode: 'classic-sequential'
+  gameMode: 'classic-sequential' | 'score-o'
   punchPolicy: 'enter' | 'enter-confirm'
   punchRadiusMeters: number
   autoFinishOnLastControl: boolean
@@ -57,7 +57,7 @@ interface ParsedGameConfig {
   mapMeta: string
   course: string | null
   cpRadiusMeters: number
-  gameMode: 'classic-sequential'
+  gameMode: 'classic-sequential' | 'score-o'
   punchPolicy: 'enter' | 'enter-confirm'
   punchRadiusMeters: number
   autoFinishOnLastControl: boolean
@@ -209,6 +209,23 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
   return rawValue === 'enter' ? 'enter' : 'enter-confirm'
 }
 
+function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
+  if (typeof rawValue !== 'string') {
+    return 'classic-sequential'
+  }
+
+  const normalized = rawValue.trim().toLowerCase()
+  if (normalized === 'classic-sequential' || normalized === 'classic' || normalized === 'sequential') {
+    return 'classic-sequential'
+  }
+
+  if (normalized === 'score-o' || normalized === 'scoreo' || normalized === 'score') {
+    return 'score-o'
+  }
+
+  throw new Error(`暂不支持的 game.mode: ${rawValue}`)
+}
+
 function parseTelemetryConfig(rawValue: unknown): TelemetryConfig {
   const normalized = normalizeObjectRecord(rawValue)
   if (!Object.keys(normalized).length) {
@@ -679,11 +696,8 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
     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}`)
-  }
+  const gameMode = parseGameMode(modeValue)
 
   return {
     mapRoot,
@@ -738,10 +752,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
     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}`)
-  }
+  const gameMode = parseGameMode(config.gamemode)
 
   return {
     mapRoot,