فهرست منبع

完善文创展示控制与结果层基础

zhangyan 1 هفته پیش
والد
کامیت
0e025c3426

+ 328 - 0
content-experience-layer-proposal.md

@@ -0,0 +1,328 @@
+# 游戏中文创体验层方案
+
+## 1. 目标
+
+为游戏过程中的文创内容建立一层独立承载能力,不把内容弹窗、图文卡片、讲解信息散落在:
+
+- 规则层
+- 页面层
+- HUD 逻辑
+- 反馈层
+
+这层的目标是:
+
+- 在正确时机触发内容体验
+- 统一内容展示方式
+- 可配置、可复用、可扩展
+- 不破坏当前地图与规则主链
+
+一句话:
+
+**把“中途内容体验”从临时弹窗提升为正式能力层。**
+
+---
+
+## 2. 当前现状
+
+当前项目已经具备一部分基础:
+
+- `control.displayContent`
+- `UiEffectDirector.showContentCard(...)`
+- 页面层已有 `contentCardVisible / contentCardTitle / contentCardBody`
+- 打点完成后可展示内容卡
+
+这说明:
+
+- 内容展示能力已经有雏形
+- 但触发方式还偏单一
+- 内容形式也还比较轻
+- 还没有形成正式的“内容体验层”模型
+
+---
+
+## 3. 设计原则
+
+### 3.1 内容体验不等于短反馈
+
+短反馈仍然属于:
+
+- 音效
+- 震动
+- HUD 提示
+- 地图 pulse
+
+文创体验属于更重的一层,应与 `FeedbackDirector` 区分。
+
+### 3.2 内容体验不直接写死在规则里
+
+规则层只负责:
+
+- 是否触发
+- 触发什么体验条目
+
+规则层不负责:
+
+- 页面怎么弹
+- 卡片长什么样
+- 是否带图片、音频、讲解按钮
+
+### 3.3 内容体验必须配置驱动
+
+以后不同活动、不同地图、不同玩法需要不同内容。
+
+所以这层必须可配置:
+
+- 哪个点触发
+- 何时触发
+- 弹什么
+- 是否只弹一次
+- 优先级如何
+
+---
+
+## 4. 建议的新层级
+
+建议增加一层:
+
+- `ContentExperienceLayer`
+
+放在概念上与这些层并列:
+
+- `MapPresentation`
+- `HUD`
+- `Feedback`
+- `ResultScene`
+
+职责:
+
+- 接收体验触发
+- 管理当前激活内容项
+- 控制展示与关闭
+- 向页面层输出当前体验模型
+
+---
+
+## 5. 建议的数据模型
+
+### 5.1 ExperienceEntry
+
+```ts
+type ExperienceTrigger =
+  | 'control_completed'
+  | 'zone_entered'
+  | 'session_finished'
+  | 'manual'
+
+type ExperienceDisplayMode =
+  | 'content-card'
+  | 'full-panel'
+  | 'audio-guide'
+  | 'unlock-card'
+
+interface ExperienceEntry {
+  id: string
+  trigger: ExperienceTrigger
+  controlId?: string
+  zoneId?: string
+  title: string
+  body: string
+  imageRef?: string
+  audioRef?: string
+  displayMode: ExperienceDisplayMode
+  once: boolean
+  priority: number
+}
+```
+
+### 5.2 ExperienceRuntimeState
+
+```ts
+interface ExperienceRuntimeState {
+  activeEntryId: string | null
+  dismissedEntryIds: string[]
+  consumedEntryIds: string[]
+}
+```
+
+---
+
+## 6. 配置建议
+
+建议在配置中增加一段:
+
+```json
+{
+  "resources": {
+    "contentEntries": {
+      "cp-3-story": {
+        "title": "校史地标",
+        "body": "这里是校园历史演变的重要节点。",
+        "imageRef": "content/campus-history-01.png",
+        "displayMode": "content-card"
+      }
+    }
+  },
+  "game": {
+    "experience": {
+      "entries": [
+        {
+          "id": "cp-3-story",
+          "trigger": "control_completed",
+          "controlId": "control-3",
+          "once": true,
+          "priority": 10
+        }
+      ]
+    }
+  }
+}
+```
+
+这意味着:
+
+- 资源层管理内容资源
+- 玩法配置决定何时触发
+
+---
+
+## 7. 触发来源
+
+第一阶段建议支持 3 种触发:
+
+### 7.1 打点完成触发
+
+最适合当前项目,价值最高。
+
+例如:
+
+- 完成某个控制点后弹一张文创卡
+- 开始点完成后弹赛事导览卡
+- 终点完成后弹纪念卡
+
+### 7.2 区域进入触发
+
+适合后续:
+
+- 地标介绍
+- 迷雾探索
+- 特定区域故事点
+
+### 7.3 结算后解锁触发
+
+适合后续与结算页联动:
+
+- 收藏卡
+- 奖章
+- 文创奖励
+
+---
+
+## 8. 页面表现建议
+
+第一阶段先做最小闭环,不追求复杂视觉。
+
+### 8.1 第一阶段
+
+支持:
+
+- 当前已有的 `content-card`
+- 标题
+- 正文
+- 关闭
+
+### 8.2 第二阶段
+
+再支持:
+
+- 图片
+- 按钮
+- 章节式展开
+- 音频讲解
+
+---
+
+## 9. 与当前架构的关系
+
+### 规则层
+
+负责:
+
+- 触发某条体验事件
+
+不负责:
+
+- 具体展示细节
+
+### Feedback
+
+继续负责:
+
+- 短反馈
+- 动效
+- 音效
+
+### ContentExperienceLayer
+
+负责:
+
+- 中等时长的信息体验
+
+### 页面层
+
+负责:
+
+- 渲染当前体验模型
+
+---
+
+## 10. 第一阶段最小实施范围
+
+建议第一阶段只做:
+
+1. `control_completed -> experience entry`
+2. `content-card` 展示
+3. `once` 语义
+4. 手动关闭
+5. 配置驱动
+
+不要一上来做:
+
+- 图片轮播
+- 视频
+- 复杂音频控制
+- 多层交互
+
+---
+
+## 11. 推荐实施顺序
+
+1. 定义 `ExperienceEntry`
+2. 在配置解析层接 `game.experience.entries`
+3. 在规则完成事件里派发体验触发
+4. MapEngine 增加体验状态承载
+5. 页面层继续复用当前 `content-card`
+6. 再逐步升级 UI
+
+---
+
+## 12. 长期价值
+
+这层建好后,后续可以自然承接:
+
+- 文创卡片
+- 地标解说
+- 解锁收藏
+- 故事节点
+- 活动内品牌内容
+
+它不只服务当前顺序赛/积分赛,而是服务整条产品体验链。
+
+---
+
+## 13. 结论
+
+当前最正确的方向不是继续在页面里零散补内容弹窗,而是:
+
+**把游戏中途的文创与故事体验正式抽成一层独立的 `ContentExperienceLayer`。**
+
+第一阶段先用“控制点完成触发内容卡”跑通最小闭环,后面再逐步扩成完整体验系统。

+ 65 - 6
miniprogram/engine/map/mapEngine.ts

@@ -13,10 +13,12 @@ import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
 import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
 import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel'
 import { GameRuntime } from '../../game/core/gameRuntime'
+import { type GameControlDisplayContentOverride } from '../../game/core/gameDefinition'
 import { type GameEffect, type GameResult } from '../../game/core/gameResult'
 import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
 import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
 import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
+import { buildResultSummarySnapshot, type ResultSummarySnapshot } from '../../game/result/resultSummary'
 import { TelemetryRuntime } from '../../game/telemetry/telemetryRuntime'
 import { getHeartRateToneSampleBpm, type HeartRateTone } from '../../game/telemetry/telemetryConfig'
 
@@ -257,6 +259,8 @@ export interface MapEngineGameInfoSnapshot {
   globalRows: MapEngineGameInfoRow[]
 }
 
+export type MapEngineResultSnapshot = ResultSummarySnapshot
+
 const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'animationLevel',
   'buildVersion',
@@ -868,6 +872,7 @@ export class MapEngine {
   configSchemaVersion: string
   configVersion: string
   controlScoreOverrides: Record<string, number>
+  controlContentOverrides: Record<string, GameControlDisplayContentOverride>
   defaultControlScore: number | null
   gameRuntime: GameRuntime
   telemetryRuntime: TelemetryRuntime
@@ -882,6 +887,8 @@ export class MapEngine {
   autoFinishOnLastControl: boolean
   punchFeedbackTimer: number
   contentCardTimer: number
+  currentContentCardPriority: number
+  shownContentCardKeys: Record<string, true>
   mapPulseTimer: number
   stageFxTimer: number
   sessionTimerInterval: number
@@ -1076,8 +1083,8 @@ export class MapEngine {
       showPunchFeedback: (text, tone, motionClass) => {
         this.showPunchFeedback(text, tone, motionClass)
       },
-      showContentCard: (title, body, motionClass) => {
-        this.showContentCard(title, body, motionClass)
+      showContentCard: (title, body, motionClass, options) => {
+        this.showContentCard(title, body, motionClass, options)
       },
       setPunchButtonFxClass: (className) => {
         this.setPunchButtonFxClass(className)
@@ -1118,6 +1125,7 @@ export class MapEngine {
     this.configSchemaVersion = '1'
     this.configVersion = ''
     this.controlScoreOverrides = {}
+    this.controlContentOverrides = {}
     this.defaultControlScore = null
     this.gameRuntime = new GameRuntime()
     this.telemetryRuntime = new TelemetryRuntime()
@@ -1134,6 +1142,8 @@ export class MapEngine {
     this.gpsLockEnabled = false
     this.punchFeedbackTimer = 0
     this.contentCardTimer = 0
+    this.currentContentCardPriority = 0
+    this.shownContentCardKeys = {}
     this.mapPulseTimer = 0
     this.stageFxTimer = 0
     this.sessionTimerInterval = 0
@@ -1405,6 +1415,15 @@ export class MapEngine {
     }
   }
 
+  getResultSceneSnapshot(): MapEngineResultSnapshot {
+    return buildResultSummarySnapshot(
+      this.gameRuntime.definition,
+      this.gameRuntime.state,
+      this.telemetryRuntime.getPresentation(),
+      this.state.mapName || (this.gameRuntime.definition ? this.gameRuntime.definition.title : '本局结果'),
+    )
+  }
+
   destroy(): void {
     this.clearInertiaTimer()
     this.clearPreviewResetTimer()
@@ -1586,6 +1605,7 @@ export class MapEngine {
       this.skipRadiusMeters,
       this.skipRequiresConfirm,
       this.controlScoreOverrides,
+      this.controlContentOverrides,
       this.defaultControlScore,
     )
     const result = this.gameRuntime.loadDefinition(definition)
@@ -1723,6 +1743,12 @@ export class MapEngine {
       panelProgressFxClass: '',
       panelDistanceFxClass: '',
     }, true)
+    this.currentContentCardPriority = 0
+  }
+
+  resetSessionContentExperienceState(): void {
+    this.shownContentCardKeys = {}
+    this.currentContentCardPriority = 0
   }
 
   clearSessionTimerInterval(): void {
@@ -1878,7 +1904,22 @@ export class MapEngine {
     }, 1400) as unknown as number
   }
 
-  showContentCard(title: string, body: string, motionClass = ''): void {
+  showContentCard(title: string, body: string, motionClass = '', options?: { contentKey?: string; autoPopup?: boolean; once?: boolean; priority?: number }): void {
+    const autoPopup = !options || options.autoPopup !== false
+    const once = !!(options && options.once)
+    const priority = options && typeof options.priority === 'number' ? options.priority : 0
+    const contentKey = options && options.contentKey ? options.contentKey : ''
+
+    if (!autoPopup) {
+      return
+    }
+    if (once && contentKey && this.shownContentCardKeys[contentKey]) {
+      return
+    }
+    if (this.state.contentCardVisible && priority < this.currentContentCardPriority) {
+      return
+    }
+
     this.clearContentCardTimer()
     this.setState({
       contentCardVisible: true,
@@ -1886,8 +1927,13 @@ export class MapEngine {
       contentCardBody: body,
       contentCardFxClass: motionClass,
     }, true)
+    this.currentContentCardPriority = priority
+    if (once && contentKey) {
+      this.shownContentCardKeys[contentKey] = true
+    }
     this.contentCardTimer = setTimeout(() => {
       this.contentCardTimer = 0
+      this.currentContentCardPriority = 0
       this.setState({
         contentCardVisible: false,
         contentCardFxClass: '',
@@ -1897,6 +1943,7 @@ export class MapEngine {
 
   closeContentCard(): void {
     this.clearContentCardTimer()
+    this.currentContentCardPriority = 0
     this.setState({
       contentCardVisible: false,
       contentCardFxClass: '',
@@ -1955,11 +2002,19 @@ export class MapEngine {
     }
 
     if (this.gameRuntime.state.status !== 'idle') {
-      return
+      if (this.gameRuntime.state.status === 'finished' || this.gameRuntime.state.status === 'failed') {
+        const reloadedResult = this.loadGameDefinitionFromCourse()
+        if (!reloadedResult || !this.gameRuntime.state) {
+          return
+        }
+      } else {
+        return
+      }
     }
 
     this.feedbackDirector.reset()
     this.resetTransientGameUiState()
+    this.resetSessionContentExperienceState()
     this.clearStartSessionResidue()
 
     if (!this.locationController.listening) {
@@ -1985,9 +2040,10 @@ export class MapEngine {
     }
 
     this.courseOverlayVisible = true
+    const gameModeText = this.gameMode === 'score-o' ? '积分赛' : '顺序打点'
     const defaultStatusText = this.currentGpsPoint
-      ? `顺序打点已开始 (${this.buildVersion})`
-      : `顺序打点已开始,GPS定位启动中 (${this.buildVersion})`
+      ? `${gameModeText}已开始 (${this.buildVersion})`
+      : `${gameModeText}已开始,GPS定位启动中 (${this.buildVersion})`
     this.commitGameResult(gameResult, defaultStatusText)
   }
 
@@ -2000,6 +2056,7 @@ export class MapEngine {
     if (!this.courseData) {
       this.clearGameRuntime()
       this.resetTransientGameUiState()
+      this.resetSessionContentExperienceState()
       this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
       this.setState({
         gpsTracking: false,
@@ -2012,6 +2069,7 @@ export class MapEngine {
 
     this.loadGameDefinitionFromCourse()
     this.resetTransientGameUiState()
+    this.resetSessionContentExperienceState()
     this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
     this.setState({
       gpsTracking: false,
@@ -2384,6 +2442,7 @@ export class MapEngine {
     this.configSchemaVersion = config.configSchemaVersion
     this.configVersion = config.configVersion
     this.controlScoreOverrides = config.controlScoreOverrides
+    this.controlContentOverrides = config.controlContentOverrides
     this.defaultControlScore = config.defaultControlScore
     this.gameMode = config.gameMode
     this.punchPolicy = config.punchPolicy

+ 53 - 9
miniprogram/game/content/courseToGameDefinition.ts

@@ -1,4 +1,10 @@
-import { type GameDefinition, type GameControl, type PunchPolicyType } from '../core/gameDefinition'
+import {
+  type GameDefinition,
+  type GameControl,
+  type GameControlDisplayContent,
+  type GameControlDisplayContentOverride,
+  type PunchPolicyType,
+} from '../core/gameDefinition'
 import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
 
 function sortBySequence<T extends { sequence: number | null }>(items: T[]): T[] {
@@ -13,6 +19,23 @@ function buildDisplayBody(label: string, sequence: number | null): string {
   return label
 }
 
+function applyDisplayContentOverride(
+  baseContent: GameControlDisplayContent,
+  override: GameControlDisplayContentOverride | undefined,
+): GameControlDisplayContent {
+  if (!override) {
+    return baseContent
+  }
+
+  return {
+    title: override.title || baseContent.title,
+    body: override.body || baseContent.body,
+    autoPopup: override.autoPopup !== undefined ? override.autoPopup : baseContent.autoPopup,
+    once: override.once !== undefined ? override.once : baseContent.once,
+    priority: override.priority !== undefined ? override.priority : baseContent.priority,
+  }
+}
+
 export function buildGameDefinitionFromCourse(
   course: OrienteeringCourseData,
   controlRadiusMeters: number,
@@ -25,20 +48,29 @@ export function buildGameDefinitionFromCourse(
   skipRadiusMeters = 30,
   skipRequiresConfirm = true,
   controlScoreOverrides: Record<string, number> = {},
+  controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {},
   defaultControlScore: number | null = null,
 ): GameDefinition {
   const controls: GameControl[] = []
 
-  for (const start of course.layers.starts) {
+  for (let startIndex = 0; startIndex < course.layers.starts.length; startIndex += 1) {
+    const start = course.layers.starts[startIndex]
+    const startId = `start-${startIndex + 1}`
     controls.push({
-      id: `start-${controls.length + 1}`,
+      id: startId,
       code: start.label || 'S',
       label: start.label || 'Start',
       kind: 'start',
       point: start.point,
       sequence: null,
       score: null,
-      displayContent: null,
+      displayContent: applyDisplayContentOverride({
+        title: '比赛开始',
+        body: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
+        autoPopup: true,
+        once: false,
+        priority: 1,
+      }, controlContentOverrides[startId]),
     })
   }
 
@@ -56,23 +88,35 @@ export function buildGameDefinitionFromCourse(
       point: control.point,
       sequence: control.sequence,
       score,
-      displayContent: {
+      displayContent: applyDisplayContentOverride({
         title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
         body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence),
-      },
+        autoPopup: true,
+        once: false,
+        priority: 1,
+      }, controlContentOverrides[controlId]),
     })
   }
 
-  for (const finish of course.layers.finishes) {
+  for (let finishIndex = 0; finishIndex < course.layers.finishes.length; finishIndex += 1) {
+    const finish = course.layers.finishes[finishIndex]
+    const finishId = `finish-${finishIndex + 1}`
+    const legacyFinishId = `finish-${controls.length + 1}`
     controls.push({
-      id: `finish-${controls.length + 1}`,
+      id: finishId,
       code: finish.label || 'F',
       label: finish.label || 'Finish',
       kind: 'finish',
       point: finish.point,
       sequence: null,
       score: null,
-      displayContent: null,
+      displayContent: applyDisplayContentOverride({
+        title: '完成路线',
+        body: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
+        autoPopup: true,
+        once: false,
+        priority: 2,
+      }, controlContentOverrides[finishId] || controlContentOverrides[legacyFinishId]),
     })
   }
 

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

@@ -8,6 +8,17 @@ export type PunchPolicyType = 'enter' | 'enter-confirm'
 export interface GameControlDisplayContent {
   title: string
   body: string
+  autoPopup: boolean
+  once: boolean
+  priority: number
+}
+
+export interface GameControlDisplayContentOverride {
+  title?: string
+  body?: string
+  autoPopup?: boolean
+  once?: boolean
+  priority?: number
 }
 
 export interface GameControl {

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

@@ -5,7 +5,7 @@ export type GameEffect =
   | { type: 'session_started' }
   | { type: 'session_cancelled' }
   | { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' }
-  | { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string }
+  | { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string; displayAutoPopup: boolean; displayOnce: boolean; displayPriority: number }
   | { type: 'guidance_state_changed'; guidanceState: GuidanceState; controlId: string | null }
   | { type: 'session_finished' }
 

+ 7 - 1
miniprogram/game/feedback/uiEffectDirector.ts

@@ -16,7 +16,7 @@ import {
 
 export interface UiEffectHost {
   showPunchFeedback: (text: string, tone: 'neutral' | 'success' | 'warning', motionClass?: string) => void
-  showContentCard: (title: string, body: string, motionClass?: string) => void
+  showContentCard: (title: string, body: string, motionClass?: string, options?: { contentKey?: string; autoPopup?: boolean; once?: boolean; priority?: number }) => void
   setPunchButtonFxClass: (className: string) => void
   setHudProgressFxClass: (className: string) => void
   setHudDistanceFxClass: (className: string) => void
@@ -262,6 +262,12 @@ export class UiEffectDirector {
           effect.displayTitle,
           effect.displayBody,
           cue ? this.getContentCardMotionClass(cue.contentCardMotion) : '',
+          {
+            contentKey: effect.controlId,
+            autoPopup: effect.displayAutoPopup,
+            once: effect.displayOnce,
+            priority: effect.displayPriority,
+          },
         )
         if (cue && cue.mapPulseMotion !== 'none') {
           this.host.showMapPulse(effect.controlId, this.getMapPulseMotionClass(cue.mapPulseMotion))

+ 91 - 0
miniprogram/game/result/resultSummary.ts

@@ -0,0 +1,91 @@
+import { type GameDefinition } from '../core/gameDefinition'
+import { type GameSessionState } from '../core/gameSessionState'
+import { type TelemetryPresentation } from '../telemetry/telemetryPresentation'
+
+export interface ResultSummaryRow {
+  label: string
+  value: string
+}
+
+export interface ResultSummarySnapshot {
+  title: string
+  subtitle: string
+  heroLabel: string
+  heroValue: string
+  rows: ResultSummaryRow[]
+}
+
+function resolveTitle(definition: GameDefinition | null, mapTitle: string): string {
+  if (mapTitle) {
+    return mapTitle
+  }
+  if (definition && definition.title) {
+    return definition.title
+  }
+  return '本局结果'
+}
+
+function buildHeroValue(definition: GameDefinition | null, sessionState: GameSessionState, telemetryPresentation: TelemetryPresentation): string {
+  if (definition && definition.mode === 'score-o') {
+    return `${sessionState.score}`
+  }
+  return telemetryPresentation.timerText
+}
+
+function buildHeroLabel(definition: GameDefinition | null): string {
+  return definition && definition.mode === 'score-o' ? '本局得分' : '本局用时'
+}
+
+function buildSubtitle(sessionState: GameSessionState): string {
+  if (sessionState.status === 'finished') {
+    return '本局已完成'
+  }
+  if (sessionState.status === 'failed') {
+    return '本局已结束'
+  }
+  return '对局摘要'
+}
+
+export function buildResultSummarySnapshot(
+  definition: GameDefinition | null,
+  sessionState: GameSessionState | null,
+  telemetryPresentation: TelemetryPresentation,
+  mapTitle: string,
+): ResultSummarySnapshot {
+  const resolvedSessionState: GameSessionState = sessionState || {
+    status: 'idle',
+    startedAt: null,
+    endedAt: null,
+    completedControlIds: [],
+    skippedControlIds: [],
+    currentTargetControlId: null,
+    inRangeControlId: null,
+    score: 0,
+    guidanceState: 'searching',
+    modeState: null,
+  }
+  const skippedCount = resolvedSessionState.skippedControlIds.length
+  const totalControlCount = definition
+    ? definition.controls.filter((control) => control.kind === 'control').length
+    : 0
+  const averageHeartRateText = telemetryPresentation.heartRateValueText !== '--'
+    ? `${telemetryPresentation.heartRateValueText} ${telemetryPresentation.heartRateUnitText || 'bpm'}`
+    : '--'
+
+  return {
+    title: resolveTitle(definition, mapTitle),
+    subtitle: buildSubtitle(resolvedSessionState),
+    heroLabel: buildHeroLabel(definition),
+    heroValue: buildHeroValue(definition, resolvedSessionState, telemetryPresentation),
+    rows: [
+      { label: '状态', value: resolvedSessionState.status === 'finished' ? '完成' : (resolvedSessionState.status === 'failed' ? '结束' : '进行中') },
+      { label: '完成点数', value: totalControlCount > 0 ? `${resolvedSessionState.completedControlIds.length}/${totalControlCount}` : `${resolvedSessionState.completedControlIds.length}` },
+      { label: '跳过点数', value: `${skippedCount}` },
+      { label: '累计里程', value: telemetryPresentation.mileageText },
+      { label: '平均速度', value: `${telemetryPresentation.averageSpeedValueText}${telemetryPresentation.averageSpeedUnitText}` },
+      { label: '当前得分', value: `${resolvedSessionState.score}` },
+      { label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` },
+      { label: '平均心率', value: averageHeartRateText },
+    ],
+  }
+}

+ 13 - 4
miniprogram/game/rules/classicSequentialRule.ts

@@ -287,8 +287,11 @@ function buildCompletedEffect(control: GameControl): GameEffect {
       controlKind: 'start',
       sequence: null,
       label: control.label,
-      displayTitle: '比赛开始',
-      displayBody: '已完成开始点打卡,前往 1 号点。',
+      displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
+      displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,前往 1 号点。',
+      displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
+      displayOnce: control.displayContent ? control.displayContent.once : false,
+      displayPriority: control.displayContent ? control.displayContent.priority : 1,
     }
   }
 
@@ -299,8 +302,11 @@ function buildCompletedEffect(control: GameControl): GameEffect {
       controlKind: 'finish',
       sequence: null,
       label: control.label,
-      displayTitle: '比赛结束',
-      displayBody: '已完成终点打卡,本局结束。',
+      displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
+      displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
+      displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
+      displayOnce: control.displayContent ? control.displayContent.once : false,
+      displayPriority: control.displayContent ? control.displayContent.priority : 2,
     }
   }
 
@@ -316,6 +322,9 @@ function buildCompletedEffect(control: GameControl): GameEffect {
     label: control.label,
     displayTitle,
     displayBody,
+    displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
+    displayOnce: control.displayContent ? control.displayContent.once : false,
+    displayPriority: control.displayContent ? control.displayContent.priority : 1,
   }
 }
 

+ 13 - 4
miniprogram/game/rules/scoreORule.ts

@@ -249,8 +249,11 @@ function buildCompletedEffect(control: GameControl): GameEffect {
       controlKind: 'start',
       sequence: null,
       label: control.label,
-      displayTitle: '比赛开始',
-      displayBody: '已完成开始点打卡,开始自由打点。',
+      displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
+      displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,开始自由打点。',
+      displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
+      displayOnce: control.displayContent ? control.displayContent.once : false,
+      displayPriority: control.displayContent ? control.displayContent.priority : 1,
     }
   }
 
@@ -261,8 +264,11 @@ function buildCompletedEffect(control: GameControl): GameEffect {
       controlKind: 'finish',
       sequence: null,
       label: control.label,
-      displayTitle: '比赛结束',
-      displayBody: '已完成终点打卡,本局结束。',
+      displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
+      displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
+      displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
+      displayOnce: control.displayContent ? control.displayContent.once : false,
+      displayPriority: control.displayContent ? control.displayContent.priority : 2,
     }
   }
 
@@ -275,6 +281,9 @@ function buildCompletedEffect(control: GameControl): GameEffect {
     label: control.label,
     displayTitle: control.displayContent ? control.displayContent.title : `收集 ${sequenceText}`,
     displayBody: control.displayContent ? control.displayContent.body : control.label,
+    displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
+    displayOnce: control.displayContent ? control.displayContent.once : false,
+    displayPriority: control.displayContent ? control.displayContent.priority : 1,
   }
 }
 

+ 88 - 3
miniprogram/pages/map/map.ts

@@ -2,6 +2,7 @@ import {
   MapEngine,
   type MapEngineGameInfoRow,
   type MapEngineGameInfoSnapshot,
+  type MapEngineResultSnapshot,
   type MapEngineStageRect,
   type MapEngineViewState,
 } from '../../engine/map/mapEngine'
@@ -64,6 +65,7 @@ type StoredUserSettings = {
 type MapPageData = MapEngineViewState & {
   showDebugPanel: boolean
   showGameInfoPanel: boolean
+  showResultScene: boolean
   showSystemSettingsPanel: boolean
   showCenterScaleRuler: boolean
   showPunchHintBanner: boolean
@@ -78,6 +80,11 @@ type MapPageData = MapEngineViewState & {
   gameInfoSubtitle: string
   gameInfoLocalRows: MapEngineGameInfoRow[]
   gameInfoGlobalRows: MapEngineGameInfoRow[]
+  resultSceneTitle: string
+  resultSceneSubtitle: string
+  resultSceneHeroLabel: string
+  resultSceneHeroValue: string
+  resultSceneRows: MapEngineGameInfoRow[]
   panelTimerText: string
   panelMileageText: string
   panelDistanceValueText: string
@@ -121,7 +128,7 @@ type MapPageData = MapEngineViewState & {
   showRightButtonGroups: boolean
   showBottomDebugButton: boolean
 }
-const INTERNAL_BUILD_VERSION = 'map-build-283'
+const INTERNAL_BUILD_VERSION = 'map-build-291'
 const USER_SETTINGS_STORAGE_KEY = 'cmr_user_settings_v1'
 const CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json'
 const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json'
@@ -494,7 +501,7 @@ function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGa
     : data.gpsLockEnabled
       ? 'active'
       : 'default'
-  const sideButton4State: SideActionButtonState = data.gameSessionStatus === 'idle' ? 'default' : 'active'
+  const sideButton4State: SideActionButtonState = data.gameSessionStatus === 'running' ? 'active' : 'muted'
   const sideButton11State: SideActionButtonState = data.showGameInfoPanel ? 'active' : 'default'
   const sideButton12State: SideActionButtonState = data.showSystemSettingsPanel ? 'active' : 'default'
   const sideButton13State: SideActionButtonState = data.showCenterScaleRuler ? 'active' : 'default'
@@ -690,10 +697,21 @@ function buildEmptyGameInfoSnapshot(): MapEngineGameInfoSnapshot {
   }
 }
 
+function buildEmptyResultSceneSnapshot(): MapEngineResultSnapshot {
+  return {
+    title: '本局结果',
+    subtitle: '未开始',
+    heroLabel: '本局用时',
+    heroValue: '--',
+    rows: [],
+  }
+}
+
 Page({
   data: {
     showDebugPanel: false,
     showGameInfoPanel: false,
+    showResultScene: false,
     showSystemSettingsPanel: false,
     showCenterScaleRuler: false,
     statusBarHeight: 0,
@@ -714,6 +732,11 @@ Page({
     gameInfoSubtitle: '未开始',
     gameInfoLocalRows: [],
     gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
+    resultSceneTitle: '本局结果',
+    resultSceneSubtitle: '未开始',
+    resultSceneHeroLabel: '本局用时',
+    resultSceneHeroValue: '--',
+    resultSceneRows: buildEmptyResultSceneSnapshot().rows,
     panelTimerText: '00:00:00',
     panelMileageText: '0m',
     panelActionTagText: '目标',
@@ -942,6 +965,22 @@ Page({
           }
         }
 
+        if (typeof nextPatch.gameSessionStatus === 'string') {
+          if (
+            nextPatch.gameSessionStatus !== this.data.gameSessionStatus
+            && (nextPatch.gameSessionStatus === 'finished' || nextPatch.gameSessionStatus === 'failed')
+          ) {
+            this.syncResultSceneSnapshot()
+            nextData.showResultScene = true
+            nextData.showDebugPanel = false
+            nextData.showGameInfoPanel = false
+            nextData.showSystemSettingsPanel = false
+            clearGameInfoPanelSyncTimer()
+          } else if (nextPatch.gameSessionStatus === 'running' || nextPatch.gameSessionStatus === 'idle') {
+            nextData.showResultScene = false
+          }
+        }
+
         if (Object.keys(nextData).length || Object.keys(derivedPatch).length) {
           this.setData({
             ...nextData,
@@ -1341,6 +1380,16 @@ Page({
     }
   },
 
+  handleConnectAllMockSources() {
+    if (!mapEngine) {
+      return
+    }
+    mapEngine.handleConnectMockLocationBridge()
+    mapEngine.handleSetMockLocationMode()
+    mapEngine.handleSetMockHeartRateMode()
+    mapEngine.handleConnectMockHeartRateBridge()
+  },
+
   handleMockBridgeUrlInput(event: WechatMiniprogram.Input) {
     this.setData({
       mockBridgeUrlDraft: event.detail.value,
@@ -1485,7 +1534,7 @@ Page({
   },
 
   handleForceExitGame() {
-    if (!mapEngine || this.data.gameSessionStatus === 'idle') {
+    if (!mapEngine || this.data.gameSessionStatus !== 'running') {
       return
     }
 
@@ -1555,6 +1604,21 @@ Page({
     })
   },
 
+  syncResultSceneSnapshot() {
+    if (!mapEngine) {
+      return
+    }
+
+    const snapshot = mapEngine.getResultSceneSnapshot()
+    this.setData({
+      resultSceneTitle: snapshot.title,
+      resultSceneSubtitle: snapshot.subtitle,
+      resultSceneHeroLabel: snapshot.heroLabel,
+      resultSceneHeroValue: snapshot.heroValue,
+      resultSceneRows: snapshot.rows,
+    })
+  },
+
   scheduleGameInfoPanelSnapshotSync() {
     if (!this.data.showGameInfoPanel) {
       clearGameInfoPanelSyncTimer()
@@ -1614,6 +1678,27 @@ Page({
 
   handleGameInfoPanelTap() {},
 
+  handleResultSceneTap() {},
+
+  handleCloseResultScene() {
+    this.setData({
+      showResultScene: false,
+    })
+  },
+
+  handleRestartFromResult() {
+    if (!mapEngine) {
+      return
+    }
+    this.setData({
+      showResultScene: false,
+    }, () => {
+      if (mapEngine) {
+        mapEngine.handleStartGame()
+      }
+    })
+  },
+
   handleOpenSystemSettingsPanel() {
     clearGameInfoPanelSyncTimer()
     this.setData({

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

@@ -36,7 +36,7 @@
     </view>
 
 
-    <view class="map-stage__overlay-center-layer" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel}}">
+    <view class="map-stage__overlay-center-layer" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel}}">
       <view class="center-scale-ruler" wx:if="{{centerScaleRulerVisible}}" style="left: {{centerScaleRulerCenterXPx}}px; top: {{centerScaleRulerZeroYPx}}px; height: {{centerScaleRulerHeightPx}}px;">
         <view class="center-scale-ruler__axis" style="bottom: {{centerScaleRulerAxisBottomPx}}px;"></view>
         <view class="center-scale-ruler__arrow"></view>
@@ -46,7 +46,7 @@
         <view wx:for="{{centerScaleRulerMajorMarks}}" wx:key="key" class="center-scale-ruler__label" style="top: {{item.topPx}}px;">{{item.label}}</view>
       </view>
     </view>
-    <view class="map-stage__overlay">
+    <view class="map-stage__overlay" wx:if="{{!showResultScene}}">
       <view class="map-stage__bottom">
         <view class="compass-widget">
           <view class="compass-widget__heading-wrap">
@@ -80,18 +80,18 @@
     </view>
   </view>
 
-  <view class="game-punch-hint" wx:if="{{showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap">
+  <view class="game-punch-hint" wx:if="{{!showResultScene && showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap">
     <view class="game-punch-hint__text">{{punchHintText}}</view>
     <view class="game-punch-hint__close" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap" catchtap="handleClosePunchHint">×</view>
   </view>
 
-  <cover-view class="map-side-toggle {{sideButtonPlacement === 'right' ? 'map-side-toggle--right' : 'map-side-toggle--left'}}" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel}}" style="top: {{topInsetHeight}}px;" bindtap="handleCycleSideButtons">
+  <cover-view class="map-side-toggle {{sideButtonPlacement === 'right' ? 'map-side-toggle--right' : 'map-side-toggle--left'}}" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel}}" style="top: {{topInsetHeight}}px;" bindtap="handleCycleSideButtons">
     <cover-view class="map-side-button map-side-button--icon">
       <cover-image class="map-side-button__image" src="{{sideToggleIconSrc}}"></cover-image>
     </cover-view>
   </cover-view>
 
-  <cover-view class="map-side-column {{sideButtonPlacement === 'right' ? 'map-side-column--right-group' : 'map-side-column--left'}} map-side-column--left-group" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
+  <cover-view class="map-side-column {{sideButtonPlacement === 'right' ? 'map-side-column--right-group' : 'map-side-column--left'}} map-side-column--left-group" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
     <cover-view class="map-side-button map-side-button--icon" bindtap="handleToggleMapRotateMode"><cover-image class="map-side-button__rotate-image {{orientationMode === 'heading-up' ? 'map-side-button__rotate-image--active' : ''}}" src="../../assets/btn_map_rotate_cropped.png"></cover-image></cover-view>
     <cover-view class="{{sideButton2Class}}" bindtap="handleToggleGpsLock">
       <cover-image
@@ -111,15 +111,15 @@
     <cover-view class="{{sideButton4Class}}" bindtap="handleForceExitGame"><cover-image class="map-side-button__action-image" src="../../assets/btn_exit.png"></cover-image></cover-view>
   </cover-view>
 
-  <cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel}}" bindtap="handlePunchAction">
+  <cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel}}" 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 && !showGameInfoPanel && !showSystemSettingsPanel && showBottomDebugButton && gameSessionStatus === 'idle'}}" bindtap="handleStartGame">
+  <cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel && showBottomDebugButton && gameSessionStatus !== 'running'}}" 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 && !showGameInfoPanel && !showSystemSettingsPanel && showBottomDebugButton}}" bindtap="handleToggleDebugPanel">
+  <cover-view class="screen-button-layer screen-button-layer--bottom-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel && showBottomDebugButton}}" bindtap="handleToggleDebugPanel">
     <cover-view class="screen-button-layer__icon">
       <cover-view class="screen-button-layer__line"></cover-view>
       <cover-view class="screen-button-layer__stand"></cover-view>
@@ -127,7 +127,7 @@
     <cover-view class="screen-button-layer__text">调试</cover-view>
   </cover-view>
 
-  <swiper wx:if="{{!showGameInfoPanel && !showSystemSettingsPanel}}" class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
+  <swiper wx:if="{{!showGameInfoPanel && !showResultScene && !showSystemSettingsPanel}}" 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">{{panelActionTagText}}</view>
@@ -232,7 +232,7 @@
       </view>
     </swiper-item>
   </swiper>
-  <view class="race-panel-pager" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel}}">
+  <view class="race-panel-pager" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel}}">
     <view class="race-panel-pager__dot {{hudPanelIndex === 0 ? 'race-panel-pager__dot--active' : ''}}"></view>
     <view class="race-panel-pager__dot {{hudPanelIndex === 1 ? 'race-panel-pager__dot--active' : ''}}"></view>
   </view>
@@ -276,6 +276,31 @@
     </view>
   </view>
 
+  <view class="result-scene-modal" wx:if="{{showResultScene}}" bindtap="handleCloseResultScene">
+    <view class="result-scene-modal__dialog" catchtap="handleResultSceneTap">
+      <view class="result-scene-modal__eyebrow">RESULT</view>
+      <view class="result-scene-modal__title">{{resultSceneTitle}}</view>
+      <view class="result-scene-modal__subtitle">{{resultSceneSubtitle}}</view>
+
+      <view class="result-scene-modal__hero">
+        <view class="result-scene-modal__hero-label">{{resultSceneHeroLabel}}</view>
+        <view class="result-scene-modal__hero-value">{{resultSceneHeroValue}}</view>
+      </view>
+
+      <view class="result-scene-modal__rows">
+        <view class="result-scene-modal__row" wx:for="{{resultSceneRows}}" wx:key="label">
+          <text class="result-scene-modal__row-label">{{item.label}}</text>
+          <text class="result-scene-modal__row-value">{{item.value}}</text>
+        </view>
+      </view>
+
+      <view class="result-scene-modal__actions">
+        <view class="result-scene-modal__action result-scene-modal__action--secondary" bindtap="handleCloseResultScene">返回地图</view>
+        <view class="result-scene-modal__action result-scene-modal__action--primary" bindtap="handleRestartFromResult">再来一局</view>
+      </view>
+    </view>
+  </view>
+
   <view class="game-info-modal" wx:if="{{showSystemSettingsPanel}}" bindtap="handleCloseSystemSettingsPanel">
     <view class="game-info-modal__dialog" catchtap="handleSystemSettingsPanelTap">
       <view class="game-info-modal__header">
@@ -525,6 +550,9 @@
             <view class="debug-section__title">Sensors</view>
             <view class="debug-section__desc">定位、罗盘与心率带连接状态</view>
           </view>
+          <view class="control-row">
+            <view class="control-chip control-chip--primary" bindtap="handleConnectAllMockSources">一键连接模拟源</view>
+          </view>
           <view class="debug-group-title">定位</view>
           <view class="info-panel__row">
             <text class="info-panel__label">GPS</text>

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

@@ -1327,6 +1327,130 @@
   box-sizing: border-box;
 }
 
+.result-scene-modal {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 28rpx;
+  box-sizing: border-box;
+  background: rgba(7, 18, 12, 0.38);
+  z-index: 32;
+}
+
+.result-scene-modal__dialog {
+  width: 100%;
+  max-width: 680rpx;
+  padding: 36rpx 32rpx 30rpx;
+  border-radius: 40rpx;
+  background: rgba(248, 251, 244, 0.98);
+  box-shadow: 0 22rpx 68rpx rgba(7, 18, 12, 0.24);
+  box-sizing: border-box;
+}
+
+.result-scene-modal__eyebrow {
+  font-size: 22rpx;
+  font-weight: 800;
+  letter-spacing: 4rpx;
+  color: #5f7a65;
+  line-height: 1;
+}
+
+.result-scene-modal__title {
+  margin-top: 14rpx;
+  font-size: 46rpx;
+  line-height: 1.08;
+  font-weight: 700;
+  color: #163020;
+}
+
+.result-scene-modal__subtitle {
+  margin-top: 12rpx;
+  font-size: 24rpx;
+  line-height: 1.35;
+  color: #5f7a65;
+}
+
+.result-scene-modal__hero {
+  margin-top: 28rpx;
+  padding: 26rpx 24rpx 22rpx;
+  border-radius: 28rpx;
+  background: linear-gradient(180deg, rgba(35, 135, 87, 0.12), rgba(35, 135, 87, 0.06));
+}
+
+.result-scene-modal__hero-label {
+  font-size: 22rpx;
+  font-weight: 700;
+  color: #4d6852;
+}
+
+.result-scene-modal__hero-value {
+  margin-top: 12rpx;
+  font-size: 68rpx;
+  line-height: 1;
+  font-weight: 800;
+  color: #163020;
+}
+
+.result-scene-modal__rows {
+  margin-top: 24rpx;
+  border-radius: 28rpx;
+  overflow: hidden;
+  background: rgba(22, 48, 32, 0.04);
+}
+
+.result-scene-modal__row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 24rpx;
+  padding: 22rpx 24rpx;
+  border-bottom: 1rpx solid rgba(22, 48, 32, 0.08);
+}
+
+.result-scene-modal__row:last-child {
+  border-bottom: none;
+}
+
+.result-scene-modal__row-label {
+  font-size: 24rpx;
+  color: #5f7a65;
+}
+
+.result-scene-modal__row-value {
+  font-size: 26rpx;
+  font-weight: 700;
+  color: #163020;
+  text-align: right;
+}
+
+.result-scene-modal__actions {
+  margin-top: 28rpx;
+  display: flex;
+  align-items: center;
+  gap: 18rpx;
+}
+
+.result-scene-modal__action {
+  flex: 1;
+  padding: 24rpx 18rpx;
+  border-radius: 999rpx;
+  text-align: center;
+  font-size: 26rpx;
+  font-weight: 700;
+}
+
+.result-scene-modal__action--secondary {
+  background: rgba(22, 48, 32, 0.08);
+  color: #163020;
+}
+
+.result-scene-modal__action--primary {
+  background: #163020;
+  color: #f7fbf2;
+}
+
 .debug-section--info {
   margin-top: 14rpx;
 }

+ 28 - 0
miniprogram/utils/remoteMapConfig.ts

@@ -2,6 +2,7 @@ import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './proj
 import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
 import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
 import { mergeTelemetryConfig, type TelemetryConfig } from '../game/telemetry/telemetryConfig'
+import { type GameControlDisplayContentOverride } from '../game/core/gameDefinition'
 import {
   mergeGameHapticsConfig,
   mergeGameUiEffectsConfig,
@@ -55,6 +56,7 @@ export interface RemoteMapConfig {
   skipRequiresConfirm: boolean
   autoFinishOnLastControl: boolean
   controlScoreOverrides: Record<string, number>
+  controlContentOverrides: Record<string, GameControlDisplayContentOverride>
   defaultControlScore: number | null
   telemetryConfig: TelemetryConfig
   audioConfig: GameAudioConfig
@@ -81,6 +83,7 @@ interface ParsedGameConfig {
   skipRequiresConfirm: boolean
   autoFinishOnLastControl: boolean
   controlScoreOverrides: Record<string, number>
+  controlContentOverrides: Record<string, GameControlDisplayContentOverride>
   defaultControlScore: number | null
   telemetryConfig: TelemetryConfig
   audioConfig: GameAudioConfig
@@ -759,6 +762,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
     ? rawPlayfield.controlOverrides as Record<string, unknown>
     : null
   const controlScoreOverrides: Record<string, number> = {}
+  const controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {}
   if (rawControlOverrides) {
     const keys = Object.keys(rawControlOverrides)
     for (const key of keys) {
@@ -770,6 +774,27 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
       if (Number.isFinite(scoreValue)) {
         controlScoreOverrides[key] = scoreValue
       }
+      const titleValue = typeof (item as Record<string, unknown>).title === 'string'
+        ? ((item as Record<string, unknown>).title as string).trim()
+        : ''
+      const bodyValue = typeof (item as Record<string, unknown>).body === 'string'
+        ? ((item as Record<string, unknown>).body as string).trim()
+        : ''
+      const autoPopupValue = (item as Record<string, unknown>).autoPopup
+      const onceValue = (item as Record<string, unknown>).once
+      const priorityNumeric = Number((item as Record<string, unknown>).priority)
+      const hasAutoPopup = typeof autoPopupValue === 'boolean'
+      const hasOnce = typeof onceValue === 'boolean'
+      const hasPriority = Number.isFinite(priorityNumeric)
+      if (titleValue || bodyValue || hasAutoPopup || hasOnce || hasPriority) {
+        controlContentOverrides[key] = {
+          ...(titleValue ? { title: titleValue } : {}),
+          ...(bodyValue ? { body: bodyValue } : {}),
+          ...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
+          ...(hasOnce ? { once: !!onceValue } : {}),
+          ...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
+        }
+      }
     }
   }
 
@@ -859,6 +884,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
       true,
     ),
     controlScoreOverrides,
+    controlContentOverrides,
     defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined
       ? parsePositiveNumber(rawScoring.defaultControlScore, 10)
       : null,
@@ -921,6 +947,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
     skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, true),
     autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
     controlScoreOverrides: {},
+    controlContentOverrides: {},
     defaultControlScore: null,
     telemetryConfig: parseTelemetryConfig({
       heartRate: {
@@ -1206,6 +1233,7 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
     skipRequiresConfirm: gameConfig.skipRequiresConfirm,
     autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
     controlScoreOverrides: gameConfig.controlScoreOverrides,
+    controlContentOverrides: gameConfig.controlContentOverrides,
     defaultControlScore: gameConfig.defaultControlScore,
     telemetryConfig: gameConfig.telemetryConfig,
     audioConfig: gameConfig.audioConfig,

+ 293 - 0
result-scene-proposal.md

@@ -0,0 +1,293 @@
+# 游戏结算层方案
+
+## 1. 目标
+
+为游戏结束后的结果展示建立独立结算层,不把结算逻辑散落在:
+
+- 规则层
+- HUD
+- 顶部提示
+- 页面临时弹窗
+
+目标是:
+
+- 统一承接结束态
+- 展示成绩与摘要信息
+- 支撑不同玩法的结算差异
+- 为后续文创奖励、奖章、分享做扩展位
+
+一句话:
+
+**把“比赛结束后显示点什么”提升为正式的结果场景能力。**
+
+---
+
+## 2. 当前现状
+
+当前项目已经有:
+
+- `session_finished`
+- `gameSessionStatus = finished`
+- 基础成绩、里程、时长、心率等 telemetry
+- 游戏信息面板可读取当前状态快照
+
+但还没有正式的:
+
+- `ResultScene`
+- `SummaryModel`
+- 结束后专属页面承载
+
+---
+
+## 3. 设计原则
+
+### 3.1 结算不应只是提示条
+
+结束不是一个瞬时反馈,而是一次阶段切换。
+
+所以它需要独立层,而不是只弹一句:
+
+- 已完成
+- 已结束
+
+### 3.2 结算要与玩法解耦
+
+顺序赛、积分赛、后续幽灵赛、金币赛,结算内容不同。
+
+所以应该有:
+
+- 通用结算结构
+- 玩法补充区块
+
+### 3.3 结算要可扩
+
+后续可能加入:
+
+- 奖章
+- 排名
+- 收藏卡
+- 文创解锁
+- 分享图
+
+所以一开始就要留结构。
+
+---
+
+## 4. 建议的新层级
+
+建议增加:
+
+- `ResultScene`
+
+概念上与这些层并列:
+
+- `MapPresentation`
+- `HUD`
+- `Feedback`
+- `ContentExperienceLayer`
+
+职责:
+
+- 承接结束态
+- 持有结算模型
+- 控制显示与关闭
+- 为玩法结果提供统一展示结构
+
+---
+
+## 5. 建议的数据模型
+
+### 5.1 SummaryModel
+
+```ts
+interface ResultSummaryModel {
+  title: string
+  subtitle: string
+  mode: string
+  finished: boolean
+  durationMs: number
+  distanceMeters: number
+  averageSpeedKmh: number | null
+  calories: number | null
+  averageHeartRateBpm: number | null
+  completedCount: number
+  skippedCount: number
+  totalCount: number
+  score: number | null
+  extraRows: Array<{ label: string; value: string }>
+}
+```
+
+### 5.2 ResultSceneState
+
+```ts
+interface ResultSceneState {
+  visible: boolean
+  summary: ResultSummaryModel | null
+}
+```
+
+---
+
+## 6. 第一阶段应展示什么
+
+建议先做一版“基础结算页”,不要一上来做复杂演出。
+
+### 通用区域
+
+- 赛事名称
+- 玩法名称
+- 完成状态
+- 总用时
+- 总里程
+- 平均速度
+- 卡路里
+- 平均心率
+
+### 玩法区域
+
+顺序赛:
+
+- 完成控制点数量
+- 跳过点数量
+- 总控制点数量
+
+积分赛:
+
+- 总得分
+- 已完成点数
+- 未完成点数
+
+### 操作区
+
+- 返回地图
+- 关闭
+- 后续再加重开 / 分享
+
+---
+
+## 7. 配置建议
+
+建议在配置中预留:
+
+```json
+{
+  "game": {
+    "result": {
+      "enabled": true,
+      "showTelemetry": true,
+      "showCollectedContent": true,
+      "showAwards": false,
+      "template": "default"
+    }
+  }
+}
+```
+
+这意味着:
+
+- 结算是否启用
+- 展示哪些区块
+- 用哪个模板
+
+都可配置。
+
+---
+
+## 8. 与当前架构的关系
+
+### 规则层
+
+负责:
+
+- 产出 `session_finished`
+
+### Telemetry
+
+负责:
+
+- 提供里程、速度、心率、卡路里等数据
+
+### MapEngine
+
+负责:
+
+- 在结束时汇总通用结算模型
+- 把结果快照送到页面层
+
+### 页面层
+
+负责:
+
+- 渲染结算页
+
+---
+
+## 9. 第一阶段最小实施范围
+
+建议第一阶段只做:
+
+1. `session_finished -> ResultScene`
+2. 基础 summary 展示
+3. 顺序赛 / 积分赛的简单差异化字段
+4. 手动关闭 / 返回地图
+
+先不要一上来做:
+
+- 复杂章节动画
+- 排名
+- 分享图生成
+- 复杂奖章系统
+
+---
+
+## 10. 后续扩展方向
+
+这层建好后,可以逐步加:
+
+- 文创奖励
+- 奖章 / 成就
+- 排名
+- 解锁内容
+- 分享卡
+- 二次引导
+
+---
+
+## 11. 推荐实施顺序
+
+1. 定义 `ResultSummaryModel`
+2. 在 `MapEngine` 汇总结束快照
+3. 页面层增加结果面板
+4. 顺序赛 / 积分赛各补一组玩法字段
+5. 再考虑动画、奖励和品牌内容
+
+---
+
+## 12. 与文创体验层的配合
+
+后续建议:
+
+- 文创体验层
+  - 承接“游戏中途”的体验
+- 结算层
+  - 承接“游戏结束后”的体验
+
+二者不要混。
+
+如果后续结算后要解锁文创卡片,可以由:
+
+- `ResultScene`
+  - 显示结算
+- 结算完成后
+  - 再触发内容奖励卡
+
+---
+
+## 13. 结论
+
+当前最合适的方向不是继续在结束时零散堆文案,而是:
+
+**正式增加一层 `ResultScene`,承接顺序赛、积分赛以及未来更多玩法的统一结算体验。**
+
+第一阶段先做基础 summary,后续再逐步接入文创奖励、奖章、排名和过场动画。