Forráskód Böngészése

整理文档并接入 H5 体验测试链路

zhangyan 1 hete
szülő
commit
0e0a724025
55 módosított fájl, 4176 hozzáadás és 54 törlés
  1. 0 0
      MyToDo.md
  2. 0 0
      doc/GeminiAnlysis.md
  3. 4 0
      doc/MyToDo.md
  4. 0 0
      doc/animation-design-proposal.md
  5. 0 0
      doc/animation-dictionary.md
  6. 0 0
      doc/animation-integration-spec.md
  7. 0 0
      doc/animation-integration-workflow.md
  8. 0 0
      doc/animation-pipeline-summary.md
  9. 0 0
      doc/animation-review-checklist.md
  10. 0 0
      doc/backend-config-management-proposal.md
  11. 0 0
      doc/backend-config-management-v2.md
  12. 0 0
      doc/communication-guidelines.md
  13. 0 0
      doc/compass-debugging-notes.md
  14. 415 0
      doc/config-default-template.md
  15. 0 0
      doc/config-design-proposal.md
  16. 163 0
      doc/config-docs-index.md
  17. 556 0
      doc/config-option-dictionary.md
  18. 0 0
      doc/config-template-classic-sequential.md
  19. 0 0
      doc/config-template-score-o.md
  20. 0 0
      doc/content-experience-layer-proposal.md
  21. 0 0
      doc/gameplay-ideas-proposal.md
  22. 384 0
      doc/h5-experience-integration-proposal.md
  23. 421 0
      doc/hybrid-experience-architecture.md
  24. 381 0
      doc/native-h5-bridge-spec.md
  25. 144 0
      doc/platform-capability-notes.md
  26. 0 0
      doc/result-scene-proposal.md
  27. 33 0
      doc/sensor-current-summary.md
  28. 0 0
      doc/temp-gameplay-discussion.md
  29. 0 0
      doc/todo-multi-user-simulator.md
  30. 0 0
      doc/todo-sensor-integration-plan.md
  31. 161 0
      event/classic-sequential.json
  32. 198 0
      event/content-h5-test-template.html
  33. 177 0
      event/score-o.json
  34. 2 0
      miniprogram/app.json
  35. 451 35
      miniprogram/engine/map/mapEngine.ts
  36. 47 0
      miniprogram/game/content/courseToGameDefinition.ts
  37. 22 0
      miniprogram/game/core/gameDefinition.ts
  38. 26 0
      miniprogram/game/experience/h5Experience.ts
  39. 8 5
      miniprogram/game/rules/classicSequentialRule.ts
  40. 8 5
      miniprogram/game/rules/scoreORule.ts
  41. 127 0
      miniprogram/pages/experience-webview/experience-webview.js
  42. 3 0
      miniprogram/pages/experience-webview/experience-webview.json
  43. 136 0
      miniprogram/pages/experience-webview/experience-webview.ts
  44. 11 0
      miniprogram/pages/experience-webview/experience-webview.wxml
  45. 27 0
      miniprogram/pages/experience-webview/experience-webview.wxss
  46. 53 7
      miniprogram/pages/map/map.ts
  47. 5 0
      miniprogram/pages/map/map.wxml
  48. 21 0
      miniprogram/pages/map/map.wxss
  49. 24 0
      miniprogram/pages/webview-test/webview-test.js
  50. 3 0
      miniprogram/pages/webview-test/webview-test.json
  51. 29 0
      miniprogram/pages/webview-test/webview-test.ts
  52. 11 0
      miniprogram/pages/webview-test/webview-test.wxml
  53. 24 0
      miniprogram/pages/webview-test/webview-test.wxss
  54. 65 2
      miniprogram/utils/remoteMapConfig.ts
  55. 36 0
      readme-develop.md

+ 0 - 0
MyToDo.md


+ 0 - 0
GeminiAnlysis.md → doc/GeminiAnlysis.md


+ 4 - 0
doc/MyToDo.md

@@ -0,0 +1,4 @@
+
+结果页会根据客户的要求不停的变换,用什么方案能实现这个需求,其实其他的弹出内容也都存在这个问题,样式,内容都时根据客户需求变化的,怎样一种方案设计比较好呢?
+
+

+ 0 - 0
animation-design-proposal.md → doc/animation-design-proposal.md


+ 0 - 0
animation-dictionary.md → doc/animation-dictionary.md


+ 0 - 0
animation-integration-spec.md → doc/animation-integration-spec.md


+ 0 - 0
animation-integration-workflow.md → doc/animation-integration-workflow.md


+ 0 - 0
animation-pipeline-summary.md → doc/animation-pipeline-summary.md


+ 0 - 0
animation-review-checklist.md → doc/animation-review-checklist.md


+ 0 - 0
backend-config-management-proposal.md → doc/backend-config-management-proposal.md


+ 0 - 0
backend-config-management-v2.md → doc/backend-config-management-v2.md


+ 0 - 0
communication-guidelines.md → doc/communication-guidelines.md


+ 0 - 0
compass-debugging-notes.md → doc/compass-debugging-notes.md


+ 415 - 0
doc/config-default-template.md

@@ -0,0 +1,415 @@
+# 默认配置模板文档(当前实现版)
+
+本文档提供一份 **当前客户端可直接使用的默认配置模板**。  
+目标是:
+
+- 给服务端/后台一个稳定的起步模板
+- 保证即使只填最少字段,也能正常跑起来
+- 随开发持续补充和维护
+
+说明:
+
+- 本模板优先保证“可运行”
+- 高级字段可以逐步补
+- 文创内容和点击内容也已经纳入模板
+
+---
+
+## 1. 顶层默认模板
+
+```json
+{
+  "schemaVersion": "1",
+  "version": "2026.03.27",
+  "app": {
+    "id": "sample-event-001",
+    "title": "示例活动",
+    "locale": "zh-CN"
+  },
+  "map": {
+    "tiles": "../map/lxcb-001/tiles/",
+    "mapmeta": "../map/lxcb-001/tiles/meta.json",
+    "declination": 6.91,
+    "initialView": {
+      "zoom": 17
+    }
+  },
+  "playfield": {
+    "kind": "course",
+    "source": {
+      "type": "kml",
+      "url": "../kml/lxcb-001/10/c01.kml"
+    },
+    "CPRadius": 6,
+    "controlOverrides": {},
+    "metadata": {
+      "title": "默认路线",
+      "code": "default-001"
+    }
+  },
+  "game": {
+    "mode": "classic-sequential",
+    "rulesVersion": "1",
+    "session": {
+      "startManually": true,
+      "requiresStartPunch": true,
+      "requiresFinishPunch": true,
+      "autoFinishOnLastControl": false,
+      "maxDurationSec": 5400
+    },
+    "punch": {
+      "policy": "enter-confirm",
+      "radiusMeters": 5,
+      "requiresFocusSelection": false
+    },
+    "sequence": {
+      "skip": {
+        "enabled": false,
+        "radiusMeters": 30,
+        "requiresConfirm": true
+      }
+    },
+    "scoring": {
+      "type": "score",
+      "defaultControlScore": 10
+    },
+    "guidance": {
+      "showLegs": true,
+      "legAnimation": true,
+      "allowFocusSelection": false
+    },
+    "visibility": {
+      "revealFullPlayfieldAfterStartPunch": true
+    },
+    "finish": {
+      "finishControlAlwaysSelectable": false
+    },
+    "telemetry": {
+      "heartRate": {
+        "age": 30,
+        "restingHeartRateBpm": 62,
+        "userWeightKg": 65
+      }
+    },
+    "feedback": {
+      "audioProfile": "default",
+      "hapticsProfile": "default",
+      "uiEffectsProfile": "default"
+    }
+  },
+  "resources": {
+    "audioProfile": "default",
+    "contentProfile": "default",
+    "themeProfile": "default-race"
+  },
+  "debug": {
+    "allowModeSwitch": false,
+    "allowMockInput": false,
+    "allowSimulator": false
+  }
+}
+```
+
+---
+
+## 2. 顺序赛推荐默认模板
+
+```json
+{
+  "schemaVersion": "1",
+  "version": "2026.03.27",
+  "app": {
+    "id": "sample-classic-001",
+    "title": "顺序赛示例",
+    "locale": "zh-CN"
+  },
+  "map": {
+    "tiles": "../map/lxcb-001/tiles/",
+    "mapmeta": "../map/lxcb-001/tiles/meta.json",
+    "declination": 6.91,
+    "initialView": {
+      "zoom": 17
+    }
+  },
+  "playfield": {
+    "kind": "course",
+    "source": {
+      "type": "kml",
+      "url": "../kml/lxcb-001/10/c01.kml"
+    },
+    "CPRadius": 6,
+    "controlOverrides": {
+      "start-1": {
+        "title": "比赛开始",
+        "body": "从这里出发,先熟悉地图方向。",
+        "autoPopup": true,
+        "once": true,
+        "priority": 1,
+        "clickTitle": "起点说明",
+        "clickBody": "点击起点可再次查看起跑说明。"
+      },
+      "control-1": {
+        "title": "第一检查点",
+        "body": "完成这个点后沿主路继续前进。",
+        "autoPopup": true,
+        "once": false,
+        "priority": 1,
+        "clickTitle": "第一检查点",
+        "clickBody": "点击查看该点位的补充说明。"
+      },
+      "finish-1": {
+        "title": "比赛结束",
+        "body": "恭喜完成本次路线。",
+        "autoPopup": true,
+        "once": true,
+        "priority": 2,
+        "clickTitle": "终点说明",
+        "clickBody": "点击终点可再次查看结束说明。"
+      }
+    },
+    "metadata": {
+      "title": "顺序赛路线示例",
+      "code": "classic-001"
+    }
+  },
+  "game": {
+    "mode": "classic-sequential",
+    "rulesVersion": "1",
+    "session": {
+      "startManually": true,
+      "requiresStartPunch": true,
+      "requiresFinishPunch": true,
+      "autoFinishOnLastControl": false,
+      "maxDurationSec": 5400
+    },
+    "punch": {
+      "policy": "enter-confirm",
+      "radiusMeters": 5,
+      "requiresFocusSelection": false
+    },
+    "sequence": {
+      "skip": {
+        "enabled": false,
+        "radiusMeters": 30,
+        "requiresConfirm": true
+      }
+    },
+    "guidance": {
+      "showLegs": true,
+      "legAnimation": true,
+      "allowFocusSelection": false
+    },
+    "visibility": {
+      "revealFullPlayfieldAfterStartPunch": true
+    },
+    "finish": {
+      "finishControlAlwaysSelectable": false
+    },
+    "telemetry": {
+      "heartRate": {
+        "age": 30,
+        "restingHeartRateBpm": 62,
+        "userWeightKg": 65
+      }
+    },
+    "feedback": {
+      "audioProfile": "default",
+      "hapticsProfile": "default",
+      "uiEffectsProfile": "default"
+    }
+  },
+  "resources": {
+    "audioProfile": "default",
+    "contentProfile": "default",
+    "themeProfile": "default-race"
+  },
+  "debug": {
+    "allowModeSwitch": false,
+    "allowMockInput": false,
+    "allowSimulator": false
+  }
+}
+```
+
+---
+
+## 3. 积分赛推荐默认模板
+
+```json
+{
+  "schemaVersion": "1",
+  "version": "2026.03.27",
+  "app": {
+    "id": "sample-score-o-001",
+    "title": "积分赛示例",
+    "locale": "zh-CN"
+  },
+  "map": {
+    "tiles": "../map/lxcb-001/tiles/",
+    "mapmeta": "../map/lxcb-001/tiles/meta.json",
+    "declination": 6.91,
+    "initialView": {
+      "zoom": 17
+    }
+  },
+  "playfield": {
+    "kind": "control-set",
+    "source": {
+      "type": "kml",
+      "url": "../kml/lxcb-001/10/c01.kml"
+    },
+    "CPRadius": 6,
+    "controlOverrides": {
+      "start-1": {
+        "title": "比赛开始",
+        "body": "从这里触发,先熟悉地图方向。",
+        "autoPopup": true,
+        "once": true,
+        "priority": 1,
+        "clickTitle": "积分赛起点",
+        "clickBody": "点击起点可查看自由打点规则。"
+      },
+      "control-1": {
+        "score": 10,
+        "clickTitle": "1号点",
+        "clickBody": "这是一个基础积分点。"
+      },
+      "control-2": {
+        "score": 20,
+        "autoPopup": false,
+        "once": true,
+        "priority": 1,
+        "clickTitle": "2号点",
+        "clickBody": "这个点配置成点击查看。"
+      },
+      "finish-1": {
+        "title": "比赛结束",
+        "body": "恭喜完成本次路线。",
+        "autoPopup": true,
+        "once": true,
+        "priority": 2,
+        "clickTitle": "终点说明",
+        "clickBody": "点击终点可再次查看结束说明。"
+      }
+    },
+    "metadata": {
+      "title": "积分赛控制点示例",
+      "code": "score-o-001"
+    }
+  },
+  "game": {
+    "mode": "score-o",
+    "rulesVersion": "1",
+    "session": {
+      "startManually": true,
+      "requiresStartPunch": true,
+      "requiresFinishPunch": false,
+      "autoFinishOnLastControl": false,
+      "maxDurationSec": 5400
+    },
+    "punch": {
+      "policy": "enter-confirm",
+      "radiusMeters": 5,
+      "requiresFocusSelection": false
+    },
+    "scoring": {
+      "type": "score",
+      "defaultControlScore": 10
+    },
+    "guidance": {
+      "showLegs": false,
+      "legAnimation": false,
+      "allowFocusSelection": true
+    },
+    "visibility": {
+      "revealFullPlayfieldAfterStartPunch": true
+    },
+    "finish": {
+      "finishControlAlwaysSelectable": true
+    },
+    "telemetry": {
+      "heartRate": {
+        "age": 30,
+        "restingHeartRateBpm": 62,
+        "userWeightKg": 65
+      }
+    },
+    "feedback": {
+      "audioProfile": "default",
+      "hapticsProfile": "default",
+      "uiEffectsProfile": "default"
+    }
+  },
+  "resources": {
+    "audioProfile": "default",
+    "contentProfile": "default",
+    "themeProfile": "default-race"
+  },
+  "debug": {
+    "allowModeSwitch": false,
+    "allowMockInput": false,
+    "allowSimulator": false
+  }
+}
+```
+
+---
+
+## 4. 默认逻辑说明
+
+### 4.1 内容展示默认逻辑
+
+- `title/body`
+  - 未配置时使用系统默认文案
+- `clickTitle/clickBody`
+  - 未配置时回退到 `title/body`
+- `autoPopup`
+  - 默认允许自动弹出
+- `once`
+  - 默认 `false`
+- `priority`
+  - 普通点默认 `1`
+  - 终点默认 `2`
+- 自动打点时:
+  - 自动打点完成后不自动弹内容
+  - 点击内容仍可用
+
+### 4.2 玩法默认逻辑
+
+- 顺序赛默认:
+  - 必须起点
+  - 必须终点
+  - 不自动结束
+  - 跳点默认关闭
+- 积分赛默认:
+  - 必须起点
+  - 终点可选
+  - 不自动结束
+  - 默认分值 `10`
+
+### 4.3 资源默认逻辑
+
+- `audioProfile = default`
+- `contentProfile = default`
+- `themeProfile = default-race`
+
+---
+
+## 5. 建议维护方式
+
+后续每次配置能力扩展时,建议同步维护:
+
+1. [D:\dev\cmr-mini\config-option-dictionary.md](D:/dev/cmr-mini/doc/doc/config-option-dictionary.md)
+2. [D:\dev\cmr-mini\config-default-template.md](D:/dev/cmr-mini/doc/doc/config-default-template.md)
+3. [D:\dev\cmr-mini\event\classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
+4. [D:\dev\cmr-mini\event\score-o.json](D:/dev/cmr-mini/event/score-o.json)
+
+这样可以保证:
+
+- 客户端实现
+- 服务端配置
+- 后台录入
+- 联调样例
+
+始终保持一致。
+

+ 0 - 0
config-design-proposal.md → doc/config-design-proposal.md


+ 163 - 0
doc/config-docs-index.md

@@ -0,0 +1,163 @@
+# 配置文档索引
+
+本文档用于汇总当前项目所有与**配置设计、配置样例、配置管理**相关的文档,作为统一入口。
+
+适用对象:
+
+- 客户端开发
+- 服务端开发
+- 后台管理设计
+- 配置录入与联调
+
+---
+
+## 1. 配置设计总方案
+
+### [config-design-proposal.md](D:/dev/cmr-mini/doc/config-design-proposal.md)
+
+作用:
+
+- 说明为什么配置要按 `app / map / playfield / game / resources / debug` 分层
+- 说明 `KML` 和配置的职责边界
+- 说明为什么上位概念用 `playfield`
+- 适合做总体架构参考
+
+适合阅读时机:
+
+- 设计配置结构
+- 设计客户端读取链
+- 和后端讨论顶层模型时
+
+---
+
+## 2. 配置选项字典
+
+### [config-option-dictionary.md](D:/dev/cmr-mini/doc/config-option-dictionary.md)
+
+作用:
+
+- 列出当前客户端已经支持或已预留的配置项
+- 说明每个字段的类型、含义、默认逻辑
+- 作为后续新增字段时的持续维护文档
+
+适合阅读时机:
+
+- 想知道某个字段是否已实现
+- 想知道字段应该怎么写
+- 想确认默认行为时
+
+---
+
+## 3. 默认配置模板
+
+### [config-default-template.md](D:/dev/cmr-mini/doc/config-default-template.md)
+
+作用:
+
+- 提供当前推荐的默认配置模板
+- 包含顺序赛和积分赛的基础默认示例
+- 用于服务端、后台、联调时直接起步
+
+适合阅读时机:
+
+- 新建一份活动配置
+- 想直接照着填配置
+- 想知道最小可运行模板长什么样
+
+---
+
+## 4. 按玩法拆分的配置模板文档
+
+### [config-template-classic-sequential.md](D:/dev/cmr-mini/doc/config-template-classic-sequential.md)
+
+作用:
+
+- 解释顺序赛配置结构
+- 说明顺序赛的必填字段和默认值
+- 适合给后端和后台做顺序赛专项参考
+
+### [config-template-score-o.md](D:/dev/cmr-mini/doc/config-template-score-o.md)
+
+作用:
+
+- 解释积分赛配置结构
+- 说明积分赛的必填字段和默认值
+- 适合给后端和后台做积分赛专项参考
+
+---
+
+## 5. 运行中的样例配置
+
+### [event/classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
+
+作用:
+
+- 当前顺序赛样例配置
+- 可直接联调
+- 已包含控制点内容覆盖示例
+
+### [event/score-o.json](D:/dev/cmr-mini/event/score-o.json)
+
+作用:
+
+- 当前积分赛样例配置
+- 可直接联调
+- 已包含分值、起终点内容、点击内容示例
+
+---
+
+## 6. 后台与服务端配置管理方案
+
+### [backend-config-management-proposal.md](D:/dev/cmr-mini/doc/backend-config-management-proposal.md)
+
+作用:
+
+- 第一版后台配置管理建议
+- 适合了解 `Map / Playfield / GameMode / ResourcePack / Event` 这套核心对象
+
+### [backend-config-management-v2.md](D:/dev/cmr-mini/doc/backend-config-management-v2.md)
+
+作用:
+
+- 在“配置项变化频繁”前提下重写的后台方案
+- 更强调:
+  - 稳定骨架
+  - `jsonb`
+  - 版本
+  - 发布
+  - 透传未知字段
+
+推荐优先看这一份。
+
+---
+
+## 7. 推荐阅读顺序
+
+如果你是第一次接触这套配置体系,建议按这个顺序看:
+
+1. [config-design-proposal.md](D:/dev/cmr-mini/doc/config-design-proposal.md)
+2. [config-option-dictionary.md](D:/dev/cmr-mini/doc/config-option-dictionary.md)
+3. [config-default-template.md](D:/dev/cmr-mini/doc/config-default-template.md)
+4. [event/classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
+5. [event/score-o.json](D:/dev/cmr-mini/event/score-o.json)
+6. [backend-config-management-v2.md](D:/dev/cmr-mini/doc/backend-config-management-v2.md)
+
+---
+
+## 8. 维护约定
+
+后续每次新增配置能力时,建议至少同步更新这几处:
+
+1. [config-option-dictionary.md](D:/dev/cmr-mini/doc/config-option-dictionary.md)
+2. [config-default-template.md](D:/dev/cmr-mini/doc/config-default-template.md)
+3. 对应玩法的 `event/*.json` 样例
+4. 如果涉及顶层结构变化,再更新 [config-design-proposal.md](D:/dev/cmr-mini/doc/config-design-proposal.md)
+
+这样可以保证:
+
+- 文档
+- 样例
+- 代码
+- 后台录入
+
+保持一致。

+ 556 - 0
doc/config-option-dictionary.md

@@ -0,0 +1,556 @@
+# 配置选项字典(当前实现版)
+
+本文档用于整理 **当前客户端已经消费或已经预留承载的配置项**,作为事件配置、后台配置和联调时的统一参考。
+
+目标:
+
+- 明确目前哪些字段已经真正生效
+- 明确每个字段的含义、类型、默认逻辑
+- 给后续扩展留下统一维护入口
+
+说明:
+
+- 本文档优先以“当前代码真实实现”为准
+- 未列出的字段,不代表未来不能加,只代表当前客户端未正式消费
+- 后续每次新增配置能力,都应同步补充本文件
+
+---
+
+## 1. 顶层结构
+
+当前推荐结构:
+
+```json
+{
+  "schemaVersion": "1",
+  "version": "2026.03.27",
+  "app": {},
+  "map": {},
+  "playfield": {},
+  "game": {},
+  "resources": {},
+  "debug": {}
+}
+```
+
+---
+
+## 2. 顶层字段字典
+
+### `schemaVersion`
+
+- 类型:`string`
+- 说明:配置结构版本
+- 建议默认值:`"1"`
+
+### `version`
+
+- 类型:`string`
+- 说明:当前配置内容版本
+- 建议默认值:日期或发布版本号,例如 `2026.03.27`
+
+### `app`
+
+- 类型:`object`
+- 说明:活动级基础信息
+
+### `map`
+
+- 类型:`object`
+- 说明:地图底座信息
+
+### `playfield`
+
+- 类型:`object`
+- 说明:玩法空间对象与内容覆盖
+
+### `game`
+
+- 类型:`object`
+- 说明:玩法规则与局流程
+
+### `resources`
+
+- 类型:`object`
+- 说明:资源 profile 引用
+
+### `debug`
+
+- 类型:`object`
+- 说明:调试开关
+
+---
+
+## 3. `app` 字段
+
+### `app.id`
+
+- 类型:`string`
+- 说明:活动或配置实例 id
+- 示例:`"sample-classic-001"`
+
+### `app.title`
+
+- 类型:`string`
+- 说明:活动标题 / 比赛名称
+- 示例:`"顺序赛示例"`
+
+### `app.locale`
+
+- 类型:`string`
+- 说明:语言环境
+- 建议默认值:`"zh-CN"`
+
+---
+
+## 4. `map` 字段
+
+### `map.tiles`
+
+- 类型:`string`
+- 说明:瓦片根路径
+- 必填:是
+
+### `map.mapmeta`
+
+- 类型:`string`
+- 说明:地图 meta 文件地址
+- 必填:是
+
+### `map.declination`
+
+- 类型:`number`
+- 说明:磁偏角
+- 示例:`6.91`
+- 备注:当前会影响真北/磁北换算
+
+### `map.initialView.zoom`
+
+- 类型:`number`
+- 说明:初始缩放级别
+- 建议默认值:`17`
+
+---
+
+## 5. `playfield` 字段
+
+### `playfield.kind`
+
+- 类型:`string`
+- 说明:空间对象类型
+- 当前推荐值:
+  - `course`
+  - `control-set`
+
+### `playfield.source.type`
+
+- 类型:`string`
+- 说明:空间底稿来源类型
+- 当前推荐值:`kml`
+
+### `playfield.source.url`
+
+- 类型:`string`
+- 说明:KML 地址
+- 必填:是
+
+### `playfield.CPRadius`
+
+- 类型:`number`
+- 说明:检查点绘制半径
+- 建议默认值:`6`
+
+### `playfield.metadata.title`
+
+- 类型:`string`
+- 说明:路线或控制点集标题
+
+### `playfield.metadata.code`
+
+- 类型:`string`
+- 说明:路线或控制点集编码
+
+---
+
+## 6. `playfield.controlOverrides`
+
+`playfield.controlOverrides` 用于对起点、检查点、终点做内容或分值覆盖。
+
+### 6.1 key 命名规则
+
+- 起点:`start-1`
+- 普通检查点:`control-1`、`control-2`、`control-3`
+- 终点:`finish-1`
+
+### 6.2 当前支持字段
+
+#### `score`
+
+- 类型:`number`
+- 说明:积分赛控制点分值
+- 适用:积分赛
+
+#### `title`
+
+- 类型:`string`
+- 说明:打点完成后自动弹出的标题
+
+#### `body`
+
+- 类型:`string`
+- 说明:打点完成后自动弹出的正文
+
+#### `clickTitle`
+
+- 类型:`string`
+- 说明:点击控制点时弹出的标题
+- 默认逻辑:未配置时回退到 `title`
+
+#### `clickBody`
+
+- 类型:`string`
+- 说明:点击控制点时弹出的正文
+- 默认逻辑:未配置时回退到 `body`
+
+#### `autoPopup`
+
+- 类型:`boolean`
+- 说明:完成该点后是否自动弹出内容
+- 建议默认值:`true`
+- 特殊逻辑:如果当前玩法是自动打点,即 `game.punch.policy = "enter"`,则无论这里如何配置,**都不自动弹出**
+
+#### `once`
+
+- 类型:`boolean`
+- 说明:该内容是否本局只自动展示一次
+- 建议默认值:`false`
+
+#### `priority`
+
+- 类型:`number`
+- 说明:内容优先级,越大越高
+- 建议默认值:
+  - 普通点:`1`
+  - 终点:`2`
+
+### 6.3 示例
+
+```json
+"controlOverrides": {
+  "start-1": {
+    "title": "比赛开始",
+    "body": "从这里出发,先熟悉地图方向。",
+    "autoPopup": true,
+    "once": true,
+    "priority": 1,
+    "clickTitle": "起点说明",
+    "clickBody": "点击起点可再次查看起跑说明。"
+  },
+  "control-2": {
+    "score": 20,
+    "title": "教学楼南侧",
+    "body": "这里是重要转折点。",
+    "autoPopup": false,
+    "once": true,
+    "priority": 1,
+    "clickTitle": "教学楼南侧",
+    "clickBody": "这个点配置成点击查看。"
+  },
+  "finish-1": {
+    "title": "比赛结束",
+    "body": "恭喜完成本次路线。",
+    "autoPopup": true,
+    "once": true,
+    "priority": 2,
+    "clickTitle": "终点说明",
+    "clickBody": "点击终点可再次查看结束说明。"
+  }
+}
+```
+
+---
+
+## 7. `game` 字段
+
+### `game.mode`
+
+- 类型:`string`
+- 说明:玩法类型
+- 当前支持:
+  - `classic-sequential`
+  - `score-o`
+
+### `game.rulesVersion`
+
+- 类型:`string`
+- 说明:规则版本
+- 建议默认值:`"1"`
+
+---
+
+## 8. `game.session`
+
+### `game.session.startManually`
+
+- 类型:`boolean`
+- 说明:是否需要手动点击开始
+- 建议默认值:`true`
+
+### `game.session.requiresStartPunch`
+
+- 类型:`boolean`
+- 说明:是否必须完成起点打卡
+- 建议默认值:
+  - 顺序赛:`true`
+  - 积分赛:`true`
+
+### `game.session.requiresFinishPunch`
+
+- 类型:`boolean`
+- 说明:是否必须完成终点打卡
+- 建议默认值:
+  - 顺序赛:`true`
+  - 积分赛:`false`
+
+### `game.session.autoFinishOnLastControl`
+
+- 类型:`boolean`
+- 说明:是否打完最后控制点自动结束
+- 建议默认值:`false`
+
+### `game.session.maxDurationSec`
+
+- 类型:`number`
+- 说明:最大比赛时长,单位秒
+- 建议默认值:`5400`
+
+---
+
+## 9. `game.punch`
+
+### `game.punch.policy`
+
+- 类型:`string`
+- 说明:打点策略
+- 当前支持:
+  - `enter-confirm`
+  - `enter`
+- 建议默认值:`enter-confirm`
+
+### `game.punch.radiusMeters`
+
+- 类型:`number`
+- 说明:打点半径
+- 建议默认值:`5`
+
+### `game.punch.requiresFocusSelection`
+
+- 类型:`boolean`
+- 说明:积分赛是否需要先选中目标再打卡
+- 建议默认值:
+  - 顺序赛:`false`
+  - 积分赛:`false`
+
+---
+
+## 10. `game.sequence.skip`
+
+仅顺序赛相关。
+
+### `game.sequence.skip.enabled`
+
+- 类型:`boolean`
+- 说明:是否允许跳点
+- 建议默认值:`false`
+
+### `game.sequence.skip.radiusMeters`
+
+- 类型:`number`
+- 说明:跳点半径
+- 建议默认值:`30`
+
+### `game.sequence.skip.requiresConfirm`
+
+- 类型:`boolean`
+- 说明:跳点是否需要确认
+- 建议默认值:`true`
+
+---
+
+## 11. `game.scoring`
+
+### `game.scoring.type`
+
+- 类型:`string`
+- 说明:积分模型
+- 当前推荐值:`score`
+
+### `game.scoring.defaultControlScore`
+
+- 类型:`number`
+- 说明:积分赛默认控制点分值
+- 建议默认值:`10`
+
+---
+
+## 12. `game.guidance`
+
+### `game.guidance.showLegs`
+
+- 类型:`boolean`
+- 说明:是否显示腿线
+- 建议默认值:
+  - 顺序赛:`true`
+  - 积分赛:`false`
+
+### `game.guidance.legAnimation`
+
+- 类型:`boolean`
+- 说明:是否显示腿线动画
+- 建议默认值:
+  - 顺序赛:`true`
+  - 积分赛:`false`
+
+### `game.guidance.allowFocusSelection`
+
+- 类型:`boolean`
+- 说明:是否允许地图点击选择目标点
+- 建议默认值:
+  - 顺序赛:`false`
+  - 积分赛:`true`
+
+---
+
+## 13. `game.visibility`
+
+### `game.visibility.revealFullPlayfieldAfterStartPunch`
+
+- 类型:`boolean`
+- 说明:起点打卡后是否显示完整路线/控制点集合
+- 建议默认值:`true`
+
+---
+
+## 14. `game.finish`
+
+### `game.finish.finishControlAlwaysSelectable`
+
+- 类型:`boolean`
+- 说明:终点是否始终可选
+- 建议默认值:
+  - 顺序赛:`false`
+  - 积分赛:`true`
+
+---
+
+## 15. `game.telemetry.heartRate`
+
+### `age`
+
+- 类型:`number`
+- 说明:年龄
+- 建议默认值:`30`
+
+### `restingHeartRateBpm`
+
+- 类型:`number`
+- 说明:静息心率
+- 建议默认值:`62`
+
+### `userWeightKg`
+
+- 类型:`number`
+- 说明:体重
+- 建议默认值:`65`
+
+---
+
+## 16. `game.feedback`
+
+### `game.feedback.audioProfile`
+
+- 类型:`string`
+- 说明:音频反馈 profile
+- 建议默认值:`default`
+
+### `game.feedback.hapticsProfile`
+
+- 类型:`string`
+- 说明:震动反馈 profile
+- 建议默认值:`default`
+
+### `game.feedback.uiEffectsProfile`
+
+- 类型:`string`
+- 说明:UI 动效 profile
+- 建议默认值:`default`
+
+---
+
+## 17. `resources`
+
+### `resources.audioProfile`
+
+- 类型:`string`
+- 建议默认值:`default`
+
+### `resources.contentProfile`
+
+- 类型:`string`
+- 建议默认值:`default`
+
+### `resources.themeProfile`
+
+- 类型:`string`
+- 建议默认值:`default-race`
+
+---
+
+## 18. `debug`
+
+### `debug.allowModeSwitch`
+
+- 类型:`boolean`
+- 建议默认值:`false`
+
+### `debug.allowMockInput`
+
+- 类型:`boolean`
+- 建议默认值:`false`
+
+### `debug.allowSimulator`
+
+- 类型:`boolean`
+- 建议默认值:`false`
+
+---
+
+## 19. 当前默认逻辑说明
+
+当前客户端对配置的处理原则是:
+
+- 能有默认值的尽量给默认值
+- 控制点内容类字段缺失时走默认文案
+- `clickTitle/clickBody` 缺失时回退到 `title/body`
+- 自动打点模式下不自动弹内容
+- 内容优先级未配置时使用普通点 `1`、终点 `2`
+
+也就是说:
+
+**大部分配置项都不是强制必填,先保证主骨架完整即可。**
+
+---
+
+## 20. 维护约定
+
+后续每次新增配置项时,应同步更新:
+
+1. 本文档
+2. 默认模板文档
+3. `event` 目录下的配置样例
+
+这样可以保证:
+
+- 服务端可对照
+- 后台可录入
+- 客户端联调时有统一参考

+ 0 - 0
config-template-classic-sequential.md → doc/config-template-classic-sequential.md


+ 0 - 0
config-template-score-o.md → doc/config-template-score-o.md


+ 0 - 0
content-experience-layer-proposal.md → doc/content-experience-layer-proposal.md


+ 0 - 0
gameplay-ideas-proposal.md → doc/gameplay-ideas-proposal.md


+ 384 - 0
doc/h5-experience-integration-proposal.md

@@ -0,0 +1,384 @@
+# H5 体验接入方案
+
+本文档用于定义当前项目中 **原生小程序 + H5 定制内容** 的混合接入方案。
+
+目标:
+
+- 保留原生地图与实时游戏主流程
+- 把高频变化、强定制的内容页交给 H5
+- 保证 H5 失败时,原生仍可完整兜底
+- 为后续客户定制、品牌包装、互动任务扩展留出稳定接口
+
+---
+
+## 1. 结论
+
+当前最合适的方向不是“所有定制都 H5 化”,而是:
+
+**原生负责核心游戏层,H5 负责定制体验层。**
+
+也就是:
+
+- 地图、打点、GPS、指北针、HUD、规则状态机继续原生
+- 结算页、文创详情、拍照任务、语音留言、小游戏、品牌包装页交给 H5
+
+---
+
+## 2. 适合 H5 化的内容
+
+当前最适合 H5 承接的是:
+
+- 结算页
+- 打点后的定制内容页
+- 文创详情页
+- 活动品牌页
+- 富图文任务页
+- 拍照上传 / 语音留言 / 小游戏类互动页
+- 表单、问卷、抽奖、作品提交页
+
+不建议 H5 化的部分:
+
+- 地图主界面
+- 打点逻辑
+- 自动转图
+- 指北针
+- HUD
+- GPS / 心率等实时能力主链
+- 需要强实时状态同步的高频游戏弹层
+
+一句话:
+
+**核心实时游戏层保留原生,变化快的定制内容层交给 H5。**
+
+---
+
+## 3. 总体架构
+
+推荐分成三层:
+
+### 3.1 原生层
+
+职责:
+
+- 地图与渲染
+- GPS / 指北针 / 自动转图
+- 打点状态机
+- HUD
+- 心率 / telemetry
+- 原生内容卡兜底
+- 原生结果页兜底
+- 核心状态与本地缓存
+
+### 3.2 H5 体验层
+
+职责:
+
+- 定制内容展示
+- 品牌包装
+- 富交互任务
+- 定制结算页
+- 富图文与媒体内容
+
+### 3.3 Bridge 层
+
+职责:
+
+- 原生向 H5 注入上下文
+- H5 向原生请求能力
+- H5 把结果回传原生
+
+---
+
+## 4. 两种 H5 页面类型
+
+### 4.1 Content Experience Page
+
+用于游戏中途的内容体验页。
+
+典型场景:
+
+- 控制点打卡后弹文创详情
+- 控制点点击后查看图文内容
+- 拍照上传任务
+- 语音留言任务
+- 小游戏互动页
+- 问答/表单类互动页
+
+### 4.2 Result Experience Page
+
+用于游戏结束后的定制结算页。
+
+典型场景:
+
+- 活动定制结算
+- 奖章 / 解锁内容
+- 排名 / 分享
+- 作品提交
+- 品牌化结束页
+
+---
+
+## 5. 原生兜底原则
+
+这是最重要的约束。
+
+### 原则 1:核心流程先在原生完成
+
+例如:
+
+- 打点成功必须先由原生确认
+- 比赛结束必须先由原生确认
+- H5 只是附加体验,不拥有核心状态
+
+### 原则 2:H5 打不开时回退原生
+
+如果:
+
+- 网络失败
+- H5 地址失效
+- 加载超时
+- Bridge 初始化失败
+
+则直接回退:
+
+- 原生内容卡
+- 原生结果页
+
+### 原则 3:H5 不控制比赛状态
+
+H5 可以展示、收集信息、提交任务结果。
+但不能决定:
+
+- 是否打卡成功
+- 是否比赛完成
+- 是否跳点成功
+
+这些只能由原生控制。
+
+### 原则 4:H5 是可选增强,不是主流程依赖
+
+即使 H5 没有打开:
+
+- 游戏仍应可继续
+- 用户仍能完成路线
+- 用户仍能看到最小内容或最小结果
+
+---
+
+## 6. 配置模型建议
+
+后续建议对“内容体验”和“结果体验”都支持两种类型:
+
+- `native`
+- `h5`
+
+### 6.1 内容体验配置示例
+
+```json
+{
+  "contentExperience": {
+    "type": "h5",
+    "url": "https://example.com/content/control-3",
+    "bridge": "content-v1",
+    "fallback": "native"
+  }
+}
+```
+
+或:
+
+```json
+{
+  "contentExperience": {
+    "type": "native"
+  }
+}
+```
+
+### 6.2 结果页配置示例
+
+```json
+{
+  "resultExperience": {
+    "type": "h5",
+    "url": "https://example.com/result/score-o",
+    "bridge": "result-v1",
+    "fallback": "native"
+  }
+}
+```
+
+### 6.3 建议扩展字段
+
+后续还可以逐步加入:
+
+- `template`
+- `theme`
+- `timeoutMs`
+- `allowClose`
+- `prefetch`
+- `requiresNetwork`
+
+---
+
+## 7. 内容页与结果页的推荐职责
+
+### 原生最小内容卡
+
+负责:
+
+- 最小图文说明
+- 最小点击查看
+- 自动弹出兜底
+
+### H5 内容页
+
+负责:
+
+- 强样式定制
+- 多媒体内容
+- 任务型互动
+- 客户活动包装
+
+### 原生最小结果页
+
+负责:
+
+- 结果一定可见
+- 成绩一定可回顾
+- 无网络也能展示基础结果
+
+### H5 结果页
+
+负责:
+
+- 品牌化包装
+- 排名/分享
+- 作品提交
+- 奖章、解锁、收集册
+
+---
+
+## 8. 性能与体验要求
+
+H5 接入时必须注意:
+
+- 不阻塞原生主流程
+- 不把高频实时状态强行桥接到 H5
+- 不在地图进行中频繁开重页面
+- 低端机上优先简化交互和媒体资源
+
+推荐策略:
+
+- 内容详情页可以 H5
+- 地图中高频反馈继续原生
+- 结算增强页可以 H5
+- 结果最小摘要必须原生兜底
+
+---
+
+## 9. 当前建议实施顺序
+
+### 第一步
+
+先实现:
+
+- 原生最小兜底内容卡
+- 原生最小结果页
+
+### 第二步
+
+新增一个通用 H5 容器页,用于承接:
+
+- 内容页
+- 结果页
+
+### 第三步
+
+定义 Bridge 协议,并先支持最核心动作:
+
+- 关闭
+- 获取上下文
+- 拍照
+- 录音
+- 提交结果
+
+### 第四步
+
+再让配置决定:
+
+- 当前活动走原生
+- 还是走 H5
+
+### 第五步
+
+最后再逐步扩到:
+
+- 上传能力
+- 分享能力
+- 小游戏任务
+- 作品提交
+
+---
+
+## 10. 下一步建议
+
+当前最适合的下一步不是直接写复杂 H5 页面,而是:
+
+1. 先定义原生与 H5 的统一入口模型
+2. 先把 Bridge 协议做小而稳
+3. 先做一个通用 H5 容器页
+4. 先让一个简单内容页或一个简单结果页跑通
+
+---
+
+## 11. 当前建议结论
+
+最稳的方案不是“把定制内容都做成 H5”,而是:
+
+**原生保底,H5 承接定制体验。**
+
+这样既能支持客户高频变化需求,也不会破坏核心游戏体验。
+
+---
+
+## 12. 当前主体能力约束补充
+
+最近实际排查已经确认:
+
+- 当前最初使用的是**个人主体**小程序
+
+在这个前提下,`web-view` 能力可能直接受到限制。  
+这意味着:
+
+- H5 页面本身可在浏览器打开
+- 小程序里仍然可能无法通过 `web-view` 打开
+
+因此当前 H5 接入方案需要增加一个现实前提:
+
+### 12.1 个人主体下
+
+可以先做:
+
+- 容器页
+- Bridge 协议
+- 配置结构
+- 原生兜底逻辑
+
+但不要指望所有 H5 内容页都能在当前环境稳定跑通。
+
+### 12.2 企业主体下
+
+企业主体审核通过后,再优先回归:
+
+- 最小 `web-view` 测试页
+- 内容体验页 H5
+- 结果页 H5
+
+也就是说:
+
+当前 H5 方案仍然成立,但在企业主体生效前,应按“预留 + 待验证”看待。
+
+详细说明见:
+
+- [platform-capability-notes.md](D:/dev/cmr-mini/doc/platform-capability-notes.md)

+ 421 - 0
doc/hybrid-experience-architecture.md

@@ -0,0 +1,421 @@
+# 混合体验架构方案
+
+本文档用于说明当前项目在 **结果页、文创内容页、客户定制体验页** 上的长期承载方案。
+
+核心结论:
+
+**不做“原生还是 H5 二选一”,而是采用三层混合方案:**
+
+- 原生模板
+- 原生有限 DSL
+- H5 扩展页
+
+---
+
+## 1. 为什么需要混合方案
+
+当前项目已经确认:
+
+- 结果页经常会变
+- 打点弹出内容经常会变
+- 样式、内容、交互形式会随着客户需求变化
+- 有些内容还会带动作:
+  - 拍照上传
+  - 语音留言
+  - 小游戏
+  - 表单与任务
+
+如果全部原生写死:
+
+- 改版成本高
+- 每次都要改页面代码
+- 客户变化一多就会拖慢开发
+
+如果全部交给 H5:
+
+- 核心体验不稳
+- 地图主流程会割裂
+- 性能和权限整合更麻烦
+
+因此最适合的方案是:
+
+**核心高频体验保留原生,灵活变化部分分层处理。**
+
+---
+
+## 2. 总体结构
+
+建议未来的体验承载层分成三层:
+
+### 2.1 原生模板层
+
+用于承接:
+
+- 最小结果页
+- 最小内容卡
+- 高频、必须稳的体验页
+
+特点:
+
+- 结构固定
+- 数据可变
+- 主题可换
+- 性能最好
+- 原生能力最完整
+
+### 2.2 原生有限 DSL 层
+
+用于承接:
+
+- 结构变化较多,但组件种类有限的页面
+- 结果页区块组合
+- 内容页区块组合
+
+特点:
+
+- 不是万能布局引擎
+- 只支持有限区块和有限顺序配置
+- 比固定模板灵活
+- 比 H5 更稳
+
+### 2.3 H5 扩展层
+
+用于承接:
+
+- 强定制内容
+- 富图文内容
+- 品牌化包装
+- 富交互任务页
+- 定制结算页
+
+特点:
+
+- 自由度最高
+- 改版速度最快
+- 最适合客户高频定制
+- 但必须有原生兜底
+
+---
+
+## 3. 三层分别适合什么
+
+## 3.1 原生模板适合
+
+- 高频结果页
+- 高频内容卡
+- 游戏过程中的即时反馈页
+- 必须流畅、必须可离线的页面
+
+示例:
+
+- 最小顺序赛结算页
+- 最小积分赛结算页
+- 控制点原生内容卡
+- 默认结束页
+
+## 3.2 原生有限 DSL 适合
+
+- 区块顺序常变
+- 字段组合常变
+- 样式变化不至于重做到 H5
+
+示例:
+
+- 结果页:
+  - 先显示成绩还是先显示摘要
+  - 哪些统计项出现
+  - 操作区放几个按钮
+- 内容页:
+  - 是否显示图片
+  - 是否显示引用说明
+  - 是否显示附加说明区块
+
+## 3.3 H5 适合
+
+- 品牌化结算页
+- 长图文故事页
+- 拍照上传任务
+- 语音留言页
+- 小游戏互动页
+- 活动专题页
+- 高自由度客户定制页
+
+---
+
+## 4. 边界原则
+
+## 4.1 原生负责核心游戏体验
+
+原生必须继续负责:
+
+- 地图
+- GPS / 指北针 / 自动转图
+- 打点逻辑
+- HUD
+- 核心状态机
+- 最小结果页
+- 最小内容页
+
+## 4.2 H5 只负责增强体验
+
+H5 不应该负责:
+
+- 打点是否成功
+- 比赛是否结束
+- 核心状态推进
+- 实时地图交互
+
+H5 只负责:
+
+- 内容展示
+- 任务互动
+- 品牌包装
+- 富交互增强体验
+
+## 4.3 原生永远保底
+
+无论 H5 是否接入,原生都必须保证:
+
+- 打点后至少能看到原生内容
+- 结束后至少能看到原生结果页
+- H5 打不开时,主流程不受影响
+
+---
+
+## 5. 推荐的数据流
+
+建议统一做成:
+
+```text
+游戏状态 / 内容数据 / 结果数据
+        ↓
+      ViewModel
+        ↓
+  Native Template / Native DSL / H5
+        ↓
+       用户界面
+```
+
+这里最关键的是:
+
+- 数据模型稳定
+- 展示方式可换
+
+也就是:
+
+**先稳定 ViewModel,再让模板与承载方式变化。**
+
+---
+
+## 6. ViewModel 的作用
+
+ViewModel 是原生模板、原生 DSL、H5 都共用的中间层。
+
+例如结果页:
+
+```json
+{
+  "type": "result-summary",
+  "title": "比赛结束",
+  "subtitle": "校园积分赛",
+  "hero": {
+    "label": "总分",
+    "value": "120"
+  },
+  "stats": [
+    { "key": "duration", "label": "用时", "value": "23:18" },
+    { "key": "distance", "label": "里程", "value": "3.2km" },
+    { "key": "controls", "label": "完成点", "value": "8/8" }
+  ],
+  "actions": [
+    { "key": "restart", "label": "再来一局" },
+    { "key": "close", "label": "返回地图" }
+  ]
+}
+```
+
+例如内容页:
+
+```json
+{
+  "type": "content-card",
+  "title": "湖边步道",
+  "body": "这里适合短暂停留观察周边地形。",
+  "image": "",
+  "cta": "继续"
+}
+```
+
+这样以后无论:
+
+- 原生模板
+- 原生 DSL
+- H5
+
+都可以消费同一份结构化数据。
+
+---
+
+## 7. 原生模板层建议
+
+建议先做有限几个模板:
+
+### 结果页模板
+
+- `result-minimal`
+- `result-rich`
+
+### 内容页模板
+
+- `content-card-minimal`
+- `content-card-story`
+
+模板负责:
+
+- 布局
+- 区块顺序
+- 基础动画与交互
+
+模板不负责:
+
+- 业务逻辑
+- 数据计算
+
+---
+
+## 8. 原生有限 DSL 建议
+
+不要做万能布局引擎,只做有限区块编排。
+
+例如结果页:
+
+```json
+{
+  "templateType": "result-native-dsl",
+  "sections": [
+    {
+      "type": "hero",
+      "field": "score"
+    },
+    {
+      "type": "stats-grid",
+      "fields": ["duration", "distance", "controls"]
+    },
+    {
+      "type": "actions",
+      "items": ["restart", "close"]
+    }
+  ]
+}
+```
+
+例如内容页:
+
+```json
+{
+  "templateType": "content-native-dsl",
+  "sections": [
+    { "type": "title" },
+    { "type": "body" },
+    { "type": "image" },
+    { "type": "actions", "items": ["continue", "openH5"] }
+  ]
+}
+```
+
+原则是:
+
+- 区块种类有限
+- 顺序可配置
+- 不支持无限自由布局
+
+---
+
+## 9. H5 扩展层建议
+
+H5 主要用于高自由度需求。
+
+建议先只承接两类页面:
+
+### 9.1 Content Experience Page
+
+用于:
+
+- 打点后的定制内容页
+- 富图文详情
+- 拍照上传任务
+- 语音留言
+- 小游戏互动
+
+### 9.2 Result Experience Page
+
+用于:
+
+- 定制结算页
+- 成绩包装页
+- 奖章 / 排名 / 分享页
+
+但要注意:
+
+- H5 只是增强页
+- 原生始终保留最小兜底
+
+---
+
+## 10. 推荐优先级
+
+不建议直接跳到 H5 大量实现。
+
+推荐顺序:
+
+### 第一步
+
+先把原生模板层打稳:
+
+- 原生最小结果页
+- 原生最小内容卡
+
+### 第二步
+
+再做原生有限 DSL:
+
+- 支撑中等复杂的客户差异化需求
+
+### 第三步
+
+最后再把 H5 扩展层接稳:
+
+- 处理真正高自由度场景
+
+---
+
+## 11. 对当前项目的建议
+
+结合当前项目现状,建议:
+
+### 当前优先做
+
+- 原生内容卡继续完善
+- 原生结果页继续完善
+- 先把 ViewModel 定稳
+
+### 中期做
+
+- 原生结果页的有限 DSL
+- 原生内容页的有限 DSL
+
+### 后期做
+
+- H5 内容页
+- H5 结果页
+- Bridge 能力逐步扩充
+
+---
+
+## 12. 一句话结论
+
+最适合当前项目的长期方案不是单押 H5,也不是所有东西都原生写死,而是:
+
+**原生模板保底、原生有限 DSL 承担中度变化、H5 承担高定制内容。**
+
+这三层结合起来,既能保证核心体验稳定,也能承接客户高频变化需求。

+ 381 - 0
doc/native-h5-bridge-spec.md

@@ -0,0 +1,381 @@
+# 原生与 H5 Bridge 协议草案
+
+本文档定义当前项目中 **原生小程序** 与 **H5 定制内容页** 之间的基础通信协议。
+
+目标:
+
+- 让 H5 能获取当前游戏上下文
+- 让 H5 能请求原生能力
+- 让原生能接收 H5 的结果回传
+- 保持协议简单、稳定、可版本化
+- 为后续拍照、录音、小游戏、结果页等扩展留出空间
+
+---
+
+## 0. 当前适用前提
+
+本规范当前属于:
+
+- 协议与实现预留
+- 容器与回退机制先行
+
+最近排查已经确认,当前最初使用的是**个人主体**小程序。  
+在这个前提下,`web-view` 能力本身可能受限。
+
+因此:
+
+- Bridge 规范仍然应该先定义
+- 容器页与回退机制也应该先实现
+- 但在企业主体审核通过前,不应把 H5 接入是否成功完全归因于 bridge 代码本身
+
+详细说明见:
+
+- [platform-capability-notes.md](D:/dev/cmr-mini/doc/platform-capability-notes.md)
+
+---
+
+## 1. 协议原则
+
+### 原则 1:Bridge 要版本化
+
+建议先固定:
+
+- `content-v1`
+- `result-v1`
+
+后续升级时:
+
+- 新增 `content-v2`
+- 新增 `result-v2`
+
+不要直接改旧协议。
+
+### 原则 2:请求能力最小化
+
+先只开放真正需要的能力,不要一开始做成“大而全总线”。
+
+### 原则 3:原生控制核心状态
+
+Bridge 只能做:
+
+- 展示
+- 上报
+- 请求能力
+
+不能让 H5 直接改比赛核心状态。
+
+### 原则 4:消息必须可回执
+
+每个请求都应有明确成功/失败返回,不允许 H5 靠超时猜测。
+
+---
+
+## 2. 通道模型
+
+建议统一按“请求 / 响应 / 事件”三类消息组织:
+
+- `request`
+  H5 请求原生能力
+- `response`
+  原生返回能力执行结果
+- `event`
+  原生主动推送状态变化
+
+推荐消息外壳:
+
+```json
+{
+  "id": "req-001",
+  "channel": "request",
+  "type": "getGameContext",
+  "payload": {}
+}
+```
+
+响应:
+
+```json
+{
+  "id": "req-001",
+  "channel": "response",
+  "type": "getGameContext",
+  "ok": true,
+  "payload": {}
+}
+```
+
+---
+
+## 3. 原生注入给 H5 的基础上下文
+
+建议至少包含:
+
+```json
+{
+  "bridgeVersion": "content-v1",
+  "eventId": "sample-score-o-001",
+  "mode": "score-o",
+  "sessionId": "session-001",
+  "sessionStatus": "running",
+  "controlId": "control-3",
+  "controlKind": "control",
+  "title": "湖边步道",
+  "body": "这里适合短暂停留观察周边地形。",
+  "theme": "default-race"
+}
+```
+
+对于结果页,可扩展为:
+
+```json
+{
+  "bridgeVersion": "result-v1",
+  "eventId": "sample-score-o-001",
+  "mode": "score-o",
+  "sessionId": "session-001",
+  "summary": {
+    "title": "比赛结束",
+    "heroValue": "120",
+    "rows": []
+  }
+}
+```
+
+---
+
+## 4. H5 -> 原生:第一阶段推荐动作
+
+建议第一阶段只支持这几个:
+
+### `close`
+
+作用:
+
+- 关闭当前 H5 页面
+
+示例:
+
+```json
+{
+  "id": "req-001",
+  "channel": "request",
+  "type": "close",
+  "payload": {}
+}
+```
+
+### `getGameContext`
+
+作用:
+
+- 让 H5 主动获取最新上下文
+
+### `takePhoto`
+
+作用:
+
+- 请求原生拍照
+
+### `recordAudio`
+
+作用:
+
+- 请求原生录音
+
+### `submitResult`
+
+作用:
+
+- 把 H5 内的任务结果、表单或作品结果提交回原生
+
+示例:
+
+```json
+{
+  "id": "req-002",
+  "channel": "request",
+  "type": "submitResult",
+  "payload": {
+    "taskId": "photo-task-1",
+    "status": "completed",
+    "assetId": "img-001"
+  }
+}
+```
+
+---
+
+## 5. 建议第二阶段可扩展动作
+
+等第一阶段跑稳后,再逐步加入:
+
+- `uploadImage`
+- `uploadAudio`
+- `getLocation`
+- `openMiniGame`
+- `submitForm`
+- `share`
+- `restartSession`
+
+这些先不要第一阶段全开。
+
+---
+
+## 6. 原生 -> H5:统一返回结构
+
+建议统一返回:
+
+```json
+{
+  "id": "req-002",
+  "channel": "response",
+  "type": "takePhoto",
+  "ok": true,
+  "payload": {
+    "assetId": "img-001",
+    "url": "https://example.com/assets/img-001.jpg"
+  }
+}
+```
+
+失败时:
+
+```json
+{
+  "id": "req-002",
+  "channel": "response",
+  "type": "takePhoto",
+  "ok": false,
+  "error": {
+    "code": "USER_CANCELLED",
+    "message": "用户取消拍照"
+  }
+}
+```
+
+---
+
+## 7. 原生 -> H5:推荐事件
+
+原生可按需主动推送轻量事件:
+
+- `contextUpdated`
+- `sessionFinished`
+- `sessionExited`
+- `networkChanged`
+
+但第一阶段要克制,避免高频推送。
+
+不建议第一阶段主动高频推:
+
+- GPS 实时位置流
+- 指北针实时角度
+- HUD 高频数字
+
+这些不适合让 H5 主导。
+
+---
+
+## 8. 错误码建议
+
+建议第一阶段统一几类错误:
+
+- `USER_CANCELLED`
+- `PERMISSION_DENIED`
+- `NETWORK_ERROR`
+- `UNSUPPORTED_ACTION`
+- `BRIDGE_NOT_READY`
+- `INTERNAL_ERROR`
+
+这样 H5 侧更容易统一处理。
+
+---
+
+## 9. 安全与边界
+
+### 9.1 H5 不直接改核心比赛状态
+
+H5 不能直接决定:
+
+- 是否打点成功
+- 是否跳点成功
+- 是否比赛结束
+
+### 9.2 H5 只能请求能力
+
+原生决定是否执行:
+
+- 拍照
+- 录音
+- 上传
+- 页面关闭
+
+### 9.3 Bridge 能力按页面类型开放
+
+例如:
+
+- 内容页开放 `takePhoto`
+- 结果页不一定开放
+
+后续可做按 `bridgeVersion` 或 `pageType` 的能力白名单。
+
+---
+
+## 10. 第一阶段推荐支持范围
+
+建议第一阶段只正式支持:
+
+- `close`
+- `getGameContext`
+- `takePhoto`
+- `recordAudio`
+- `submitResult`
+
+这样足够承接:
+
+- 文创详情
+- 拍照任务
+- 语音留言
+- 结果页回传动作
+
+---
+
+## 11. 不建议第一阶段支持的内容
+
+先不要一上来开放:
+
+- 任意写比赛状态
+- 任意切换玩法
+- 任意修改地图行为
+- 任意控制打点
+- 高频实时 telemetry 推送
+
+这些都属于核心状态,应该继续由原生掌控。
+
+---
+
+## 12. 当前建议实施顺序
+
+1. 先实现一个通用 H5 容器页
+2. 先跑通 `content-v1`
+3. 先支持 5 个最小动作
+4. 再跑通一个简单结果页
+5. 最后再扩桥接能力
+
+---
+
+## 13. 当前建议结论
+
+Bridge 的第一阶段目标,不是做成万能总线,而是:
+
+**稳定承接定制内容页与结果页的最小需求。**
+
+先把:
+
+- 关闭
+- 获取上下文
+- 拍照
+- 录音
+- 结果提交
+
+这 5 条做稳,就足够支撑第一波客户定制需求。

+ 144 - 0
doc/platform-capability-notes.md

@@ -0,0 +1,144 @@
+# 平台能力与主体限制说明
+
+本文档用于记录当前项目在 **微信小程序平台能力** 上已经确认的边界,避免后续把环境或主体限制误判成代码问题。
+
+---
+
+## 1. 当前已确认的关键事实
+
+当前项目最初使用的是**个人主体**小程序。
+
+在这个前提下,已经出现并确认了以下问题:
+
+- `web-view` 无法打开指定 H5 页面
+- 某些传感器能力在不同设备上表现异常或不稳定
+- 部分能力在 `iOS` 与 `Android` 上差异极大
+
+这些问题在排查后,已经基本确认不完全是代码链路问题,而与 **小程序主体能力边界** 直接相关。
+
+---
+
+## 2. 当前确认受影响的能力
+
+### 2.1 WebView / H5 定制内容
+
+现象:
+
+- 浏览器中 H5 页面可正常打开
+- 小程序 `web-view` 中提示:
+  - `无法打开该页面`
+  - `不支持打开 https://...`
+
+当前结论:
+
+- 这不代表 H5 页面本身有问题
+- 也不代表内容体验链路本身有问题
+- 在个人主体下,即使同域名下配置可读,`web-view` 仍可能不可用或受限
+
+### 2.2 传感器相关能力
+
+现象:
+
+- `Compass` 在不同平台表现不一致
+- `Accelerometer` 启动异常
+- 某些 Android 设备上指北针样本不稳定
+
+当前结论:
+
+- 这类问题不能简单归因为算法
+- 其中一部分和小程序主体能力、平台能力边界有关
+- 在企业主体完成前,不宜继续对这类问题做过度代码优化
+
+---
+
+## 3. 为什么“同域名能读配置”不代表“能开 H5”
+
+这是排查中最容易误判的点。
+
+在微信小程序里:
+
+- 读取配置、请求接口,依赖的是:
+  - `request` 相关域名能力
+- 打开 `web-view`,依赖的是:
+  - `业务域名`
+
+这两者不是同一条能力链。
+
+因此会出现:
+
+- 配置文件能正常读取
+- 同域名 H5 页面却无法在 `web-view` 中打开
+
+这个现象本身并不矛盾。
+
+---
+
+## 4. 当前开发策略
+
+在企业主体审核完成前,建议采用以下策略:
+
+### 4.1 原生能力优先
+
+继续优先开发:
+
+- 地图主流程
+- 打点逻辑
+- HUD
+- 指北针与自动转图
+- 原生内容卡兜底
+- 原生结果页兜底
+
+### 4.2 H5 与高级传感器相关能力先按“接口预留”处理
+
+当前阶段可以继续做:
+
+- 文档
+- 模型
+- 配置结构
+- Bridge 设计
+- 容器页
+
+但不要再花大量时间试图用当前个人主体把所有能力彻底打通。
+
+### 4.3 企业主体通过后再做专项回归
+
+企业主体切换完成后,应专项回归:
+
+- `web-view`
+- `Compass`
+- `Accelerometer`
+- 其它之前表现异常的能力
+
+---
+
+## 5. 企业主体切换后的回归建议
+
+建议回归顺序:
+
+1. 最小 `web-view` 测试页
+2. H5 内容体验页自动弹出
+3. H5 点击内容页
+4. `Compass` 样本接收
+5. 自动转图
+6. `Accelerometer`
+
+如果最小 `web-view` 测试页仍失败,再继续查:
+
+- 业务域名
+- 当前 appid
+- 当前环境版本
+- 真机微信缓存
+
+---
+
+## 6. 一句话结论
+
+当前阶段已经确认:
+
+**个人主体会直接影响 `web-view` 和部分传感器能力的可用性与稳定性。**
+
+因此在企业主体审核完成前,最合理的做法是:
+
+- 原生主流程继续开发
+- H5 和高级传感器按“预留 + 待验证”处理
+- 待企业主体生效后,再统一回归验证

+ 0 - 0
result-scene-proposal.md → doc/result-scene-proposal.md


+ 33 - 0
sensor-current-summary.md → doc/sensor-current-summary.md

@@ -199,3 +199,36 @@
 - `Compass`
 
 其余能力更多承担辅助、调试、反馈和后续扩展输入的角色。
+
+---
+
+## 7. 当前主体能力边界补充
+
+最近排查已经确认:
+
+- 当前最初使用的是**个人主体**小程序
+
+这会影响部分设备能力的可用性与稳定性,尤其是:
+
+- `Compass`
+- `Accelerometer`
+- 与 `web-view` 相关的扩展体验链
+
+因此当前这份传感器结论要加一个前提:
+
+**它不仅受到代码实现影响,也受到小程序主体能力边界影响。**
+
+这意味着:
+
+- 某些 Android 上的样本异常,不一定是算法错误
+- 某些 H5 / 传感器问题,不一定能在个人主体下彻底解决
+
+当前建议:
+
+- 原生主流程继续开发
+- 传感器高级能力与 H5 接入先保留设计与代码入口
+- 等企业主体切换完成后,再做专项回归
+
+详细说明见:
+
+- [platform-capability-notes.md](D:/dev/cmr-mini/doc/platform-capability-notes.md)

+ 0 - 0
temp-gameplay-discussion.md → doc/temp-gameplay-discussion.md


+ 0 - 0
todo-multi-user-simulator.md → doc/todo-multi-user-simulator.md


+ 0 - 0
todo-sensor-integration-plan.md → doc/todo-sensor-integration-plan.md


+ 161 - 0
event/classic-sequential.json

@@ -0,0 +1,161 @@
+{
+  "schemaVersion": "1",
+  "version": "2026.03.25",
+  "app": {
+    "id": "sample-classic-001",
+    "title": "顺序赛示例",
+    "locale": "zh-CN"
+  },
+  "map": {
+    "tiles": "../map/lxcb-001/tiles/",
+    "mapmeta": "../map/lxcb-001/tiles/meta.json",
+    "declination": 6.91,
+    "initialView": {
+      "zoom": 17
+    }
+  },
+  "playfield": {
+    "kind": "course",
+    "source": {
+      "type": "kml",
+      "url": "../kml/lxcb-001/10/c01.kml"
+    },
+    "CPRadius": 6,
+    "controlOverrides": {
+      "start-1": {
+        "title": "比赛开始",
+        "body": "从这里出发,先熟悉地图方向,再推进到第一个目标点。",
+        "autoPopup": true,
+        "once": true,
+        "priority": 1,
+        "contentExperience": {
+          "type": "h5",
+          "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
+          "bridge": "content-v1"
+        },
+        "clickTitle": "起点说明",
+        "clickBody": "点击起点可再次查看起跑说明与路线背景。",
+        "clickExperience": {
+          "type": "h5",
+          "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
+          "bridge": "content-v1"
+        }
+      },
+      "control-1": {
+        "title": "图书馆前广场",
+        "body": "这是第一检查点,完成后沿主路继续前进。",
+        "autoPopup": true,
+        "once": false,
+        "priority": 1,
+        "clickTitle": "图书馆前广场",
+        "clickBody": "这里是顺序赛的首个关键点位,适合确认路线方向。",
+        "contentExperience": {
+          "type": "h5",
+          "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
+          "bridge": "content-v1"
+        },
+        "clickExperience": {
+          "type": "h5",
+          "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
+          "bridge": "content-v1"
+        }
+      },
+      "control-2": {
+        "title": "教学楼南侧",
+        "body": "注意这里地形开阔,适合快速判断下一段方向。",
+        "autoPopup": false,
+        "once": true,
+        "priority": 1,
+        "clickTitle": "教学楼南侧",
+        "clickBody": "这个点配置成点击查看,经过时不会自动弹出。",
+        "clickExperience": {
+          "type": "h5",
+          "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
+          "bridge": "content-v1"
+        }
+      },
+      "control-3": {
+        "title": "湖边步道",
+        "body": "经过这里时可以观察水边和林带的边界关系。",
+        "autoPopup": true,
+        "once": false,
+        "priority": 1,
+        "clickTitle": "湖边步道",
+        "clickBody": "点击可查看更详细的路线观察建议。"
+      },
+      "finish-1": {
+        "title": "终点到达",
+        "body": "恭喜完成本次顺序赛,准备查看结果。",
+        "autoPopup": true,
+        "once": true,
+        "priority": 2,
+        "clickTitle": "终点说明",
+        "clickBody": "点击终点可再次查看本局结束说明。",
+        "clickExperience": {
+          "type": "h5",
+          "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
+          "bridge": "content-v1"
+        }
+      }
+    },
+    "metadata": {
+      "title": "顺序赛路线示例",
+      "code": "classic-001"
+    }
+  },
+  "game": {
+    "mode": "classic-sequential",
+    "rulesVersion": "1",
+    "session": {
+      "startManually": true,
+      "requiresStartPunch": true,
+      "requiresFinishPunch": true,
+      "autoFinishOnLastControl": false,
+      "maxDurationSec": 5400
+    },
+    "punch": {
+      "policy": "enter-confirm",
+      "radiusMeters": 5
+    },
+    "sequence": {
+      "skip": {
+        "enabled": true,
+        "radiusMeters": 30,
+        "requiresConfirm": true
+      }
+    },
+    "guidance": {
+      "showLegs": true,
+      "legAnimation": true,
+      "allowFocusSelection": false
+    },
+    "visibility": {
+      "revealFullPlayfieldAfterStartPunch": true
+    },
+    "finish": {
+      "finishControlAlwaysSelectable": false
+    },
+    "telemetry": {
+      "heartRate": {
+        "age": 30,
+        "restingHeartRateBpm": 62,
+        "userWeightKg": 65
+      }
+    },
+    "feedback": {
+      "audioProfile": "default",
+      "hapticsProfile": "default",
+      "uiEffectsProfile": "default"
+    }
+  },
+  "resources": {
+    "audioProfile": "default",
+    "contentProfile": "default",
+    "themeProfile": "default-race"
+  },
+  "debug": {
+    "allowModeSwitch": false,
+    "allowMockInput": false,
+    "allowSimulator": false
+  }
+}

+ 198 - 0
event/content-h5-test-template.html

@@ -0,0 +1,198 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8" />
+    <meta
+      name="viewport"
+      content="width=device-width, initial-scale=1, viewport-fit=cover"
+    />
+    <title>CMR 内容体验测试页</title>
+    <style>
+      :root {
+        color-scheme: light;
+        --bg: #f4f8f5;
+        --fg: #173127;
+        --muted: #5e6f65;
+        --accent: #1f7a5a;
+        --accent-soft: #dcefe6;
+        --card: rgba(255, 255, 255, 0.9);
+        --border: rgba(23, 49, 39, 0.08);
+      }
+
+      * {
+        box-sizing: border-box;
+      }
+
+      body {
+        margin: 0;
+        font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
+        background:
+          radial-gradient(circle at top left, rgba(31, 122, 90, 0.14), transparent 32%),
+          linear-gradient(180deg, #eef6f1 0%, var(--bg) 100%);
+        color: var(--fg);
+      }
+
+      .page {
+        min-height: 100vh;
+        padding: 28px 18px 32px;
+      }
+
+      .card {
+        max-width: 720px;
+        margin: 0 auto;
+        background: var(--card);
+        border: 1px solid var(--border);
+        border-radius: 24px;
+        padding: 24px;
+        box-shadow: 0 18px 42px rgba(24, 49, 39, 0.12);
+        backdrop-filter: blur(10px);
+      }
+
+      .eyebrow {
+        margin: 0 0 8px;
+        color: var(--muted);
+        font-size: 12px;
+        letter-spacing: 0.16em;
+        text-transform: uppercase;
+      }
+
+      h1 {
+        margin: 0 0 12px;
+        font-size: 28px;
+        line-height: 1.2;
+      }
+
+      .lead {
+        margin: 0 0 18px;
+        color: var(--muted);
+        font-size: 15px;
+        line-height: 1.7;
+      }
+
+      .context {
+        margin: 18px 0 0;
+        padding: 14px 16px;
+        border-radius: 16px;
+        background: var(--accent-soft);
+      }
+
+      .context pre {
+        margin: 0;
+        white-space: pre-wrap;
+        word-break: break-word;
+        font-size: 12px;
+        line-height: 1.6;
+      }
+
+      .actions {
+        display: grid;
+        gap: 12px;
+        margin-top: 22px;
+      }
+
+      button {
+        appearance: none;
+        border: 0;
+        border-radius: 16px;
+        padding: 14px 18px;
+        font-size: 16px;
+        font-weight: 600;
+        cursor: pointer;
+      }
+
+      .btn-primary {
+        background: var(--accent);
+        color: #fff;
+      }
+
+      .btn-secondary {
+        background: rgba(23, 49, 39, 0.08);
+        color: var(--fg);
+      }
+    </style>
+  </head>
+  <body>
+    <main class="page">
+      <section class="card">
+        <p class="eyebrow">content-v1 test</p>
+        <h1 id="title">内容体验测试页</h1>
+        <p class="lead" id="desc">
+          这个页面用于验证小程序内容 H5 容器、上下文传参和关闭/回退链路。
+        </p>
+
+        <div class="actions">
+          <button class="btn-primary" id="closeBtn">关闭并返回小程序</button>
+          <button class="btn-secondary" id="fallbackBtn">模拟失败并回退原生卡片</button>
+        </div>
+
+        <div class="context">
+          <pre id="contextView">loading...</pre>
+        </div>
+      </section>
+    </main>
+
+    <script>
+      function getQueryParam(key) {
+        const params = new URLSearchParams(window.location.search);
+        return params.get(key) || "";
+      }
+
+      function parseContext() {
+        const raw = getQueryParam("cmrContext");
+        if (!raw) {
+          return {};
+        }
+        try {
+          return JSON.parse(raw);
+        } catch (error) {
+          return { parseError: String(error), raw };
+        }
+      }
+
+      function postToMiniProgram(action, payload) {
+        const message = { action, payload: payload || {} };
+        if (
+          window.wx &&
+          window.wx.miniProgram &&
+          typeof window.wx.miniProgram.postMessage === "function"
+        ) {
+          window.wx.miniProgram.postMessage({ data: message });
+        } else if (
+          window.parent &&
+          typeof window.parent.postMessage === "function"
+        ) {
+          window.parent.postMessage(message, "*");
+        }
+      }
+
+      const context = parseContext();
+      const titleEl = document.getElementById("title");
+      const descEl = document.getElementById("desc");
+      const contextViewEl = document.getElementById("contextView");
+
+      if (context.title) {
+        titleEl.textContent = context.title;
+      }
+      if (context.body) {
+        descEl.textContent = context.body;
+      }
+      contextViewEl.textContent = JSON.stringify(
+        {
+          bridge: getQueryParam("cmrBridge"),
+          kind: getQueryParam("cmrKind"),
+          context,
+        },
+        null,
+        2
+      );
+
+      document.getElementById("closeBtn").addEventListener("click", function () {
+        postToMiniProgram("close");
+      });
+
+      document.getElementById("fallbackBtn").addEventListener("click", function () {
+        postToMiniProgram("fallback");
+      });
+    </script>
+  </body>
+</html>

+ 177 - 0
event/score-o.json

@@ -0,0 +1,177 @@
+{
+  "schemaVersion": "1",
+  "version": "2026.03.25",
+  "app": {
+    "id": "sample-score-o-001",
+    "title": "积分赛示例",
+    "locale": "zh-CN"
+  },
+  "map": {
+    "tiles": "../map/lxcb-001/tiles/",
+    "mapmeta": "../map/lxcb-001/tiles/meta.json",
+    "declination": 6.91,
+    "initialView": {
+      "zoom": 17
+    }
+  },
+  "playfield": {
+    "kind": "control-set",
+    "source": {
+      "type": "kml",
+      "url": "../kml/lxcb-001/10/c01.kml"
+    },
+    "CPRadius": 6,
+    "controlOverrides": {
+      "start-1": {
+        "title": "比赛开始",
+        "body": "从这里触发,先熟悉地图方向。",
+        "autoPopup": true,
+        "once": true,
+        "priority": 1,
+        "contentExperience": {
+          "type": "h5",
+          "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
+          "bridge": "content-v1"
+        },
+        "clickTitle": "积分赛起点",
+        "clickBody": "点击起点可查看自由打点规则与终点说明。",
+        "clickExperience": {
+          "type": "h5",
+          "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
+          "bridge": "content-v1"
+        }
+      },
+      "control-1": {
+        "score": 10,
+        "clickTitle": "1号点",
+        "clickBody": "这是一个基础积分点,适合作为开局热身。",
+        "clickExperience": {
+          "type": "h5",
+          "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
+          "bridge": "content-v1"
+        }
+      },
+      "control-2": {
+        "score": 20,
+        "autoPopup": false,
+        "once": true,
+        "priority": 1,
+        "clickTitle": "2号点",
+        "clickBody": "这个点配置成点击查看,经过时不会自动弹。",
+        "clickExperience": {
+          "type": "h5",
+          "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
+          "bridge": "content-v1"
+        }
+      },
+      "control-3": {
+        "score": 30,
+        "title": "湖边步道",
+        "body": "这里适合短暂停留观察周边地形。",
+        "autoPopup": true,
+        "once": false,
+        "priority": 1,
+        "clickTitle": "湖边步道",
+        "clickBody": "点击可查看这一区域的补充说明。",
+        "contentExperience": {
+          "type": "h5",
+          "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
+          "bridge": "content-v1"
+        }
+      },
+      "control-4": {
+        "score": 40
+      },
+      "control-5": {
+        "score": 50
+      },
+      "control-6": {
+        "score": 60,
+        "title": "悬崖边",
+        "body": "这里很危险啊。",
+        "autoPopup": true,
+        "once": true,
+        "priority": 2,
+        "clickTitle": "悬崖边",
+        "clickBody": "点击查看地形风险提示。"
+      },
+      "control-7": {
+        "score": 70
+      },
+      "control-8": {
+        "score": 80
+      },
+      "finish-1": {
+        "title": "比赛结束",
+        "body": "恭喜完成本次路线,准备查看结果。",
+        "autoPopup": true,
+        "once": true,
+        "priority": 2,
+        "clickTitle": "终点说明",
+        "clickBody": "点击终点可再次查看结束与结算提示。",
+        "clickExperience": {
+          "type": "h5",
+          "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
+          "bridge": "content-v1"
+        }
+      }
+    },
+    "metadata": {
+      "title": "积分赛控制点示例(2 起终点 + 8 积分点)",
+      "code": "score-o-001"
+    }
+  },
+  "game": {
+    "mode": "score-o",
+    "rulesVersion": "1",
+    "session": {
+      "startManually": true,
+      "requiresStartPunch": true,
+      "requiresFinishPunch": false,
+      "autoFinishOnLastControl": false,
+      "maxDurationSec": 5400
+    },
+    "punch": {
+      "policy": "enter-confirm",
+      "radiusMeters": 5,
+      "requiresFocusSelection": false
+    },
+    "scoring": {
+      "type": "score",
+      "defaultControlScore": 10
+    },
+    "guidance": {
+      "showLegs": false,
+      "legAnimation": false,
+      "allowFocusSelection": true
+    },
+    "visibility": {
+      "revealFullPlayfieldAfterStartPunch": true
+    },
+    "finish": {
+      "finishControlAlwaysSelectable": true
+    },
+    "telemetry": {
+      "heartRate": {
+        "age": 30,
+        "restingHeartRateBpm": 62,
+        "userWeightKg": 65
+      }
+    },
+    "feedback": {
+      "audioProfile": "default",
+      "hapticsProfile": "default",
+      "uiEffectsProfile": "default"
+    }
+  },
+  "resources": {
+    "audioProfile": "default",
+    "contentProfile": "default",
+    "themeProfile": "default-race"
+  },
+  "debug": {
+    "allowModeSwitch": false,
+    "allowMockInput": false,
+    "allowSimulator": false
+  }
+}

+ 2 - 0
miniprogram/app.json

@@ -1,6 +1,8 @@
 {
   "pages": [
     "pages/map/map",
+    "pages/experience-webview/experience-webview",
+    "pages/webview-test/webview-test",
     "pages/index/index",
     "pages/logs/logs"
   ],

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

@@ -13,7 +13,8 @@ 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 GameControl, type GameControlDisplayContentOverride } from '../../game/core/gameDefinition'
+import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
 import { type GameEffect, type GameResult } from '../../game/core/gameResult'
 import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
 import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
@@ -228,6 +229,8 @@ export interface MapEngineViewState {
   contentCardVisible: boolean
   contentCardTitle: string
   contentCardBody: string
+  pendingContentEntryVisible: boolean
+  pendingContentEntryText: string
   punchButtonFxClass: string
   panelProgressFxClass: string
   panelDistanceFxClass: string
@@ -245,6 +248,18 @@ export interface MapEngineViewState {
 
 export interface MapEngineCallbacks {
   onData: (patch: Partial<MapEngineViewState>) => void
+  onOpenH5Experience?: (request: H5ExperienceRequest) => void
+}
+
+interface ContentCardEntry {
+  title: string
+  body: string
+  motionClass: string
+  contentKey: string
+  once: boolean
+  priority: number
+  autoPopup: boolean
+  h5Request: H5ExperienceRequest | null
 }
 
 export interface MapEngineGameInfoRow {
@@ -368,6 +383,8 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'contentCardVisible',
   'contentCardTitle',
   'contentCardBody',
+  'pendingContentEntryVisible',
+  'pendingContentEntryText',
   'punchButtonFxClass',
   'panelProgressFxClass',
   'panelDistanceFxClass',
@@ -889,17 +906,22 @@ export class MapEngine {
   contentCardTimer: number
   currentContentCardPriority: number
   shownContentCardKeys: Record<string, true>
+  currentContentCard: ContentCardEntry | null
+  pendingContentCards: ContentCardEntry[]
+  currentH5ExperienceOpen: boolean
   mapPulseTimer: number
   stageFxTimer: number
   sessionTimerInterval: number
   hasGpsCenteredOnce: boolean
   gpsLockEnabled: boolean
+  onOpenH5Experience?: (request: H5ExperienceRequest) => void
 
   constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
     this.buildVersion = buildVersion
     this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync())
     this.compassTuningProfile = 'balanced'
     this.onData = callbacks.onData
+    this.onOpenH5Experience = callbacks.onOpenH5Experience
     this.accelerometerErrorText = null
     this.renderer = new WebGLMapRenderer(
       (stats) => {
@@ -1144,6 +1166,9 @@ export class MapEngine {
     this.contentCardTimer = 0
     this.currentContentCardPriority = 0
     this.shownContentCardKeys = {}
+    this.currentContentCard = null
+    this.pendingContentCards = []
+    this.currentH5ExperienceOpen = false
     this.mapPulseTimer = 0
     this.stageFxTimer = 0
     this.sessionTimerInterval = 0
@@ -1258,6 +1283,8 @@ export class MapEngine {
       contentCardVisible: false,
       contentCardTitle: '',
       contentCardBody: '',
+      pendingContentEntryVisible: false,
+      pendingContentEntryText: '',
       punchButtonFxClass: '',
       panelProgressFxClass: '',
       panelDistanceFxClass: '',
@@ -1707,6 +1734,196 @@ export class MapEngine {
     }
   }
 
+  getPendingManualContentCount(): number {
+    return this.pendingContentCards.filter((item) => !item.autoPopup).length
+  }
+
+  buildPendingContentEntryText(): string {
+    const count = this.getPendingManualContentCount()
+    if (count <= 1) {
+      return count === 1 ? '查看内容' : ''
+    }
+    return `查看内容(${count})`
+  }
+
+  syncPendingContentEntryState(immediate = true): void {
+    const count = this.getPendingManualContentCount()
+    this.setState({
+      pendingContentEntryVisible: count > 0,
+      pendingContentEntryText: this.buildPendingContentEntryText(),
+    }, immediate)
+  }
+
+  resolveContentControlByKey(contentKey: string): { control: GameControl; displayMode: 'auto' | 'click' } | null {
+    if (!contentKey || !this.gameRuntime.definition) {
+      return null
+    }
+
+    const isClickContent = contentKey.indexOf(':click') >= 0
+    const controlId = isClickContent ? contentKey.replace(/:click$/, '') : contentKey
+    const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
+    if (!control || !control.displayContent) {
+      return null
+    }
+
+    return {
+      control,
+      displayMode: isClickContent ? 'click' : 'auto',
+    }
+  }
+
+  buildContentH5Request(
+    contentKey: string,
+    title: string,
+    body: string,
+    motionClass: string,
+    once: boolean,
+    priority: number,
+    autoPopup: boolean,
+  ): H5ExperienceRequest | null {
+    const resolved = this.resolveContentControlByKey(contentKey)
+    if (!resolved) {
+      return null
+    }
+
+    const displayContent = resolved.control.displayContent
+    if (!displayContent) {
+      return null
+    }
+
+    const experienceConfig = resolved.displayMode === 'click'
+      ? displayContent.clickExperience
+      : displayContent.contentExperience
+    if (!experienceConfig || experienceConfig.type !== 'h5' || !experienceConfig.url) {
+      return null
+    }
+
+    return {
+      kind: 'content',
+      title: title || resolved.control.label || '内容体验',
+      url: experienceConfig.url,
+      bridgeVersion: experienceConfig.bridge || 'content-v1',
+      context: {
+        eventId: this.configAppId || '',
+        configTitle: this.state.mapName || '',
+        configVersion: this.configVersion || '',
+        mode: this.gameMode,
+        sessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
+        controlId: resolved.control.id,
+        controlKind: resolved.control.kind,
+        controlCode: resolved.control.code,
+        controlLabel: resolved.control.label,
+        controlSequence: resolved.control.sequence,
+        displayMode: resolved.displayMode,
+        title,
+        body,
+      },
+      fallback: {
+        title,
+        body,
+        motionClass,
+        contentKey,
+        once,
+        priority,
+        autoPopup,
+      },
+    }
+  }
+
+  hasActiveContentExperience(): boolean {
+    return this.state.contentCardVisible || this.currentH5ExperienceOpen
+  }
+
+  enqueueContentCard(item: ContentCardEntry): void {
+    if (item.once && item.contentKey && this.shownContentCardKeys[item.contentKey]) {
+      return
+    }
+    if (item.contentKey && this.pendingContentCards.some((pending) => pending.contentKey === item.contentKey && pending.autoPopup === item.autoPopup)) {
+      return
+    }
+    this.pendingContentCards.push(item)
+    this.syncPendingContentEntryState()
+  }
+
+  openContentCardEntry(item: ContentCardEntry): void {
+    this.clearContentCardTimer()
+    if (item.h5Request && this.onOpenH5Experience) {
+      this.setState({
+        contentCardVisible: false,
+        contentCardFxClass: '',
+        pendingContentEntryVisible: false,
+        pendingContentEntryText: '',
+      }, true)
+      this.currentContentCardPriority = item.priority
+      this.currentContentCard = item
+      this.currentH5ExperienceOpen = true
+      if (item.once && item.contentKey) {
+        this.shownContentCardKeys[item.contentKey] = true
+      }
+      try {
+        this.onOpenH5Experience(item.h5Request)
+        return
+      } catch {
+        this.currentH5ExperienceOpen = false
+        this.currentContentCardPriority = 0
+        this.currentContentCard = null
+      }
+    }
+
+    this.setState({
+      contentCardVisible: true,
+      contentCardTitle: item.title,
+      contentCardBody: item.body,
+      contentCardFxClass: item.motionClass,
+      pendingContentEntryVisible: false,
+      pendingContentEntryText: '',
+    }, true)
+    this.currentContentCardPriority = item.priority
+    this.currentContentCard = item
+    if (item.once && item.contentKey) {
+      this.shownContentCardKeys[item.contentKey] = true
+    }
+    this.contentCardTimer = setTimeout(() => {
+      this.contentCardTimer = 0
+      this.currentContentCardPriority = 0
+      this.currentContentCard = null
+      this.setState({
+        contentCardVisible: false,
+        contentCardFxClass: '',
+      }, true)
+      this.flushQueuedContentCards()
+    }, 2600) as unknown as number
+  }
+
+  flushQueuedContentCards(): void {
+    if (this.state.contentCardVisible || !this.pendingContentCards.length) {
+      this.syncPendingContentEntryState()
+      return
+    }
+
+    let candidateIndex = -1
+    let candidatePriority = Number.NEGATIVE_INFINITY
+
+    for (let index = 0; index < this.pendingContentCards.length; index += 1) {
+      const item = this.pendingContentCards[index]
+      if (!item.autoPopup) {
+        continue
+      }
+      if (item.priority > candidatePriority) {
+        candidatePriority = item.priority
+        candidateIndex = index
+      }
+    }
+
+    if (candidateIndex < 0) {
+      this.syncPendingContentEntryState()
+      return
+    }
+
+    const nextItem = this.pendingContentCards.splice(candidateIndex, 1)[0]
+    this.openContentCardEntry(nextItem)
+  }
+
   clearMapPulseTimer(): void {
     if (this.mapPulseTimer) {
       clearTimeout(this.mapPulseTimer)
@@ -1734,6 +1951,8 @@ export class MapEngine {
       contentCardVisible: false,
       contentCardTitle: '',
       contentCardBody: '',
+      pendingContentEntryVisible: this.getPendingManualContentCount() > 0,
+      pendingContentEntryText: this.buildPendingContentEntryText(),
       contentCardFxClass: '',
       mapPulseVisible: false,
       mapPulseFxClass: '',
@@ -1744,11 +1963,20 @@ export class MapEngine {
       panelDistanceFxClass: '',
     }, true)
     this.currentContentCardPriority = 0
+    this.currentContentCard = null
+    this.currentH5ExperienceOpen = false
   }
 
   resetSessionContentExperienceState(): void {
     this.shownContentCardKeys = {}
     this.currentContentCardPriority = 0
+    this.currentContentCard = null
+    this.pendingContentCards = []
+    this.currentH5ExperienceOpen = false
+    this.setState({
+      pendingContentEntryVisible: false,
+      pendingContentEntryText: '',
+    })
   }
 
   clearSessionTimerInterval(): void {
@@ -1909,45 +2137,100 @@ export class MapEngine {
     const once = !!(options && options.once)
     const priority = options && typeof options.priority === 'number' ? options.priority : 0
     const contentKey = options && options.contentKey ? options.contentKey : ''
+    const entry = {
+      title,
+      body,
+      motionClass,
+      contentKey,
+      once,
+      priority,
+      autoPopup,
+      h5Request: this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup),
+    }
 
-    if (!autoPopup) {
+    if (once && contentKey && this.shownContentCardKeys[contentKey]) {
       return
     }
-    if (once && contentKey && this.shownContentCardKeys[contentKey]) {
+
+    if (!autoPopup) {
+      this.enqueueContentCard(entry)
       return
     }
-    if (this.state.contentCardVisible && priority < this.currentContentCardPriority) {
+
+    if (this.currentH5ExperienceOpen) {
+      this.enqueueContentCard(entry)
       return
     }
 
-    this.clearContentCardTimer()
-    this.setState({
-      contentCardVisible: true,
-      contentCardTitle: title,
-      contentCardBody: body,
-      contentCardFxClass: motionClass,
-    }, true)
-    this.currentContentCardPriority = priority
-    if (once && contentKey) {
-      this.shownContentCardKeys[contentKey] = true
+    if (this.state.contentCardVisible) {
+      if (priority > this.currentContentCardPriority) {
+        this.openContentCardEntry(entry)
+        return
+      }
+
+      this.enqueueContentCard(entry)
+      return
     }
-    this.contentCardTimer = setTimeout(() => {
-      this.contentCardTimer = 0
-      this.currentContentCardPriority = 0
-      this.setState({
-        contentCardVisible: false,
-        contentCardFxClass: '',
-      }, true)
-    }, 2600) as unknown as number
+
+    this.openContentCardEntry(entry)
   }
 
   closeContentCard(): void {
     this.clearContentCardTimer()
     this.currentContentCardPriority = 0
+    this.currentContentCard = null
+    this.currentH5ExperienceOpen = false
     this.setState({
       contentCardVisible: false,
       contentCardFxClass: '',
     }, true)
+    this.flushQueuedContentCards()
+  }
+
+  openPendingContentCard(): void {
+    if (!this.pendingContentCards.length) {
+      return
+    }
+
+    let candidateIndex = -1
+    let candidatePriority = Number.NEGATIVE_INFINITY
+    for (let index = 0; index < this.pendingContentCards.length; index += 1) {
+      const item = this.pendingContentCards[index]
+      if (item.autoPopup) {
+        continue
+      }
+      if (item.priority > candidatePriority) {
+        candidatePriority = item.priority
+        candidateIndex = index
+      }
+    }
+
+    if (candidateIndex < 0) {
+      return
+    }
+
+    const pending = this.pendingContentCards.splice(candidateIndex, 1)[0]
+    this.openContentCardEntry({
+      ...pending,
+      autoPopup: true,
+    })
+  }
+
+  handleH5ExperienceClosed(): void {
+    this.currentH5ExperienceOpen = false
+    this.currentContentCardPriority = 0
+    this.currentContentCard = null
+    this.flushQueuedContentCards()
+  }
+
+  handleH5ExperienceFallback(fallback: H5ExperienceFallbackPayload): void {
+    this.currentH5ExperienceOpen = false
+    this.currentContentCardPriority = 0
+    this.currentContentCard = null
+    this.openContentCardEntry({
+      ...fallback,
+      h5Request: null,
+    })
   }
 
   applyGameEffects(effects: GameEffect[]): string | null {
@@ -2693,24 +2976,29 @@ export class MapEngine {
   }
 
   handleMapTap(stageX: number, stageY: number): void {
-    if (!this.gameRuntime.definition || !this.gameRuntime.state || this.gameRuntime.definition.mode !== 'score-o') {
+    if (!this.gameRuntime.definition || !this.gameRuntime.state) {
       return
     }
 
-    const focusedControlId = this.findFocusableControlAt(stageX, stageY)
-    if (focusedControlId === undefined) {
-      return
+    if (this.gameRuntime.definition.mode === 'score-o') {
+      const focusedControlId = this.findFocusableControlAt(stageX, stageY)
+      if (focusedControlId !== undefined) {
+        const gameResult = this.gameRuntime.dispatch({
+          type: 'control_focused',
+          at: Date.now(),
+          controlId: focusedControlId,
+        })
+        this.commitGameResult(
+          gameResult,
+          focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`,
+        )
+      }
     }
 
-    const gameResult = this.gameRuntime.dispatch({
-      type: 'control_focused',
-      at: Date.now(),
-      controlId: focusedControlId,
-    })
-    this.commitGameResult(
-      gameResult,
-      focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`,
-    )
+    const contentControlId = this.findContentControlAt(stageX, stageY)
+    if (contentControlId) {
+      this.openControlClickContent(contentControlId)
+    }
   }
 
   findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
@@ -2749,6 +3037,134 @@ export class MapEngine {
     return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId
   }
 
+  findContentControlAt(stageX: number, stageY: number): string | undefined {
+    if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
+      return undefined
+    }
+
+    let matchedControlId: string | undefined
+    let matchedDistance = Number.POSITIVE_INFINITY
+    let matchedPriority = Number.NEGATIVE_INFINITY
+    const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())
+
+    for (const control of this.gameRuntime.definition.controls) {
+      if (
+        !control.displayContent
+        || (
+          !control.displayContent.clickTitle
+          && !control.displayContent.clickBody
+          && !(control.displayContent.clickExperience && control.displayContent.clickExperience.type === 'h5')
+          && !(control.displayContent.contentExperience && control.displayContent.contentExperience.type === 'h5')
+        )
+      ) {
+        continue
+      }
+      if (!this.isControlTapContentVisible(control)) {
+        continue
+      }
+
+      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) {
+        continue
+      }
+
+      const controlPriority = this.getControlTapContentPriority(control)
+      const sameDistance = Math.abs(distancePx - matchedDistance) <= 2
+      if (
+        distancePx < matchedDistance
+        || (sameDistance && controlPriority > matchedPriority)
+      ) {
+        matchedDistance = distancePx
+        matchedPriority = controlPriority
+        matchedControlId = control.id
+      }
+    }
+
+    return matchedControlId
+  }
+
+  getControlTapContentPriority(control: { kind: 'start' | 'control' | 'finish'; id: string }): number {
+    if (!this.gameRuntime.state || !this.gamePresentation.map) {
+      return 0
+    }
+
+    const currentTargetControlId = this.gameRuntime.state.currentTargetControlId
+    const completedControlIds = this.gameRuntime.state.completedControlIds
+
+    if (currentTargetControlId === control.id) {
+      return 100
+    }
+
+    if (control.kind === 'start') {
+      return completedControlIds.includes(control.id) ? 10 : 90
+    }
+
+    if (control.kind === 'finish') {
+      return completedControlIds.includes(control.id)
+        ? 80
+        : (this.gamePresentation.map.completedStart ? 85 : 5)
+    }
+
+    return completedControlIds.includes(control.id) ? 40 : 60
+  }
+
+  isControlTapContentVisible(control: { kind: 'start' | 'control' | 'finish'; sequence: number | null; id: string }): boolean {
+    if (this.gamePresentation.map.revealFullCourse) {
+      return true
+    }
+
+    if (control.kind === 'start') {
+      return this.gamePresentation.map.activeStart || this.gamePresentation.map.completedStart
+    }
+
+    if (control.kind === 'finish') {
+      return this.gamePresentation.map.activeFinish || this.gamePresentation.map.focusedFinish || this.gamePresentation.map.completedFinish
+    }
+
+    if (control.sequence === null) {
+      return false
+    }
+
+    const readyControlSequences = this.resolveReadyControlSequences()
+    return this.gamePresentation.map.activeControlSequences.includes(control.sequence)
+      || this.gamePresentation.map.completedControlSequences.includes(control.sequence)
+      || this.gamePresentation.map.skippedControlSequences.includes(control.sequence)
+      || this.gamePresentation.map.focusedControlSequences.includes(control.sequence)
+      || readyControlSequences.includes(control.sequence)
+  }
+
+  openControlClickContent(controlId: string): void {
+    if (!this.gameRuntime.definition) {
+      return
+    }
+
+    const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
+    if (!control || !control.displayContent) {
+      return
+    }
+
+    const title = control.displayContent.clickTitle || control.displayContent.title || control.label || '内容体验'
+    const body = control.displayContent.clickBody || control.displayContent.body || ''
+    if (!title && !body) {
+      return
+    }
+
+    this.showContentCard(title, body, 'game-content-card--fx-pop', {
+      contentKey: `${control.id}:click`,
+      autoPopup: true,
+      once: false,
+      priority: control.displayContent.priority,
+    })
+  }
+
   getControlHitRadiusPx(): number {
     if (!this.state.tileSizePx) {
       return 28

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

@@ -1,4 +1,6 @@
 import {
+  type GameContentExperienceConfig,
+  type GameContentExperienceConfigOverride,
   type GameDefinition,
   type GameControl,
   type GameControlDisplayContent,
@@ -19,6 +21,35 @@ function buildDisplayBody(label: string, sequence: number | null): string {
   return label
 }
 
+function applyExperienceOverride(
+  baseExperience: GameContentExperienceConfig | null,
+  override: GameContentExperienceConfigOverride | undefined,
+): GameContentExperienceConfig | null {
+  if (!override) {
+    return baseExperience
+  }
+
+  if (override.type === 'native') {
+    return {
+      type: 'native',
+      url: null,
+      bridge: 'content-v1',
+      fallback: 'native',
+    }
+  }
+
+  if (override.type === 'h5' && override.url) {
+    return {
+      type: 'h5',
+      url: override.url,
+      bridge: override.bridge || (baseExperience ? baseExperience.bridge : 'content-v1'),
+      fallback: override.fallback || 'native',
+    }
+  }
+
+  return baseExperience
+}
+
 function applyDisplayContentOverride(
   baseContent: GameControlDisplayContent,
   override: GameControlDisplayContentOverride | undefined,
@@ -33,6 +64,10 @@ function applyDisplayContentOverride(
     autoPopup: override.autoPopup !== undefined ? override.autoPopup : baseContent.autoPopup,
     once: override.once !== undefined ? override.once : baseContent.once,
     priority: override.priority !== undefined ? override.priority : baseContent.priority,
+    clickTitle: override.clickTitle !== undefined ? override.clickTitle : baseContent.clickTitle,
+    clickBody: override.clickBody !== undefined ? override.clickBody : baseContent.clickBody,
+    contentExperience: applyExperienceOverride(baseContent.contentExperience, override.contentExperience),
+    clickExperience: applyExperienceOverride(baseContent.clickExperience, override.clickExperience),
   }
 }
 
@@ -70,6 +105,10 @@ export function buildGameDefinitionFromCourse(
         autoPopup: true,
         once: false,
         priority: 1,
+        clickTitle: '比赛开始',
+        clickBody: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
+        contentExperience: null,
+        clickExperience: null,
       }, controlContentOverrides[startId]),
     })
   }
@@ -94,6 +133,10 @@ export function buildGameDefinitionFromCourse(
         autoPopup: true,
         once: false,
         priority: 1,
+        clickTitle: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
+        clickBody: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence),
+        contentExperience: null,
+        clickExperience: null,
       }, controlContentOverrides[controlId]),
     })
   }
@@ -116,6 +159,10 @@ export function buildGameDefinitionFromCourse(
         autoPopup: true,
         once: false,
         priority: 2,
+        clickTitle: '完成路线',
+        clickBody: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
+        contentExperience: null,
+        clickExperience: null,
       }, controlContentOverrides[finishId] || controlContentOverrides[legacyFinishId]),
     })
   }

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

@@ -5,12 +5,30 @@ export type GameMode = 'classic-sequential' | 'score-o'
 export type GameControlKind = 'start' | 'control' | 'finish'
 export type PunchPolicyType = 'enter' | 'enter-confirm'
 
+export interface GameContentExperienceConfig {
+  type: 'native' | 'h5'
+  url: string | null
+  bridge: string
+  fallback: 'native'
+}
+
+export interface GameContentExperienceConfigOverride {
+  type?: 'native' | 'h5'
+  url?: string
+  bridge?: string
+  fallback?: 'native'
+}
+
 export interface GameControlDisplayContent {
   title: string
   body: string
   autoPopup: boolean
   once: boolean
   priority: number
+  clickTitle: string | null
+  clickBody: string | null
+  contentExperience: GameContentExperienceConfig | null
+  clickExperience: GameContentExperienceConfig | null
 }
 
 export interface GameControlDisplayContentOverride {
@@ -19,6 +37,10 @@ export interface GameControlDisplayContentOverride {
   autoPopup?: boolean
   once?: boolean
   priority?: number
+  clickTitle?: string
+  clickBody?: string
+  contentExperience?: GameContentExperienceConfigOverride
+  clickExperience?: GameContentExperienceConfigOverride
 }
 
 export interface GameControl {

+ 26 - 0
miniprogram/game/experience/h5Experience.ts

@@ -0,0 +1,26 @@
+export type H5ExperienceKind = 'content' | 'result'
+
+export interface H5ExperienceFallbackPayload {
+  title: string
+  body: string
+  motionClass: string
+  contentKey: string
+  once: boolean
+  priority: number
+  autoPopup: boolean
+}
+
+export interface H5ExperienceRequest {
+  kind: H5ExperienceKind
+  title: string
+  url: string
+  bridgeVersion: string
+  context: Record<string, unknown>
+  fallback: H5ExperienceFallbackPayload
+}
+
+export interface H5BridgeMessage {
+  action?: string
+  type?: string
+  payload?: Record<string, unknown>
+}

+ 8 - 5
miniprogram/game/rules/classicSequentialRule.ts

@@ -279,7 +279,10 @@ function getInitialTargetId(definition: GameDefinition): string | null {
   return firstTarget ? firstTarget.id : null
 }
 
-function buildCompletedEffect(control: GameControl): GameEffect {
+function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect {
+  const allowAutoPopup = punchPolicy === 'enter'
+    ? false
+    : (control.displayContent ? control.displayContent.autoPopup : true)
   if (control.kind === 'start') {
     return {
       type: 'control_completed',
@@ -289,7 +292,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
       label: control.label,
       displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
       displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,前往 1 号点。',
-      displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
+      displayAutoPopup: allowAutoPopup,
       displayOnce: control.displayContent ? control.displayContent.once : false,
       displayPriority: control.displayContent ? control.displayContent.priority : 1,
     }
@@ -304,7 +307,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
       label: control.label,
       displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
       displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
-      displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
+      displayAutoPopup: allowAutoPopup,
       displayOnce: control.displayContent ? control.displayContent.once : false,
       displayPriority: control.displayContent ? control.displayContent.priority : 2,
     }
@@ -322,7 +325,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
     label: control.label,
     displayTitle,
     displayBody,
-    displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
+    displayAutoPopup: allowAutoPopup,
     displayOnce: control.displayContent ? control.displayContent.once : false,
     displayPriority: control.displayContent ? control.displayContent.priority : 1,
   }
@@ -353,7 +356,7 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
       phase: resolveClassicPhase(nextTarget, currentTarget, finished),
     },
   }
-  const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
+  const effects: GameEffect[] = [buildCompletedEffect(currentTarget, definition.punchPolicy)]
 
   if (finished) {
     effects.push({ type: 'session_finished' })

+ 8 - 5
miniprogram/game/rules/scoreORule.ts

@@ -241,7 +241,10 @@ function buildPunchHintText(
     : `进入${targetLabel}后点击打点`
 }
 
-function buildCompletedEffect(control: GameControl): GameEffect {
+function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect {
+  const allowAutoPopup = punchPolicy === 'enter'
+    ? false
+    : (control.displayContent ? control.displayContent.autoPopup : true)
   if (control.kind === 'start') {
     return {
       type: 'control_completed',
@@ -251,7 +254,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
       label: control.label,
       displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
       displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,开始自由打点。',
-      displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
+      displayAutoPopup: allowAutoPopup,
       displayOnce: control.displayContent ? control.displayContent.once : false,
       displayPriority: control.displayContent ? control.displayContent.priority : 1,
     }
@@ -266,7 +269,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
       label: control.label,
       displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
       displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
-      displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
+      displayAutoPopup: allowAutoPopup,
       displayOnce: control.displayContent ? control.displayContent.once : false,
       displayPriority: control.displayContent ? control.displayContent.priority : 2,
     }
@@ -281,7 +284,7 @@ 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,
+    displayAutoPopup: allowAutoPopup,
     displayOnce: control.displayContent ? control.displayContent.once : false,
     displayPriority: control.displayContent ? control.displayContent.priority : 1,
   }
@@ -435,7 +438,7 @@ function applyCompletion(
     currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
   }, nextModeState)
 
-  const effects: GameEffect[] = [buildCompletedEffect(control)]
+  const effects: GameEffect[] = [buildCompletedEffect(control, definition.punchPolicy)]
   if (control.kind === 'finish') {
     effects.push({ type: 'session_finished' })
   }

+ 127 - 0
miniprogram/pages/experience-webview/experience-webview.js

@@ -0,0 +1,127 @@
+let currentRequest = null
+let currentEventChannel = null
+let pageResolved = false
+
+function appendQueryParam(url, key, value) {
+  const separator = url.indexOf('?') >= 0 ? '&' : '?'
+  return `${url}${separator}${key}=${encodeURIComponent(value)}`
+}
+
+function buildWebViewSrc(request) {
+  let nextUrl = request.url
+  nextUrl = appendQueryParam(nextUrl, 'cmrBridge', request.bridgeVersion)
+  nextUrl = appendQueryParam(nextUrl, 'cmrKind', request.kind)
+  return nextUrl
+}
+
+function emitFallbackAndClose() {
+  if (!currentRequest || !currentEventChannel) {
+    return
+  }
+  if (!pageResolved) {
+    pageResolved = true
+    currentEventChannel.emit('fallback', currentRequest.fallback)
+  }
+  wx.navigateBack({
+    fail() {},
+  })
+}
+
+function emitCloseAndBack(payload) {
+  if (currentEventChannel && !pageResolved) {
+    pageResolved = true
+    currentEventChannel.emit('close', payload || {})
+  }
+  wx.navigateBack({
+    fail() {},
+  })
+}
+
+Page({
+  data: {
+    webViewSrc: '',
+    webViewReady: false,
+    loadErrorText: '',
+  },
+
+  onLoad() {
+    pageResolved = false
+    currentRequest = null
+    currentEventChannel = null
+    this.setData({
+      webViewSrc: '',
+      webViewReady: false,
+      loadErrorText: '',
+    })
+
+    try {
+      currentEventChannel = this.getOpenerEventChannel()
+    } catch (error) {
+      currentEventChannel = null
+    }
+
+    if (!currentEventChannel) {
+      return
+    }
+
+    currentEventChannel.on('init', (request) => {
+      currentRequest = request
+      wx.setNavigationBarTitle({
+        title: request.title || '内容体验',
+        fail() {},
+      })
+      this.setData({
+        webViewSrc: buildWebViewSrc(request),
+        webViewReady: true,
+        loadErrorText: '',
+      })
+    })
+  },
+
+  onUnload() {
+    if (currentEventChannel && !pageResolved) {
+      currentEventChannel.emit('close', {})
+    }
+    pageResolved = false
+    currentRequest = null
+    currentEventChannel = null
+  },
+
+  handleWebViewMessage(event) {
+    const dataList = event.detail && Array.isArray(event.detail.data)
+      ? event.detail.data
+      : []
+    const rawMessage = dataList.length ? dataList[dataList.length - 1] : null
+    if (!rawMessage || typeof rawMessage !== 'object') {
+      return
+    }
+
+    const action = rawMessage.action || rawMessage.type || ''
+    if (!action) {
+      return
+    }
+
+    if (action === 'close') {
+      emitCloseAndBack(rawMessage.payload)
+      return
+    }
+
+    if (action === 'submitResult') {
+      if (currentEventChannel) {
+        currentEventChannel.emit('submitResult', rawMessage.payload || {})
+      }
+      return
+    }
+
+    if (action === 'fallback') {
+      emitFallbackAndClose()
+    }
+  },
+
+  handleWebViewError() {
+    this.setData({
+      loadErrorText: '页面打开失败,已回退原生内容',
+    })
+    emitFallbackAndClose()
+  },
+})

+ 3 - 0
miniprogram/pages/experience-webview/experience-webview.json

@@ -0,0 +1,3 @@
+{
+  "navigationBarTitleText": "内容体验"
+}

+ 136 - 0
miniprogram/pages/experience-webview/experience-webview.ts

@@ -0,0 +1,136 @@
+import { type H5BridgeMessage, type H5ExperienceRequest } from '../../game/experience/h5Experience'
+
+type ExperienceWebViewPageData = {
+  webViewSrc: string
+  webViewReady: boolean
+  loadErrorText: string
+}
+
+let currentRequest: H5ExperienceRequest | null = null
+let currentEventChannel: WechatMiniprogram.EventChannel | null = null
+let pageResolved = false
+
+function appendQueryParam(url: string, key: string, value: string): string {
+  const separator = url.indexOf('?') >= 0 ? '&' : '?'
+  return `${url}${separator}${key}=${encodeURIComponent(value)}`
+}
+
+function buildWebViewSrc(request: H5ExperienceRequest): string {
+  let nextUrl = request.url
+  nextUrl = appendQueryParam(nextUrl, 'cmrBridge', request.bridgeVersion)
+  nextUrl = appendQueryParam(nextUrl, 'cmrKind', request.kind)
+  return nextUrl
+}
+
+function emitFallbackAndClose() {
+  if (!currentRequest || !currentEventChannel) {
+    return
+  }
+  if (!pageResolved) {
+    pageResolved = true
+    currentEventChannel.emit('fallback', currentRequest.fallback)
+  }
+  wx.navigateBack({
+    fail: () => {},
+  })
+}
+
+function emitCloseAndBack(payload?: Record<string, unknown>) {
+  if (currentEventChannel && !pageResolved) {
+    pageResolved = true
+    currentEventChannel.emit('close', payload || {})
+  }
+  wx.navigateBack({
+    fail: () => {},
+  })
+}
+
+Page<ExperienceWebViewPageData, WechatMiniprogram.IAnyObject>({
+  data: {
+    webViewSrc: '',
+    webViewReady: false,
+    loadErrorText: '',
+  },
+
+  onLoad() {
+    pageResolved = false
+    currentRequest = null
+    currentEventChannel = null
+    this.setData({
+      webViewSrc: '',
+      webViewReady: false,
+      loadErrorText: '',
+    })
+
+    try {
+      currentEventChannel = this.getOpenerEventChannel()
+    } catch {
+      currentEventChannel = null
+    }
+
+    if (!currentEventChannel) {
+      return
+    }
+
+    currentEventChannel.on('init', (request: H5ExperienceRequest) => {
+      currentRequest = request
+      wx.setNavigationBarTitle({
+        title: request.title || '内容体验',
+        fail: () => {},
+      })
+      this.setData({
+        webViewSrc: buildWebViewSrc(request),
+        webViewReady: true,
+        loadErrorText: '',
+      })
+    })
+  },
+
+  onUnload() {
+    if (currentEventChannel && !pageResolved) {
+      currentEventChannel.emit('close', {})
+    }
+    pageResolved = false
+    currentRequest = null
+    currentEventChannel = null
+  },
+
+  handleWebViewMessage(event: WechatMiniprogram.CustomEvent) {
+    const dataList = event.detail && Array.isArray(event.detail.data)
+      ? event.detail.data
+      : []
+    const rawMessage = dataList.length ? dataList[dataList.length - 1] : null
+    if (!rawMessage || typeof rawMessage !== 'object') {
+      return
+    }
+
+    const message = rawMessage as H5BridgeMessage
+    const action = message.action || message.type || ''
+    if (!action) {
+      return
+    }
+
+    if (action === 'close') {
+      emitCloseAndBack(message.payload)
+      return
+    }
+
+    if (action === 'submitResult') {
+      if (currentEventChannel) {
+        currentEventChannel.emit('submitResult', message.payload || {})
+      }
+      return
+    }
+
+    if (action === 'fallback') {
+      emitFallbackAndClose()
+    }
+  },
+
+  handleWebViewError() {
+    this.setData({
+      loadErrorText: '页面打开失败,已回退原生内容',
+    })
+    emitFallbackAndClose()
+  },
+})

+ 11 - 0
miniprogram/pages/experience-webview/experience-webview.wxml

@@ -0,0 +1,11 @@
+<view wx:if="{{!webViewReady}}" class="experience-webview__loading">
+  <view class="experience-webview__loading-title">内容页加载中</view>
+  <view wx:if="{{loadErrorText}}" class="experience-webview__loading-error">{{loadErrorText}}</view>
+</view>
+
+<web-view
+  wx:if="{{webViewReady && webViewSrc}}"
+  src="{{webViewSrc}}"
+  bindmessage="handleWebViewMessage"
+  binderror="handleWebViewError"
+></web-view>

+ 27 - 0
miniprogram/pages/experience-webview/experience-webview.wxss

@@ -0,0 +1,27 @@
+.page {
+  height: 100%;
+}
+page {
+  background: #f5f7f6;
+}
+
+.experience-webview__loading {
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 48rpx;
+  color: #1f2f26;
+}
+
+.experience-webview__loading-title {
+  font-size: 32rpx;
+  font-weight: 600;
+}
+
+.experience-webview__loading-error {
+  margin-top: 20rpx;
+  font-size: 26rpx;
+  color: #a0523d;
+}

+ 53 - 7
miniprogram/pages/map/map.ts

@@ -8,6 +8,7 @@ import {
 } from '../../engine/map/mapEngine'
 import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
 import { type AnimationLevel } from '../../utils/animationLevel'
+import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
 type CompassTickData = {
   angle: number
   long: boolean
@@ -848,8 +849,8 @@ Page({
       mapEngine = null
     }
 
-    mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
-      onData: (patch) => {
+      mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
+        onData: (patch) => {
         const nextPatch = patch as Partial<MapPageData>
         const includeDebugFields = this.data.showDebugPanel
         const includeRulerFields = this.data.showCenterScaleRuler
@@ -988,11 +989,14 @@ Page({
           })
         }
 
-        if (this.data.showGameInfoPanel) {
-          this.scheduleGameInfoPanelSnapshotSync()
-        }
-      },
-    })
+          if (this.data.showGameInfoPanel) {
+            this.scheduleGameInfoPanelSnapshotSync()
+          }
+        },
+        onOpenH5Experience: (request) => {
+          this.openH5Experience(request)
+        },
+      })
 
     const storedUserSettings = loadStoredUserSettings()
     if (storedUserSettings.animationLevel) {
@@ -1390,6 +1394,12 @@ Page({
     mapEngine.handleConnectMockHeartRateBridge()
   },
 
+  handleOpenWebViewTest() {
+    wx.navigateTo({
+      url: '/pages/webview-test/webview-test',
+    })
+  },
+
   handleMockBridgeUrlInput(event: WechatMiniprogram.Input) {
     this.setData({
       mockBridgeUrlDraft: event.detail.value,
@@ -1887,6 +1897,42 @@ Page({
     }
   },
 
+  handleOpenPendingContentCard() {
+    if (mapEngine) {
+      mapEngine.openPendingContentCard()
+    }
+  },
+
+  openH5Experience(request: H5ExperienceRequest) {
+    wx.navigateTo({
+      url: '/pages/experience-webview/experience-webview',
+      success: (result) => {
+        const eventChannel = result.eventChannel
+        eventChannel.on('fallback', (payload: H5ExperienceFallbackPayload) => {
+          if (mapEngine) {
+            mapEngine.handleH5ExperienceFallback(payload)
+          }
+        })
+        eventChannel.on('close', () => {
+          if (mapEngine) {
+            mapEngine.handleH5ExperienceClosed()
+          }
+        })
+        eventChannel.on('submitResult', () => {
+          if (mapEngine) {
+            mapEngine.handleH5ExperienceClosed()
+          }
+        })
+        eventChannel.emit('init', request)
+      },
+      fail: () => {
+        if (mapEngine) {
+          mapEngine.handleH5ExperienceFallback(request.fallback)
+        }
+      },
+    })
+  },
+
   handleCloseContentCard() {
     if (mapEngine) {
       mapEngine.closeContentCard()

+ 5 - 0
miniprogram/pages/map/map.wxml

@@ -115,6 +115,10 @@
     <cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view>
   </cover-view>
 
+  <cover-view class="map-content-entry" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel && pendingContentEntryVisible}}" bindtap="handleOpenPendingContentCard">
+    <cover-view class="map-content-entry__text">{{pendingContentEntryText}}</cover-view>
+  </cover-view>
+
   <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>
@@ -552,6 +556,7 @@
           </view>
           <view class="control-row">
             <view class="control-chip control-chip--primary" bindtap="handleConnectAllMockSources">一键连接模拟源</view>
+            <view class="control-chip control-chip--secondary" bindtap="handleOpenWebViewTest">测试 H5</view>
           </view>
           <view class="debug-group-title">定位</view>
           <view class="info-panel__row">

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

@@ -1155,6 +1155,27 @@
   animation: punch-button-warning 0.56s ease-in-out 1;
 }
 
+.map-content-entry {
+  position: absolute;
+  right: 22rpx;
+  bottom: 352rpx;
+  min-width: 96rpx;
+  height: 52rpx;
+  padding: 0 18rpx;
+  border-radius: 28rpx;
+  background: rgba(33, 47, 58, 0.88);
+  box-shadow: 0 10rpx 24rpx rgba(18, 28, 38, 0.2);
+  z-index: 18;
+}
+
+.map-content-entry__text {
+  font-size: 22rpx;
+  line-height: 52rpx;
+  font-weight: 700;
+  text-align: center;
+  color: rgba(244, 248, 252, 0.94);
+}
+
 
 .race-panel__line {
   position: absolute;

+ 24 - 0
miniprogram/pages/webview-test/webview-test.js

@@ -0,0 +1,24 @@
+const WEB_VIEW_TEST_URL = 'https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html'
+
+Page({
+  data: {
+    webViewSrc: '',
+    webViewReady: false,
+  },
+
+  onLoad() {
+    this.setData({
+      webViewSrc: WEB_VIEW_TEST_URL,
+      webViewReady: true,
+    })
+  },
+
+  handleWebViewError() {
+    wx.showModal({
+      title: 'H5 打开失败',
+      content: WEB_VIEW_TEST_URL,
+      showCancel: false,
+      confirmText: '知道了',
+    })
+  },
+})

+ 3 - 0
miniprogram/pages/webview-test/webview-test.json

@@ -0,0 +1,3 @@
+{
+  "navigationBarTitleText": "WebView 测试"
+}

+ 29 - 0
miniprogram/pages/webview-test/webview-test.ts

@@ -0,0 +1,29 @@
+type WebViewTestPageData = {
+  webViewSrc: string
+  webViewReady: boolean
+}
+
+const WEB_VIEW_TEST_URL = 'https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html'
+
+Page<WebViewTestPageData, WechatMiniprogram.IAnyObject>({
+  data: {
+    webViewSrc: '',
+    webViewReady: false,
+  },
+
+  onLoad() {
+    this.setData({
+      webViewSrc: WEB_VIEW_TEST_URL,
+      webViewReady: true,
+    })
+  },
+
+  handleWebViewError() {
+    wx.showModal({
+      title: 'H5 打开失败',
+      content: WEB_VIEW_TEST_URL,
+      showCancel: false,
+      confirmText: '知道了',
+    })
+  },
+})

+ 11 - 0
miniprogram/pages/webview-test/webview-test.wxml

@@ -0,0 +1,11 @@
+<view class="webview-test-page">
+  <view class="webview-test-page__loading" wx:if="{{!webViewReady}}">
+    <view class="webview-test-page__title">H5 测试页加载中</view>
+    <view class="webview-test-page__desc">{{webViewSrc}}</view>
+  </view>
+  <web-view
+    wx:if="{{webViewReady && webViewSrc}}"
+    src="{{webViewSrc}}"
+    binderror="handleWebViewError"
+  ></web-view>
+</view>

+ 24 - 0
miniprogram/pages/webview-test/webview-test.wxss

@@ -0,0 +1,24 @@
+.webview-test-page {
+  height: 100vh;
+  background: #f5f7f8;
+}
+
+.webview-test-page__loading {
+  min-height: 100vh;
+  padding: 120rpx 48rpx;
+  box-sizing: border-box;
+}
+
+.webview-test-page__title {
+  font-size: 40rpx;
+  font-weight: 600;
+  color: #102a24;
+}
+
+.webview-test-page__desc {
+  margin-top: 28rpx;
+  font-size: 24rpx;
+  line-height: 1.6;
+  color: #5a6e68;
+  word-break: break-all;
+}

+ 65 - 2
miniprogram/utils/remoteMapConfig.ts

@@ -2,7 +2,10 @@ 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 {
+  type GameContentExperienceConfigOverride,
+  type GameControlDisplayContentOverride,
+} from '../game/core/gameDefinition'
 import {
   mergeGameHapticsConfig,
   mergeGameUiEffectsConfig,
@@ -233,6 +236,44 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
   return rawValue === 'enter' ? 'enter' : 'enter-confirm'
 }
 
+function parseContentExperienceOverride(
+  rawValue: unknown,
+  baseUrl: string,
+): GameContentExperienceConfigOverride | undefined {
+  const normalized = normalizeObjectRecord(rawValue)
+  if (!Object.keys(normalized).length) {
+    return undefined
+  }
+
+  const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : ''
+  if (typeValue === 'native') {
+    return {
+      type: 'native',
+      fallback: 'native',
+    }
+  }
+
+  if (typeValue !== 'h5') {
+    return undefined
+  }
+
+  const rawUrl = typeof normalized.url === 'string' ? normalized.url.trim() : ''
+  if (!rawUrl) {
+    return undefined
+  }
+
+  const bridgeValue = typeof normalized.bridge === 'string' && normalized.bridge.trim()
+    ? normalized.bridge.trim()
+    : 'content-v1'
+
+  return {
+    type: 'h5',
+    url: resolveUrl(baseUrl, rawUrl),
+    bridge: bridgeValue,
+    fallback: 'native',
+  }
+}
+
 function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
   if (typeof rawValue !== 'string') {
     return 'classic-sequential'
@@ -780,19 +821,41 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
       const bodyValue = typeof (item as Record<string, unknown>).body === 'string'
         ? ((item as Record<string, unknown>).body as string).trim()
         : ''
+      const clickTitleValue = typeof (item as Record<string, unknown>).clickTitle === 'string'
+        ? ((item as Record<string, unknown>).clickTitle as string).trim()
+        : ''
+      const clickBodyValue = typeof (item as Record<string, unknown>).clickBody === 'string'
+        ? ((item as Record<string, unknown>).clickBody 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 contentExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).contentExperience, gameConfigUrl)
+      const clickExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).clickExperience, gameConfigUrl)
       const hasAutoPopup = typeof autoPopupValue === 'boolean'
       const hasOnce = typeof onceValue === 'boolean'
       const hasPriority = Number.isFinite(priorityNumeric)
-      if (titleValue || bodyValue || hasAutoPopup || hasOnce || hasPriority) {
+      if (
+        titleValue
+        || bodyValue
+        || clickTitleValue
+        || clickBodyValue
+        || hasAutoPopup
+        || hasOnce
+        || hasPriority
+        || contentExperienceValue
+        || clickExperienceValue
+      ) {
         controlContentOverrides[key] = {
           ...(titleValue ? { title: titleValue } : {}),
           ...(bodyValue ? { body: bodyValue } : {}),
+          ...(clickTitleValue ? { clickTitle: clickTitleValue } : {}),
+          ...(clickBodyValue ? { clickBody: clickBodyValue } : {}),
           ...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
           ...(hasOnce ? { once: !!onceValue } : {}),
           ...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
+          ...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}),
+          ...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}),
         }
       }
     }

+ 36 - 0
readme-develop.md

@@ -1440,3 +1440,39 @@ GPS:
 
 - 先用这套底座承接后续配置字段和玩法细化
 - 一旦再次出现“某类状态总是漏同步”的真实问题,再继续沿统一提交链收口
+
+---
+
+## 22. 平台能力边界补充
+
+最近这轮 H5 与传感器排查,已经明确了一件事:
+
+- 当前项目最初使用的是**个人主体**小程序
+- 这会直接影响部分平台能力
+
+目前已经确认受影响或可能受影响的能力包括:
+
+- `web-view`
+- `Compass`
+- `Accelerometer`
+- 其它部分设备能力在 `iOS / Android` 上的稳定性
+
+这意味着:
+
+- 某些问题并不一定是代码实现错误
+- 也可能是主体能力边界导致
+
+例如当前已经确认:
+
+- 配置文件可以正常读取,不代表同域名 H5 页面就一定能在 `web-view` 中打开
+- 某些传感器在个人主体环境下表现不稳定,不代表原生链路本身一定有问题
+
+因此当前阶段建议:
+
+- 继续优先开发原生主流程
+- H5 与高级传感器按“预留 + 待验证”处理
+- 待企业主体审核通过后,再统一做专项回归
+
+详细说明见:
+
+- [platform-capability-notes.md](D:/dev/cmr-mini/doc/platform-capability-notes.md)