From 0e0a724025b0d88a5ff72392e7f7f057ab787766 Mon Sep 17 00:00:00 2001 From: zhangyan Date: Fri, 27 Mar 2026 15:36:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E7=90=86=E6=96=87=E6=A1=A3=E5=B9=B6?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20H5=20=E4=BD=93=E9=AA=8C=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MyToDo.md | 0 GeminiAnlysis.md => doc/GeminiAnlysis.md | 0 doc/MyToDo.md | 4 + .../animation-design-proposal.md | 0 .../animation-dictionary.md | 0 .../animation-integration-spec.md | 0 .../animation-integration-workflow.md | 0 .../animation-pipeline-summary.md | 0 .../animation-review-checklist.md | 0 .../backend-config-management-proposal.md | 0 .../backend-config-management-v2.md | 0 .../communication-guidelines.md | 0 .../compass-debugging-notes.md | 0 doc/config-default-template.md | 415 +++++++++++++ .../config-design-proposal.md | 0 doc/config-docs-index.md | 163 +++++ doc/config-option-dictionary.md | 556 ++++++++++++++++++ .../config-template-classic-sequential.md | 0 .../config-template-score-o.md | 0 .../content-experience-layer-proposal.md | 0 .../gameplay-ideas-proposal.md | 0 doc/h5-experience-integration-proposal.md | 384 ++++++++++++ doc/hybrid-experience-architecture.md | 421 +++++++++++++ doc/native-h5-bridge-spec.md | 381 ++++++++++++ doc/platform-capability-notes.md | 144 +++++ .../result-scene-proposal.md | 0 .../sensor-current-summary.md | 33 ++ .../temp-gameplay-discussion.md | 0 .../todo-multi-user-simulator.md | 0 .../todo-sensor-integration-plan.md | 0 event/classic-sequential.json | 161 +++++ event/content-h5-test-template.html | 198 +++++++ event/score-o.json | 177 ++++++ miniprogram/app.json | 2 + miniprogram/engine/map/mapEngine.ts | 488 +++++++++++++-- .../game/content/courseToGameDefinition.ts | 47 ++ miniprogram/game/core/gameDefinition.ts | 22 + miniprogram/game/experience/h5Experience.ts | 26 + .../game/rules/classicSequentialRule.ts | 13 +- miniprogram/game/rules/scoreORule.ts | 13 +- .../experience-webview/experience-webview.js | 127 ++++ .../experience-webview.json | 3 + .../experience-webview/experience-webview.ts | 136 +++++ .../experience-webview.wxml | 11 + .../experience-webview.wxss | 27 + miniprogram/pages/map/map.ts | 60 +- miniprogram/pages/map/map.wxml | 5 + miniprogram/pages/map/map.wxss | 21 + .../pages/webview-test/webview-test.js | 24 + .../pages/webview-test/webview-test.json | 3 + .../pages/webview-test/webview-test.ts | 29 + .../pages/webview-test/webview-test.wxml | 11 + .../pages/webview-test/webview-test.wxss | 24 + miniprogram/utils/remoteMapConfig.ts | 67 ++- readme-develop.md | 36 ++ 55 files changed, 4177 insertions(+), 55 deletions(-) delete mode 100644 MyToDo.md rename GeminiAnlysis.md => doc/GeminiAnlysis.md (100%) create mode 100644 doc/MyToDo.md rename animation-design-proposal.md => doc/animation-design-proposal.md (100%) rename animation-dictionary.md => doc/animation-dictionary.md (100%) rename animation-integration-spec.md => doc/animation-integration-spec.md (100%) rename animation-integration-workflow.md => doc/animation-integration-workflow.md (100%) rename animation-pipeline-summary.md => doc/animation-pipeline-summary.md (100%) rename animation-review-checklist.md => doc/animation-review-checklist.md (100%) rename backend-config-management-proposal.md => doc/backend-config-management-proposal.md (100%) rename backend-config-management-v2.md => doc/backend-config-management-v2.md (100%) rename communication-guidelines.md => doc/communication-guidelines.md (100%) rename compass-debugging-notes.md => doc/compass-debugging-notes.md (100%) create mode 100644 doc/config-default-template.md rename config-design-proposal.md => doc/config-design-proposal.md (100%) create mode 100644 doc/config-docs-index.md create mode 100644 doc/config-option-dictionary.md rename config-template-classic-sequential.md => doc/config-template-classic-sequential.md (100%) rename config-template-score-o.md => doc/config-template-score-o.md (100%) rename content-experience-layer-proposal.md => doc/content-experience-layer-proposal.md (100%) rename gameplay-ideas-proposal.md => doc/gameplay-ideas-proposal.md (100%) create mode 100644 doc/h5-experience-integration-proposal.md create mode 100644 doc/hybrid-experience-architecture.md create mode 100644 doc/native-h5-bridge-spec.md create mode 100644 doc/platform-capability-notes.md rename result-scene-proposal.md => doc/result-scene-proposal.md (100%) rename sensor-current-summary.md => doc/sensor-current-summary.md (82%) rename temp-gameplay-discussion.md => doc/temp-gameplay-discussion.md (100%) rename todo-multi-user-simulator.md => doc/todo-multi-user-simulator.md (100%) rename todo-sensor-integration-plan.md => doc/todo-sensor-integration-plan.md (100%) create mode 100644 event/classic-sequential.json create mode 100644 event/content-h5-test-template.html create mode 100644 event/score-o.json create mode 100644 miniprogram/game/experience/h5Experience.ts create mode 100644 miniprogram/pages/experience-webview/experience-webview.js create mode 100644 miniprogram/pages/experience-webview/experience-webview.json create mode 100644 miniprogram/pages/experience-webview/experience-webview.ts create mode 100644 miniprogram/pages/experience-webview/experience-webview.wxml create mode 100644 miniprogram/pages/experience-webview/experience-webview.wxss create mode 100644 miniprogram/pages/webview-test/webview-test.js create mode 100644 miniprogram/pages/webview-test/webview-test.json create mode 100644 miniprogram/pages/webview-test/webview-test.ts create mode 100644 miniprogram/pages/webview-test/webview-test.wxml create mode 100644 miniprogram/pages/webview-test/webview-test.wxss diff --git a/MyToDo.md b/MyToDo.md deleted file mode 100644 index e69de29..0000000 diff --git a/GeminiAnlysis.md b/doc/GeminiAnlysis.md similarity index 100% rename from GeminiAnlysis.md rename to doc/GeminiAnlysis.md diff --git a/doc/MyToDo.md b/doc/MyToDo.md new file mode 100644 index 0000000..b1f225c --- /dev/null +++ b/doc/MyToDo.md @@ -0,0 +1,4 @@ + +结果页会根据客户的要求不停的变换,用什么方案能实现这个需求,其实其他的弹出内容也都存在这个问题,样式,内容都时根据客户需求变化的,怎样一种方案设计比较好呢? + + diff --git a/animation-design-proposal.md b/doc/animation-design-proposal.md similarity index 100% rename from animation-design-proposal.md rename to doc/animation-design-proposal.md diff --git a/animation-dictionary.md b/doc/animation-dictionary.md similarity index 100% rename from animation-dictionary.md rename to doc/animation-dictionary.md diff --git a/animation-integration-spec.md b/doc/animation-integration-spec.md similarity index 100% rename from animation-integration-spec.md rename to doc/animation-integration-spec.md diff --git a/animation-integration-workflow.md b/doc/animation-integration-workflow.md similarity index 100% rename from animation-integration-workflow.md rename to doc/animation-integration-workflow.md diff --git a/animation-pipeline-summary.md b/doc/animation-pipeline-summary.md similarity index 100% rename from animation-pipeline-summary.md rename to doc/animation-pipeline-summary.md diff --git a/animation-review-checklist.md b/doc/animation-review-checklist.md similarity index 100% rename from animation-review-checklist.md rename to doc/animation-review-checklist.md diff --git a/backend-config-management-proposal.md b/doc/backend-config-management-proposal.md similarity index 100% rename from backend-config-management-proposal.md rename to doc/backend-config-management-proposal.md diff --git a/backend-config-management-v2.md b/doc/backend-config-management-v2.md similarity index 100% rename from backend-config-management-v2.md rename to doc/backend-config-management-v2.md diff --git a/communication-guidelines.md b/doc/communication-guidelines.md similarity index 100% rename from communication-guidelines.md rename to doc/communication-guidelines.md diff --git a/compass-debugging-notes.md b/doc/compass-debugging-notes.md similarity index 100% rename from compass-debugging-notes.md rename to doc/compass-debugging-notes.md diff --git a/doc/config-default-template.md b/doc/config-default-template.md new file mode 100644 index 0000000..9e3e80c --- /dev/null +++ b/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) + +这样可以保证: + +- 客户端实现 +- 服务端配置 +- 后台录入 +- 联调样例 + +始终保持一致。 + diff --git a/config-design-proposal.md b/doc/config-design-proposal.md similarity index 100% rename from config-design-proposal.md rename to doc/config-design-proposal.md diff --git a/doc/config-docs-index.md b/doc/config-docs-index.md new file mode 100644 index 0000000..97a3bf4 --- /dev/null +++ b/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) + +这样可以保证: + +- 文档 +- 样例 +- 代码 +- 后台录入 + +保持一致。 diff --git a/doc/config-option-dictionary.md b/doc/config-option-dictionary.md new file mode 100644 index 0000000..c9f9f9e --- /dev/null +++ b/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` 目录下的配置样例 + +这样可以保证: + +- 服务端可对照 +- 后台可录入 +- 客户端联调时有统一参考 diff --git a/config-template-classic-sequential.md b/doc/config-template-classic-sequential.md similarity index 100% rename from config-template-classic-sequential.md rename to doc/config-template-classic-sequential.md diff --git a/config-template-score-o.md b/doc/config-template-score-o.md similarity index 100% rename from config-template-score-o.md rename to doc/config-template-score-o.md diff --git a/content-experience-layer-proposal.md b/doc/content-experience-layer-proposal.md similarity index 100% rename from content-experience-layer-proposal.md rename to doc/content-experience-layer-proposal.md diff --git a/gameplay-ideas-proposal.md b/doc/gameplay-ideas-proposal.md similarity index 100% rename from gameplay-ideas-proposal.md rename to doc/gameplay-ideas-proposal.md diff --git a/doc/h5-experience-integration-proposal.md b/doc/h5-experience-integration-proposal.md new file mode 100644 index 0000000..79cee14 --- /dev/null +++ b/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) diff --git a/doc/hybrid-experience-architecture.md b/doc/hybrid-experience-architecture.md new file mode 100644 index 0000000..a6a663a --- /dev/null +++ b/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 承担高定制内容。** + +这三层结合起来,既能保证核心体验稳定,也能承接客户高频变化需求。 diff --git a/doc/native-h5-bridge-spec.md b/doc/native-h5-bridge-spec.md new file mode 100644 index 0000000..d5f69fa --- /dev/null +++ b/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 条做稳,就足够支撑第一波客户定制需求。 diff --git a/doc/platform-capability-notes.md b/doc/platform-capability-notes.md new file mode 100644 index 0000000..4c32230 --- /dev/null +++ b/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 和高级传感器按“预留 + 待验证”处理 +- 待企业主体生效后,再统一回归验证 diff --git a/result-scene-proposal.md b/doc/result-scene-proposal.md similarity index 100% rename from result-scene-proposal.md rename to doc/result-scene-proposal.md diff --git a/sensor-current-summary.md b/doc/sensor-current-summary.md similarity index 82% rename from sensor-current-summary.md rename to doc/sensor-current-summary.md index f616ab7..a9c704a 100644 --- a/sensor-current-summary.md +++ b/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) diff --git a/temp-gameplay-discussion.md b/doc/temp-gameplay-discussion.md similarity index 100% rename from temp-gameplay-discussion.md rename to doc/temp-gameplay-discussion.md diff --git a/todo-multi-user-simulator.md b/doc/todo-multi-user-simulator.md similarity index 100% rename from todo-multi-user-simulator.md rename to doc/todo-multi-user-simulator.md diff --git a/todo-sensor-integration-plan.md b/doc/todo-sensor-integration-plan.md similarity index 100% rename from todo-sensor-integration-plan.md rename to doc/todo-sensor-integration-plan.md diff --git a/event/classic-sequential.json b/event/classic-sequential.json new file mode 100644 index 0000000..99e6558 --- /dev/null +++ b/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 + } +} diff --git a/event/content-h5-test-template.html b/event/content-h5-test-template.html new file mode 100644 index 0000000..af3370c --- /dev/null +++ b/event/content-h5-test-template.html @@ -0,0 +1,198 @@ + + + + + + CMR 内容体验测试页 + + + +
+
+

content-v1 test

+

内容体验测试页

+

+ 这个页面用于验证小程序内容 H5 容器、上下文传参和关闭/回退链路。 +

+ +
+ + +
+ +
+
loading...
+
+
+
+ + + + diff --git a/event/score-o.json b/event/score-o.json new file mode 100644 index 0000000..78d80c5 --- /dev/null +++ b/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 + } +} diff --git a/miniprogram/app.json b/miniprogram/app.json index b5e6e9e..c265205 100644 --- a/miniprogram/app.json +++ b/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" ], diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index 576f3fa..fbad9ad 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/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) => 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 = [ 'contentCardVisible', 'contentCardTitle', 'contentCardBody', + 'pendingContentEntryVisible', + 'pendingContentEntryText', 'punchButtonFxClass', 'panelProgressFxClass', 'panelDistanceFxClass', @@ -889,17 +906,22 @@ export class MapEngine { contentCardTimer: number currentContentCardPriority: number shownContentCardKeys: Record + 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 : '' - - if (!autoPopup) { - return + const entry = { + title, + body, + motionClass, + contentKey, + once, + priority, + autoPopup, + h5Request: this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup), } + if (once && contentKey && this.shownContentCardKeys[contentKey]) { return } - if (this.state.contentCardVisible && priority < this.currentContentCardPriority) { + + if (!autoPopup) { + 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.currentH5ExperienceOpen) { + this.enqueueContentCard(entry) + return } - this.contentCardTimer = setTimeout(() => { - this.contentCardTimer = 0 - this.currentContentCardPriority = 0 - this.setState({ - contentCardVisible: false, - contentCardFxClass: '', - }, true) - }, 2600) as unknown as number + + if (this.state.contentCardVisible) { + if (priority > this.currentContentCardPriority) { + this.openContentCardEntry(entry) + return + } + + this.enqueueContentCard(entry) + return + } + + 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 diff --git a/miniprogram/game/content/courseToGameDefinition.ts b/miniprogram/game/content/courseToGameDefinition.ts index 0968317..814bb8a 100644 --- a/miniprogram/game/content/courseToGameDefinition.ts +++ b/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]), }) } diff --git a/miniprogram/game/core/gameDefinition.ts b/miniprogram/game/core/gameDefinition.ts index 9f141e6..f60d5de 100644 --- a/miniprogram/game/core/gameDefinition.ts +++ b/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 { diff --git a/miniprogram/game/experience/h5Experience.ts b/miniprogram/game/experience/h5Experience.ts new file mode 100644 index 0000000..8a9e819 --- /dev/null +++ b/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 + fallback: H5ExperienceFallbackPayload +} + +export interface H5BridgeMessage { + action?: string + type?: string + payload?: Record +} diff --git a/miniprogram/game/rules/classicSequentialRule.ts b/miniprogram/game/rules/classicSequentialRule.ts index 2d5c714..f968305 100644 --- a/miniprogram/game/rules/classicSequentialRule.ts +++ b/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' }) diff --git a/miniprogram/game/rules/scoreORule.ts b/miniprogram/game/rules/scoreORule.ts index c890785..d38605f 100644 --- a/miniprogram/game/rules/scoreORule.ts +++ b/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' }) } diff --git a/miniprogram/pages/experience-webview/experience-webview.js b/miniprogram/pages/experience-webview/experience-webview.js new file mode 100644 index 0000000..b577245 --- /dev/null +++ b/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() + }, +}) diff --git a/miniprogram/pages/experience-webview/experience-webview.json b/miniprogram/pages/experience-webview/experience-webview.json new file mode 100644 index 0000000..ce090f1 --- /dev/null +++ b/miniprogram/pages/experience-webview/experience-webview.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "内容体验" +} diff --git a/miniprogram/pages/experience-webview/experience-webview.ts b/miniprogram/pages/experience-webview/experience-webview.ts new file mode 100644 index 0000000..a58d31f --- /dev/null +++ b/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) { + 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 { + 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() + }, +}) diff --git a/miniprogram/pages/experience-webview/experience-webview.wxml b/miniprogram/pages/experience-webview/experience-webview.wxml new file mode 100644 index 0000000..3da6ffe --- /dev/null +++ b/miniprogram/pages/experience-webview/experience-webview.wxml @@ -0,0 +1,11 @@ + + 内容页加载中 + {{loadErrorText}} + + + diff --git a/miniprogram/pages/experience-webview/experience-webview.wxss b/miniprogram/pages/experience-webview/experience-webview.wxss new file mode 100644 index 0000000..8607d17 --- /dev/null +++ b/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; +} diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 9328671..84ae48a 100644 --- a/miniprogram/pages/map/map.ts +++ b/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 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() diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 9c83565..e513521 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -115,6 +115,10 @@ {{punchButtonText}} + + {{pendingContentEntryText}} + + 开始 @@ -552,6 +556,7 @@ 一键连接模拟源 + 测试 H5 定位 diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index 30f5cbd..13be461 100644 --- a/miniprogram/pages/map/map.wxss +++ b/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; diff --git a/miniprogram/pages/webview-test/webview-test.js b/miniprogram/pages/webview-test/webview-test.js new file mode 100644 index 0000000..c662422 --- /dev/null +++ b/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: '知道了', + }) + }, +}) diff --git a/miniprogram/pages/webview-test/webview-test.json b/miniprogram/pages/webview-test/webview-test.json new file mode 100644 index 0000000..870e6b9 --- /dev/null +++ b/miniprogram/pages/webview-test/webview-test.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "WebView 测试" +} diff --git a/miniprogram/pages/webview-test/webview-test.ts b/miniprogram/pages/webview-test/webview-test.ts new file mode 100644 index 0000000..8211230 --- /dev/null +++ b/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({ + 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: '知道了', + }) + }, +}) diff --git a/miniprogram/pages/webview-test/webview-test.wxml b/miniprogram/pages/webview-test/webview-test.wxml new file mode 100644 index 0000000..101c812 --- /dev/null +++ b/miniprogram/pages/webview-test/webview-test.wxml @@ -0,0 +1,11 @@ + + + H5 测试页加载中 + {{webViewSrc}} + + + diff --git a/miniprogram/pages/webview-test/webview-test.wxss b/miniprogram/pages/webview-test/webview-test.wxss new file mode 100644 index 0000000..1e4e860 --- /dev/null +++ b/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; +} diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts index 6f43925..77d28c8 100644 --- a/miniprogram/utils/remoteMapConfig.ts +++ b/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).body === 'string' ? ((item as Record).body as string).trim() : '' + const clickTitleValue = typeof (item as Record).clickTitle === 'string' + ? ((item as Record).clickTitle as string).trim() + : '' + const clickBodyValue = typeof (item as Record).clickBody === 'string' + ? ((item as Record).clickBody as string).trim() + : '' const autoPopupValue = (item as Record).autoPopup const onceValue = (item as Record).once const priorityNumeric = Number((item as Record).priority) + const contentExperienceValue = parseContentExperienceOverride((item as Record).contentExperience, gameConfigUrl) + const clickExperienceValue = parseContentExperienceOverride((item as Record).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 } : {}), } } } diff --git a/readme-develop.md b/readme-develop.md index f4df882..81e5551 100644 --- a/readme-develop.md +++ b/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)