整理文档并接入 H5 体验测试链路
This commit is contained in:
4
doc/MyToDo.md
Normal file
4
doc/MyToDo.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
结果页会根据客户的要求不停的变换,用什么方案能实现这个需求,其实其他的弹出内容也都存在这个问题,样式,内容都时根据客户需求变化的,怎样一种方案设计比较好呢?
|
||||||
|
|
||||||
|
|
||||||
415
doc/config-default-template.md
Normal file
415
doc/config-default-template.md
Normal file
@@ -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)
|
||||||
|
|
||||||
|
这样可以保证:
|
||||||
|
|
||||||
|
- 客户端实现
|
||||||
|
- 服务端配置
|
||||||
|
- 后台录入
|
||||||
|
- 联调样例
|
||||||
|
|
||||||
|
始终保持一致。
|
||||||
|
|
||||||
163
doc/config-docs-index.md
Normal file
163
doc/config-docs-index.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# 配置文档索引
|
||||||
|
|
||||||
|
本文档用于汇总当前项目所有与**配置设计、配置样例、配置管理**相关的文档,作为统一入口。
|
||||||
|
|
||||||
|
适用对象:
|
||||||
|
|
||||||
|
- 客户端开发
|
||||||
|
- 服务端开发
|
||||||
|
- 后台管理设计
|
||||||
|
- 配置录入与联调
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 配置设计总方案
|
||||||
|
|
||||||
|
### [config-design-proposal.md](D:/dev/cmr-mini/doc/config-design-proposal.md)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 说明为什么配置要按 `app / map / playfield / game / resources / debug` 分层
|
||||||
|
- 说明 `KML` 和配置的职责边界
|
||||||
|
- 说明为什么上位概念用 `playfield`
|
||||||
|
- 适合做总体架构参考
|
||||||
|
|
||||||
|
适合阅读时机:
|
||||||
|
|
||||||
|
- 设计配置结构
|
||||||
|
- 设计客户端读取链
|
||||||
|
- 和后端讨论顶层模型时
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 配置选项字典
|
||||||
|
|
||||||
|
### [config-option-dictionary.md](D:/dev/cmr-mini/doc/config-option-dictionary.md)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 列出当前客户端已经支持或已预留的配置项
|
||||||
|
- 说明每个字段的类型、含义、默认逻辑
|
||||||
|
- 作为后续新增字段时的持续维护文档
|
||||||
|
|
||||||
|
适合阅读时机:
|
||||||
|
|
||||||
|
- 想知道某个字段是否已实现
|
||||||
|
- 想知道字段应该怎么写
|
||||||
|
- 想确认默认行为时
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 默认配置模板
|
||||||
|
|
||||||
|
### [config-default-template.md](D:/dev/cmr-mini/doc/config-default-template.md)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 提供当前推荐的默认配置模板
|
||||||
|
- 包含顺序赛和积分赛的基础默认示例
|
||||||
|
- 用于服务端、后台、联调时直接起步
|
||||||
|
|
||||||
|
适合阅读时机:
|
||||||
|
|
||||||
|
- 新建一份活动配置
|
||||||
|
- 想直接照着填配置
|
||||||
|
- 想知道最小可运行模板长什么样
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 按玩法拆分的配置模板文档
|
||||||
|
|
||||||
|
### [config-template-classic-sequential.md](D:/dev/cmr-mini/doc/config-template-classic-sequential.md)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 解释顺序赛配置结构
|
||||||
|
- 说明顺序赛的必填字段和默认值
|
||||||
|
- 适合给后端和后台做顺序赛专项参考
|
||||||
|
|
||||||
|
### [config-template-score-o.md](D:/dev/cmr-mini/doc/config-template-score-o.md)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 解释积分赛配置结构
|
||||||
|
- 说明积分赛的必填字段和默认值
|
||||||
|
- 适合给后端和后台做积分赛专项参考
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 运行中的样例配置
|
||||||
|
|
||||||
|
### [event/classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 当前顺序赛样例配置
|
||||||
|
- 可直接联调
|
||||||
|
- 已包含控制点内容覆盖示例
|
||||||
|
|
||||||
|
### [event/score-o.json](D:/dev/cmr-mini/event/score-o.json)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 当前积分赛样例配置
|
||||||
|
- 可直接联调
|
||||||
|
- 已包含分值、起终点内容、点击内容示例
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 后台与服务端配置管理方案
|
||||||
|
|
||||||
|
### [backend-config-management-proposal.md](D:/dev/cmr-mini/doc/backend-config-management-proposal.md)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 第一版后台配置管理建议
|
||||||
|
- 适合了解 `Map / Playfield / GameMode / ResourcePack / Event` 这套核心对象
|
||||||
|
|
||||||
|
### [backend-config-management-v2.md](D:/dev/cmr-mini/doc/backend-config-management-v2.md)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 在“配置项变化频繁”前提下重写的后台方案
|
||||||
|
- 更强调:
|
||||||
|
- 稳定骨架
|
||||||
|
- `jsonb`
|
||||||
|
- 版本
|
||||||
|
- 发布
|
||||||
|
- 透传未知字段
|
||||||
|
|
||||||
|
推荐优先看这一份。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 推荐阅读顺序
|
||||||
|
|
||||||
|
如果你是第一次接触这套配置体系,建议按这个顺序看:
|
||||||
|
|
||||||
|
1. [config-design-proposal.md](D:/dev/cmr-mini/doc/config-design-proposal.md)
|
||||||
|
2. [config-option-dictionary.md](D:/dev/cmr-mini/doc/config-option-dictionary.md)
|
||||||
|
3. [config-default-template.md](D:/dev/cmr-mini/doc/config-default-template.md)
|
||||||
|
4. [event/classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
|
||||||
|
5. [event/score-o.json](D:/dev/cmr-mini/event/score-o.json)
|
||||||
|
6. [backend-config-management-v2.md](D:/dev/cmr-mini/doc/backend-config-management-v2.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 维护约定
|
||||||
|
|
||||||
|
后续每次新增配置能力时,建议至少同步更新这几处:
|
||||||
|
|
||||||
|
1. [config-option-dictionary.md](D:/dev/cmr-mini/doc/config-option-dictionary.md)
|
||||||
|
2. [config-default-template.md](D:/dev/cmr-mini/doc/config-default-template.md)
|
||||||
|
3. 对应玩法的 `event/*.json` 样例
|
||||||
|
4. 如果涉及顶层结构变化,再更新 [config-design-proposal.md](D:/dev/cmr-mini/doc/config-design-proposal.md)
|
||||||
|
|
||||||
|
这样可以保证:
|
||||||
|
|
||||||
|
- 文档
|
||||||
|
- 样例
|
||||||
|
- 代码
|
||||||
|
- 后台录入
|
||||||
|
|
||||||
|
保持一致。
|
||||||
556
doc/config-option-dictionary.md
Normal file
556
doc/config-option-dictionary.md
Normal file
@@ -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` 目录下的配置样例
|
||||||
|
|
||||||
|
这样可以保证:
|
||||||
|
|
||||||
|
- 服务端可对照
|
||||||
|
- 后台可录入
|
||||||
|
- 客户端联调时有统一参考
|
||||||
384
doc/h5-experience-integration-proposal.md
Normal file
384
doc/h5-experience-integration-proposal.md
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
# H5 体验接入方案
|
||||||
|
|
||||||
|
本文档用于定义当前项目中 **原生小程序 + H5 定制内容** 的混合接入方案。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 保留原生地图与实时游戏主流程
|
||||||
|
- 把高频变化、强定制的内容页交给 H5
|
||||||
|
- 保证 H5 失败时,原生仍可完整兜底
|
||||||
|
- 为后续客户定制、品牌包装、互动任务扩展留出稳定接口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 结论
|
||||||
|
|
||||||
|
当前最合适的方向不是“所有定制都 H5 化”,而是:
|
||||||
|
|
||||||
|
**原生负责核心游戏层,H5 负责定制体验层。**
|
||||||
|
|
||||||
|
也就是:
|
||||||
|
|
||||||
|
- 地图、打点、GPS、指北针、HUD、规则状态机继续原生
|
||||||
|
- 结算页、文创详情、拍照任务、语音留言、小游戏、品牌包装页交给 H5
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 适合 H5 化的内容
|
||||||
|
|
||||||
|
当前最适合 H5 承接的是:
|
||||||
|
|
||||||
|
- 结算页
|
||||||
|
- 打点后的定制内容页
|
||||||
|
- 文创详情页
|
||||||
|
- 活动品牌页
|
||||||
|
- 富图文任务页
|
||||||
|
- 拍照上传 / 语音留言 / 小游戏类互动页
|
||||||
|
- 表单、问卷、抽奖、作品提交页
|
||||||
|
|
||||||
|
不建议 H5 化的部分:
|
||||||
|
|
||||||
|
- 地图主界面
|
||||||
|
- 打点逻辑
|
||||||
|
- 自动转图
|
||||||
|
- 指北针
|
||||||
|
- HUD
|
||||||
|
- GPS / 心率等实时能力主链
|
||||||
|
- 需要强实时状态同步的高频游戏弹层
|
||||||
|
|
||||||
|
一句话:
|
||||||
|
|
||||||
|
**核心实时游戏层保留原生,变化快的定制内容层交给 H5。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 总体架构
|
||||||
|
|
||||||
|
推荐分成三层:
|
||||||
|
|
||||||
|
### 3.1 原生层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 地图与渲染
|
||||||
|
- GPS / 指北针 / 自动转图
|
||||||
|
- 打点状态机
|
||||||
|
- HUD
|
||||||
|
- 心率 / telemetry
|
||||||
|
- 原生内容卡兜底
|
||||||
|
- 原生结果页兜底
|
||||||
|
- 核心状态与本地缓存
|
||||||
|
|
||||||
|
### 3.2 H5 体验层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 定制内容展示
|
||||||
|
- 品牌包装
|
||||||
|
- 富交互任务
|
||||||
|
- 定制结算页
|
||||||
|
- 富图文与媒体内容
|
||||||
|
|
||||||
|
### 3.3 Bridge 层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 原生向 H5 注入上下文
|
||||||
|
- H5 向原生请求能力
|
||||||
|
- H5 把结果回传原生
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 两种 H5 页面类型
|
||||||
|
|
||||||
|
### 4.1 Content Experience Page
|
||||||
|
|
||||||
|
用于游戏中途的内容体验页。
|
||||||
|
|
||||||
|
典型场景:
|
||||||
|
|
||||||
|
- 控制点打卡后弹文创详情
|
||||||
|
- 控制点点击后查看图文内容
|
||||||
|
- 拍照上传任务
|
||||||
|
- 语音留言任务
|
||||||
|
- 小游戏互动页
|
||||||
|
- 问答/表单类互动页
|
||||||
|
|
||||||
|
### 4.2 Result Experience Page
|
||||||
|
|
||||||
|
用于游戏结束后的定制结算页。
|
||||||
|
|
||||||
|
典型场景:
|
||||||
|
|
||||||
|
- 活动定制结算
|
||||||
|
- 奖章 / 解锁内容
|
||||||
|
- 排名 / 分享
|
||||||
|
- 作品提交
|
||||||
|
- 品牌化结束页
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 原生兜底原则
|
||||||
|
|
||||||
|
这是最重要的约束。
|
||||||
|
|
||||||
|
### 原则 1:核心流程先在原生完成
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- 打点成功必须先由原生确认
|
||||||
|
- 比赛结束必须先由原生确认
|
||||||
|
- H5 只是附加体验,不拥有核心状态
|
||||||
|
|
||||||
|
### 原则 2:H5 打不开时回退原生
|
||||||
|
|
||||||
|
如果:
|
||||||
|
|
||||||
|
- 网络失败
|
||||||
|
- H5 地址失效
|
||||||
|
- 加载超时
|
||||||
|
- Bridge 初始化失败
|
||||||
|
|
||||||
|
则直接回退:
|
||||||
|
|
||||||
|
- 原生内容卡
|
||||||
|
- 原生结果页
|
||||||
|
|
||||||
|
### 原则 3:H5 不控制比赛状态
|
||||||
|
|
||||||
|
H5 可以展示、收集信息、提交任务结果。
|
||||||
|
但不能决定:
|
||||||
|
|
||||||
|
- 是否打卡成功
|
||||||
|
- 是否比赛完成
|
||||||
|
- 是否跳点成功
|
||||||
|
|
||||||
|
这些只能由原生控制。
|
||||||
|
|
||||||
|
### 原则 4:H5 是可选增强,不是主流程依赖
|
||||||
|
|
||||||
|
即使 H5 没有打开:
|
||||||
|
|
||||||
|
- 游戏仍应可继续
|
||||||
|
- 用户仍能完成路线
|
||||||
|
- 用户仍能看到最小内容或最小结果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 配置模型建议
|
||||||
|
|
||||||
|
后续建议对“内容体验”和“结果体验”都支持两种类型:
|
||||||
|
|
||||||
|
- `native`
|
||||||
|
- `h5`
|
||||||
|
|
||||||
|
### 6.1 内容体验配置示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contentExperience": {
|
||||||
|
"type": "h5",
|
||||||
|
"url": "https://example.com/content/control-3",
|
||||||
|
"bridge": "content-v1",
|
||||||
|
"fallback": "native"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contentExperience": {
|
||||||
|
"type": "native"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 结果页配置示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"resultExperience": {
|
||||||
|
"type": "h5",
|
||||||
|
"url": "https://example.com/result/score-o",
|
||||||
|
"bridge": "result-v1",
|
||||||
|
"fallback": "native"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 建议扩展字段
|
||||||
|
|
||||||
|
后续还可以逐步加入:
|
||||||
|
|
||||||
|
- `template`
|
||||||
|
- `theme`
|
||||||
|
- `timeoutMs`
|
||||||
|
- `allowClose`
|
||||||
|
- `prefetch`
|
||||||
|
- `requiresNetwork`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 内容页与结果页的推荐职责
|
||||||
|
|
||||||
|
### 原生最小内容卡
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 最小图文说明
|
||||||
|
- 最小点击查看
|
||||||
|
- 自动弹出兜底
|
||||||
|
|
||||||
|
### H5 内容页
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 强样式定制
|
||||||
|
- 多媒体内容
|
||||||
|
- 任务型互动
|
||||||
|
- 客户活动包装
|
||||||
|
|
||||||
|
### 原生最小结果页
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 结果一定可见
|
||||||
|
- 成绩一定可回顾
|
||||||
|
- 无网络也能展示基础结果
|
||||||
|
|
||||||
|
### H5 结果页
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 品牌化包装
|
||||||
|
- 排名/分享
|
||||||
|
- 作品提交
|
||||||
|
- 奖章、解锁、收集册
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 性能与体验要求
|
||||||
|
|
||||||
|
H5 接入时必须注意:
|
||||||
|
|
||||||
|
- 不阻塞原生主流程
|
||||||
|
- 不把高频实时状态强行桥接到 H5
|
||||||
|
- 不在地图进行中频繁开重页面
|
||||||
|
- 低端机上优先简化交互和媒体资源
|
||||||
|
|
||||||
|
推荐策略:
|
||||||
|
|
||||||
|
- 内容详情页可以 H5
|
||||||
|
- 地图中高频反馈继续原生
|
||||||
|
- 结算增强页可以 H5
|
||||||
|
- 结果最小摘要必须原生兜底
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 当前建议实施顺序
|
||||||
|
|
||||||
|
### 第一步
|
||||||
|
|
||||||
|
先实现:
|
||||||
|
|
||||||
|
- 原生最小兜底内容卡
|
||||||
|
- 原生最小结果页
|
||||||
|
|
||||||
|
### 第二步
|
||||||
|
|
||||||
|
新增一个通用 H5 容器页,用于承接:
|
||||||
|
|
||||||
|
- 内容页
|
||||||
|
- 结果页
|
||||||
|
|
||||||
|
### 第三步
|
||||||
|
|
||||||
|
定义 Bridge 协议,并先支持最核心动作:
|
||||||
|
|
||||||
|
- 关闭
|
||||||
|
- 获取上下文
|
||||||
|
- 拍照
|
||||||
|
- 录音
|
||||||
|
- 提交结果
|
||||||
|
|
||||||
|
### 第四步
|
||||||
|
|
||||||
|
再让配置决定:
|
||||||
|
|
||||||
|
- 当前活动走原生
|
||||||
|
- 还是走 H5
|
||||||
|
|
||||||
|
### 第五步
|
||||||
|
|
||||||
|
最后再逐步扩到:
|
||||||
|
|
||||||
|
- 上传能力
|
||||||
|
- 分享能力
|
||||||
|
- 小游戏任务
|
||||||
|
- 作品提交
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 下一步建议
|
||||||
|
|
||||||
|
当前最适合的下一步不是直接写复杂 H5 页面,而是:
|
||||||
|
|
||||||
|
1. 先定义原生与 H5 的统一入口模型
|
||||||
|
2. 先把 Bridge 协议做小而稳
|
||||||
|
3. 先做一个通用 H5 容器页
|
||||||
|
4. 先让一个简单内容页或一个简单结果页跑通
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 当前建议结论
|
||||||
|
|
||||||
|
最稳的方案不是“把定制内容都做成 H5”,而是:
|
||||||
|
|
||||||
|
**原生保底,H5 承接定制体验。**
|
||||||
|
|
||||||
|
这样既能支持客户高频变化需求,也不会破坏核心游戏体验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 当前主体能力约束补充
|
||||||
|
|
||||||
|
最近实际排查已经确认:
|
||||||
|
|
||||||
|
- 当前最初使用的是**个人主体**小程序
|
||||||
|
|
||||||
|
在这个前提下,`web-view` 能力可能直接受到限制。
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- H5 页面本身可在浏览器打开
|
||||||
|
- 小程序里仍然可能无法通过 `web-view` 打开
|
||||||
|
|
||||||
|
因此当前 H5 接入方案需要增加一个现实前提:
|
||||||
|
|
||||||
|
### 12.1 个人主体下
|
||||||
|
|
||||||
|
可以先做:
|
||||||
|
|
||||||
|
- 容器页
|
||||||
|
- Bridge 协议
|
||||||
|
- 配置结构
|
||||||
|
- 原生兜底逻辑
|
||||||
|
|
||||||
|
但不要指望所有 H5 内容页都能在当前环境稳定跑通。
|
||||||
|
|
||||||
|
### 12.2 企业主体下
|
||||||
|
|
||||||
|
企业主体审核通过后,再优先回归:
|
||||||
|
|
||||||
|
- 最小 `web-view` 测试页
|
||||||
|
- 内容体验页 H5
|
||||||
|
- 结果页 H5
|
||||||
|
|
||||||
|
也就是说:
|
||||||
|
|
||||||
|
当前 H5 方案仍然成立,但在企业主体生效前,应按“预留 + 待验证”看待。
|
||||||
|
|
||||||
|
详细说明见:
|
||||||
|
|
||||||
|
- [platform-capability-notes.md](D:/dev/cmr-mini/doc/platform-capability-notes.md)
|
||||||
421
doc/hybrid-experience-architecture.md
Normal file
421
doc/hybrid-experience-architecture.md
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
# 混合体验架构方案
|
||||||
|
|
||||||
|
本文档用于说明当前项目在 **结果页、文创内容页、客户定制体验页** 上的长期承载方案。
|
||||||
|
|
||||||
|
核心结论:
|
||||||
|
|
||||||
|
**不做“原生还是 H5 二选一”,而是采用三层混合方案:**
|
||||||
|
|
||||||
|
- 原生模板
|
||||||
|
- 原生有限 DSL
|
||||||
|
- H5 扩展页
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 为什么需要混合方案
|
||||||
|
|
||||||
|
当前项目已经确认:
|
||||||
|
|
||||||
|
- 结果页经常会变
|
||||||
|
- 打点弹出内容经常会变
|
||||||
|
- 样式、内容、交互形式会随着客户需求变化
|
||||||
|
- 有些内容还会带动作:
|
||||||
|
- 拍照上传
|
||||||
|
- 语音留言
|
||||||
|
- 小游戏
|
||||||
|
- 表单与任务
|
||||||
|
|
||||||
|
如果全部原生写死:
|
||||||
|
|
||||||
|
- 改版成本高
|
||||||
|
- 每次都要改页面代码
|
||||||
|
- 客户变化一多就会拖慢开发
|
||||||
|
|
||||||
|
如果全部交给 H5:
|
||||||
|
|
||||||
|
- 核心体验不稳
|
||||||
|
- 地图主流程会割裂
|
||||||
|
- 性能和权限整合更麻烦
|
||||||
|
|
||||||
|
因此最适合的方案是:
|
||||||
|
|
||||||
|
**核心高频体验保留原生,灵活变化部分分层处理。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 总体结构
|
||||||
|
|
||||||
|
建议未来的体验承载层分成三层:
|
||||||
|
|
||||||
|
### 2.1 原生模板层
|
||||||
|
|
||||||
|
用于承接:
|
||||||
|
|
||||||
|
- 最小结果页
|
||||||
|
- 最小内容卡
|
||||||
|
- 高频、必须稳的体验页
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- 结构固定
|
||||||
|
- 数据可变
|
||||||
|
- 主题可换
|
||||||
|
- 性能最好
|
||||||
|
- 原生能力最完整
|
||||||
|
|
||||||
|
### 2.2 原生有限 DSL 层
|
||||||
|
|
||||||
|
用于承接:
|
||||||
|
|
||||||
|
- 结构变化较多,但组件种类有限的页面
|
||||||
|
- 结果页区块组合
|
||||||
|
- 内容页区块组合
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- 不是万能布局引擎
|
||||||
|
- 只支持有限区块和有限顺序配置
|
||||||
|
- 比固定模板灵活
|
||||||
|
- 比 H5 更稳
|
||||||
|
|
||||||
|
### 2.3 H5 扩展层
|
||||||
|
|
||||||
|
用于承接:
|
||||||
|
|
||||||
|
- 强定制内容
|
||||||
|
- 富图文内容
|
||||||
|
- 品牌化包装
|
||||||
|
- 富交互任务页
|
||||||
|
- 定制结算页
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- 自由度最高
|
||||||
|
- 改版速度最快
|
||||||
|
- 最适合客户高频定制
|
||||||
|
- 但必须有原生兜底
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 三层分别适合什么
|
||||||
|
|
||||||
|
## 3.1 原生模板适合
|
||||||
|
|
||||||
|
- 高频结果页
|
||||||
|
- 高频内容卡
|
||||||
|
- 游戏过程中的即时反馈页
|
||||||
|
- 必须流畅、必须可离线的页面
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
- 最小顺序赛结算页
|
||||||
|
- 最小积分赛结算页
|
||||||
|
- 控制点原生内容卡
|
||||||
|
- 默认结束页
|
||||||
|
|
||||||
|
## 3.2 原生有限 DSL 适合
|
||||||
|
|
||||||
|
- 区块顺序常变
|
||||||
|
- 字段组合常变
|
||||||
|
- 样式变化不至于重做到 H5
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
- 结果页:
|
||||||
|
- 先显示成绩还是先显示摘要
|
||||||
|
- 哪些统计项出现
|
||||||
|
- 操作区放几个按钮
|
||||||
|
- 内容页:
|
||||||
|
- 是否显示图片
|
||||||
|
- 是否显示引用说明
|
||||||
|
- 是否显示附加说明区块
|
||||||
|
|
||||||
|
## 3.3 H5 适合
|
||||||
|
|
||||||
|
- 品牌化结算页
|
||||||
|
- 长图文故事页
|
||||||
|
- 拍照上传任务
|
||||||
|
- 语音留言页
|
||||||
|
- 小游戏互动页
|
||||||
|
- 活动专题页
|
||||||
|
- 高自由度客户定制页
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 边界原则
|
||||||
|
|
||||||
|
## 4.1 原生负责核心游戏体验
|
||||||
|
|
||||||
|
原生必须继续负责:
|
||||||
|
|
||||||
|
- 地图
|
||||||
|
- GPS / 指北针 / 自动转图
|
||||||
|
- 打点逻辑
|
||||||
|
- HUD
|
||||||
|
- 核心状态机
|
||||||
|
- 最小结果页
|
||||||
|
- 最小内容页
|
||||||
|
|
||||||
|
## 4.2 H5 只负责增强体验
|
||||||
|
|
||||||
|
H5 不应该负责:
|
||||||
|
|
||||||
|
- 打点是否成功
|
||||||
|
- 比赛是否结束
|
||||||
|
- 核心状态推进
|
||||||
|
- 实时地图交互
|
||||||
|
|
||||||
|
H5 只负责:
|
||||||
|
|
||||||
|
- 内容展示
|
||||||
|
- 任务互动
|
||||||
|
- 品牌包装
|
||||||
|
- 富交互增强体验
|
||||||
|
|
||||||
|
## 4.3 原生永远保底
|
||||||
|
|
||||||
|
无论 H5 是否接入,原生都必须保证:
|
||||||
|
|
||||||
|
- 打点后至少能看到原生内容
|
||||||
|
- 结束后至少能看到原生结果页
|
||||||
|
- H5 打不开时,主流程不受影响
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 推荐的数据流
|
||||||
|
|
||||||
|
建议统一做成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
游戏状态 / 内容数据 / 结果数据
|
||||||
|
↓
|
||||||
|
ViewModel
|
||||||
|
↓
|
||||||
|
Native Template / Native DSL / H5
|
||||||
|
↓
|
||||||
|
用户界面
|
||||||
|
```
|
||||||
|
|
||||||
|
这里最关键的是:
|
||||||
|
|
||||||
|
- 数据模型稳定
|
||||||
|
- 展示方式可换
|
||||||
|
|
||||||
|
也就是:
|
||||||
|
|
||||||
|
**先稳定 ViewModel,再让模板与承载方式变化。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ViewModel 的作用
|
||||||
|
|
||||||
|
ViewModel 是原生模板、原生 DSL、H5 都共用的中间层。
|
||||||
|
|
||||||
|
例如结果页:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "result-summary",
|
||||||
|
"title": "比赛结束",
|
||||||
|
"subtitle": "校园积分赛",
|
||||||
|
"hero": {
|
||||||
|
"label": "总分",
|
||||||
|
"value": "120"
|
||||||
|
},
|
||||||
|
"stats": [
|
||||||
|
{ "key": "duration", "label": "用时", "value": "23:18" },
|
||||||
|
{ "key": "distance", "label": "里程", "value": "3.2km" },
|
||||||
|
{ "key": "controls", "label": "完成点", "value": "8/8" }
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{ "key": "restart", "label": "再来一局" },
|
||||||
|
{ "key": "close", "label": "返回地图" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
例如内容页:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "content-card",
|
||||||
|
"title": "湖边步道",
|
||||||
|
"body": "这里适合短暂停留观察周边地形。",
|
||||||
|
"image": "",
|
||||||
|
"cta": "继续"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这样以后无论:
|
||||||
|
|
||||||
|
- 原生模板
|
||||||
|
- 原生 DSL
|
||||||
|
- H5
|
||||||
|
|
||||||
|
都可以消费同一份结构化数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 原生模板层建议
|
||||||
|
|
||||||
|
建议先做有限几个模板:
|
||||||
|
|
||||||
|
### 结果页模板
|
||||||
|
|
||||||
|
- `result-minimal`
|
||||||
|
- `result-rich`
|
||||||
|
|
||||||
|
### 内容页模板
|
||||||
|
|
||||||
|
- `content-card-minimal`
|
||||||
|
- `content-card-story`
|
||||||
|
|
||||||
|
模板负责:
|
||||||
|
|
||||||
|
- 布局
|
||||||
|
- 区块顺序
|
||||||
|
- 基础动画与交互
|
||||||
|
|
||||||
|
模板不负责:
|
||||||
|
|
||||||
|
- 业务逻辑
|
||||||
|
- 数据计算
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 原生有限 DSL 建议
|
||||||
|
|
||||||
|
不要做万能布局引擎,只做有限区块编排。
|
||||||
|
|
||||||
|
例如结果页:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"templateType": "result-native-dsl",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"type": "hero",
|
||||||
|
"field": "score"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stats-grid",
|
||||||
|
"fields": ["duration", "distance", "controls"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "actions",
|
||||||
|
"items": ["restart", "close"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
例如内容页:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"templateType": "content-native-dsl",
|
||||||
|
"sections": [
|
||||||
|
{ "type": "title" },
|
||||||
|
{ "type": "body" },
|
||||||
|
{ "type": "image" },
|
||||||
|
{ "type": "actions", "items": ["continue", "openH5"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
原则是:
|
||||||
|
|
||||||
|
- 区块种类有限
|
||||||
|
- 顺序可配置
|
||||||
|
- 不支持无限自由布局
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. H5 扩展层建议
|
||||||
|
|
||||||
|
H5 主要用于高自由度需求。
|
||||||
|
|
||||||
|
建议先只承接两类页面:
|
||||||
|
|
||||||
|
### 9.1 Content Experience Page
|
||||||
|
|
||||||
|
用于:
|
||||||
|
|
||||||
|
- 打点后的定制内容页
|
||||||
|
- 富图文详情
|
||||||
|
- 拍照上传任务
|
||||||
|
- 语音留言
|
||||||
|
- 小游戏互动
|
||||||
|
|
||||||
|
### 9.2 Result Experience Page
|
||||||
|
|
||||||
|
用于:
|
||||||
|
|
||||||
|
- 定制结算页
|
||||||
|
- 成绩包装页
|
||||||
|
- 奖章 / 排名 / 分享页
|
||||||
|
|
||||||
|
但要注意:
|
||||||
|
|
||||||
|
- H5 只是增强页
|
||||||
|
- 原生始终保留最小兜底
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 推荐优先级
|
||||||
|
|
||||||
|
不建议直接跳到 H5 大量实现。
|
||||||
|
|
||||||
|
推荐顺序:
|
||||||
|
|
||||||
|
### 第一步
|
||||||
|
|
||||||
|
先把原生模板层打稳:
|
||||||
|
|
||||||
|
- 原生最小结果页
|
||||||
|
- 原生最小内容卡
|
||||||
|
|
||||||
|
### 第二步
|
||||||
|
|
||||||
|
再做原生有限 DSL:
|
||||||
|
|
||||||
|
- 支撑中等复杂的客户差异化需求
|
||||||
|
|
||||||
|
### 第三步
|
||||||
|
|
||||||
|
最后再把 H5 扩展层接稳:
|
||||||
|
|
||||||
|
- 处理真正高自由度场景
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 对当前项目的建议
|
||||||
|
|
||||||
|
结合当前项目现状,建议:
|
||||||
|
|
||||||
|
### 当前优先做
|
||||||
|
|
||||||
|
- 原生内容卡继续完善
|
||||||
|
- 原生结果页继续完善
|
||||||
|
- 先把 ViewModel 定稳
|
||||||
|
|
||||||
|
### 中期做
|
||||||
|
|
||||||
|
- 原生结果页的有限 DSL
|
||||||
|
- 原生内容页的有限 DSL
|
||||||
|
|
||||||
|
### 后期做
|
||||||
|
|
||||||
|
- H5 内容页
|
||||||
|
- H5 结果页
|
||||||
|
- Bridge 能力逐步扩充
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 一句话结论
|
||||||
|
|
||||||
|
最适合当前项目的长期方案不是单押 H5,也不是所有东西都原生写死,而是:
|
||||||
|
|
||||||
|
**原生模板保底、原生有限 DSL 承担中度变化、H5 承担高定制内容。**
|
||||||
|
|
||||||
|
这三层结合起来,既能保证核心体验稳定,也能承接客户高频变化需求。
|
||||||
381
doc/native-h5-bridge-spec.md
Normal file
381
doc/native-h5-bridge-spec.md
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
# 原生与 H5 Bridge 协议草案
|
||||||
|
|
||||||
|
本文档定义当前项目中 **原生小程序** 与 **H5 定制内容页** 之间的基础通信协议。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 让 H5 能获取当前游戏上下文
|
||||||
|
- 让 H5 能请求原生能力
|
||||||
|
- 让原生能接收 H5 的结果回传
|
||||||
|
- 保持协议简单、稳定、可版本化
|
||||||
|
- 为后续拍照、录音、小游戏、结果页等扩展留出空间
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 当前适用前提
|
||||||
|
|
||||||
|
本规范当前属于:
|
||||||
|
|
||||||
|
- 协议与实现预留
|
||||||
|
- 容器与回退机制先行
|
||||||
|
|
||||||
|
最近排查已经确认,当前最初使用的是**个人主体**小程序。
|
||||||
|
在这个前提下,`web-view` 能力本身可能受限。
|
||||||
|
|
||||||
|
因此:
|
||||||
|
|
||||||
|
- Bridge 规范仍然应该先定义
|
||||||
|
- 容器页与回退机制也应该先实现
|
||||||
|
- 但在企业主体审核通过前,不应把 H5 接入是否成功完全归因于 bridge 代码本身
|
||||||
|
|
||||||
|
详细说明见:
|
||||||
|
|
||||||
|
- [platform-capability-notes.md](D:/dev/cmr-mini/doc/platform-capability-notes.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 协议原则
|
||||||
|
|
||||||
|
### 原则 1:Bridge 要版本化
|
||||||
|
|
||||||
|
建议先固定:
|
||||||
|
|
||||||
|
- `content-v1`
|
||||||
|
- `result-v1`
|
||||||
|
|
||||||
|
后续升级时:
|
||||||
|
|
||||||
|
- 新增 `content-v2`
|
||||||
|
- 新增 `result-v2`
|
||||||
|
|
||||||
|
不要直接改旧协议。
|
||||||
|
|
||||||
|
### 原则 2:请求能力最小化
|
||||||
|
|
||||||
|
先只开放真正需要的能力,不要一开始做成“大而全总线”。
|
||||||
|
|
||||||
|
### 原则 3:原生控制核心状态
|
||||||
|
|
||||||
|
Bridge 只能做:
|
||||||
|
|
||||||
|
- 展示
|
||||||
|
- 上报
|
||||||
|
- 请求能力
|
||||||
|
|
||||||
|
不能让 H5 直接改比赛核心状态。
|
||||||
|
|
||||||
|
### 原则 4:消息必须可回执
|
||||||
|
|
||||||
|
每个请求都应有明确成功/失败返回,不允许 H5 靠超时猜测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 通道模型
|
||||||
|
|
||||||
|
建议统一按“请求 / 响应 / 事件”三类消息组织:
|
||||||
|
|
||||||
|
- `request`
|
||||||
|
H5 请求原生能力
|
||||||
|
- `response`
|
||||||
|
原生返回能力执行结果
|
||||||
|
- `event`
|
||||||
|
原生主动推送状态变化
|
||||||
|
|
||||||
|
推荐消息外壳:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "req-001",
|
||||||
|
"channel": "request",
|
||||||
|
"type": "getGameContext",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "req-001",
|
||||||
|
"channel": "response",
|
||||||
|
"type": "getGameContext",
|
||||||
|
"ok": true,
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 原生注入给 H5 的基础上下文
|
||||||
|
|
||||||
|
建议至少包含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bridgeVersion": "content-v1",
|
||||||
|
"eventId": "sample-score-o-001",
|
||||||
|
"mode": "score-o",
|
||||||
|
"sessionId": "session-001",
|
||||||
|
"sessionStatus": "running",
|
||||||
|
"controlId": "control-3",
|
||||||
|
"controlKind": "control",
|
||||||
|
"title": "湖边步道",
|
||||||
|
"body": "这里适合短暂停留观察周边地形。",
|
||||||
|
"theme": "default-race"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
对于结果页,可扩展为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bridgeVersion": "result-v1",
|
||||||
|
"eventId": "sample-score-o-001",
|
||||||
|
"mode": "score-o",
|
||||||
|
"sessionId": "session-001",
|
||||||
|
"summary": {
|
||||||
|
"title": "比赛结束",
|
||||||
|
"heroValue": "120",
|
||||||
|
"rows": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. H5 -> 原生:第一阶段推荐动作
|
||||||
|
|
||||||
|
建议第一阶段只支持这几个:
|
||||||
|
|
||||||
|
### `close`
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 关闭当前 H5 页面
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "req-001",
|
||||||
|
"channel": "request",
|
||||||
|
"type": "close",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `getGameContext`
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 让 H5 主动获取最新上下文
|
||||||
|
|
||||||
|
### `takePhoto`
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 请求原生拍照
|
||||||
|
|
||||||
|
### `recordAudio`
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 请求原生录音
|
||||||
|
|
||||||
|
### `submitResult`
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 把 H5 内的任务结果、表单或作品结果提交回原生
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "req-002",
|
||||||
|
"channel": "request",
|
||||||
|
"type": "submitResult",
|
||||||
|
"payload": {
|
||||||
|
"taskId": "photo-task-1",
|
||||||
|
"status": "completed",
|
||||||
|
"assetId": "img-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 建议第二阶段可扩展动作
|
||||||
|
|
||||||
|
等第一阶段跑稳后,再逐步加入:
|
||||||
|
|
||||||
|
- `uploadImage`
|
||||||
|
- `uploadAudio`
|
||||||
|
- `getLocation`
|
||||||
|
- `openMiniGame`
|
||||||
|
- `submitForm`
|
||||||
|
- `share`
|
||||||
|
- `restartSession`
|
||||||
|
|
||||||
|
这些先不要第一阶段全开。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 原生 -> H5:统一返回结构
|
||||||
|
|
||||||
|
建议统一返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "req-002",
|
||||||
|
"channel": "response",
|
||||||
|
"type": "takePhoto",
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"assetId": "img-001",
|
||||||
|
"url": "https://example.com/assets/img-001.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "req-002",
|
||||||
|
"channel": "response",
|
||||||
|
"type": "takePhoto",
|
||||||
|
"ok": false,
|
||||||
|
"error": {
|
||||||
|
"code": "USER_CANCELLED",
|
||||||
|
"message": "用户取消拍照"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 原生 -> H5:推荐事件
|
||||||
|
|
||||||
|
原生可按需主动推送轻量事件:
|
||||||
|
|
||||||
|
- `contextUpdated`
|
||||||
|
- `sessionFinished`
|
||||||
|
- `sessionExited`
|
||||||
|
- `networkChanged`
|
||||||
|
|
||||||
|
但第一阶段要克制,避免高频推送。
|
||||||
|
|
||||||
|
不建议第一阶段主动高频推:
|
||||||
|
|
||||||
|
- GPS 实时位置流
|
||||||
|
- 指北针实时角度
|
||||||
|
- HUD 高频数字
|
||||||
|
|
||||||
|
这些不适合让 H5 主导。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 错误码建议
|
||||||
|
|
||||||
|
建议第一阶段统一几类错误:
|
||||||
|
|
||||||
|
- `USER_CANCELLED`
|
||||||
|
- `PERMISSION_DENIED`
|
||||||
|
- `NETWORK_ERROR`
|
||||||
|
- `UNSUPPORTED_ACTION`
|
||||||
|
- `BRIDGE_NOT_READY`
|
||||||
|
- `INTERNAL_ERROR`
|
||||||
|
|
||||||
|
这样 H5 侧更容易统一处理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 安全与边界
|
||||||
|
|
||||||
|
### 9.1 H5 不直接改核心比赛状态
|
||||||
|
|
||||||
|
H5 不能直接决定:
|
||||||
|
|
||||||
|
- 是否打点成功
|
||||||
|
- 是否跳点成功
|
||||||
|
- 是否比赛结束
|
||||||
|
|
||||||
|
### 9.2 H5 只能请求能力
|
||||||
|
|
||||||
|
原生决定是否执行:
|
||||||
|
|
||||||
|
- 拍照
|
||||||
|
- 录音
|
||||||
|
- 上传
|
||||||
|
- 页面关闭
|
||||||
|
|
||||||
|
### 9.3 Bridge 能力按页面类型开放
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- 内容页开放 `takePhoto`
|
||||||
|
- 结果页不一定开放
|
||||||
|
|
||||||
|
后续可做按 `bridgeVersion` 或 `pageType` 的能力白名单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 第一阶段推荐支持范围
|
||||||
|
|
||||||
|
建议第一阶段只正式支持:
|
||||||
|
|
||||||
|
- `close`
|
||||||
|
- `getGameContext`
|
||||||
|
- `takePhoto`
|
||||||
|
- `recordAudio`
|
||||||
|
- `submitResult`
|
||||||
|
|
||||||
|
这样足够承接:
|
||||||
|
|
||||||
|
- 文创详情
|
||||||
|
- 拍照任务
|
||||||
|
- 语音留言
|
||||||
|
- 结果页回传动作
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 不建议第一阶段支持的内容
|
||||||
|
|
||||||
|
先不要一上来开放:
|
||||||
|
|
||||||
|
- 任意写比赛状态
|
||||||
|
- 任意切换玩法
|
||||||
|
- 任意修改地图行为
|
||||||
|
- 任意控制打点
|
||||||
|
- 高频实时 telemetry 推送
|
||||||
|
|
||||||
|
这些都属于核心状态,应该继续由原生掌控。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 当前建议实施顺序
|
||||||
|
|
||||||
|
1. 先实现一个通用 H5 容器页
|
||||||
|
2. 先跑通 `content-v1`
|
||||||
|
3. 先支持 5 个最小动作
|
||||||
|
4. 再跑通一个简单结果页
|
||||||
|
5. 最后再扩桥接能力
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 当前建议结论
|
||||||
|
|
||||||
|
Bridge 的第一阶段目标,不是做成万能总线,而是:
|
||||||
|
|
||||||
|
**稳定承接定制内容页与结果页的最小需求。**
|
||||||
|
|
||||||
|
先把:
|
||||||
|
|
||||||
|
- 关闭
|
||||||
|
- 获取上下文
|
||||||
|
- 拍照
|
||||||
|
- 录音
|
||||||
|
- 结果提交
|
||||||
|
|
||||||
|
这 5 条做稳,就足够支撑第一波客户定制需求。
|
||||||
144
doc/platform-capability-notes.md
Normal file
144
doc/platform-capability-notes.md
Normal file
@@ -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 和高级传感器按“预留 + 待验证”处理
|
||||||
|
- 待企业主体生效后,再统一回归验证
|
||||||
@@ -199,3 +199,36 @@
|
|||||||
- `Compass`
|
- `Compass`
|
||||||
|
|
||||||
其余能力更多承担辅助、调试、反馈和后续扩展输入的角色。
|
其余能力更多承担辅助、调试、反馈和后续扩展输入的角色。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 当前主体能力边界补充
|
||||||
|
|
||||||
|
最近排查已经确认:
|
||||||
|
|
||||||
|
- 当前最初使用的是**个人主体**小程序
|
||||||
|
|
||||||
|
这会影响部分设备能力的可用性与稳定性,尤其是:
|
||||||
|
|
||||||
|
- `Compass`
|
||||||
|
- `Accelerometer`
|
||||||
|
- 与 `web-view` 相关的扩展体验链
|
||||||
|
|
||||||
|
因此当前这份传感器结论要加一个前提:
|
||||||
|
|
||||||
|
**它不仅受到代码实现影响,也受到小程序主体能力边界影响。**
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- 某些 Android 上的样本异常,不一定是算法错误
|
||||||
|
- 某些 H5 / 传感器问题,不一定能在个人主体下彻底解决
|
||||||
|
|
||||||
|
当前建议:
|
||||||
|
|
||||||
|
- 原生主流程继续开发
|
||||||
|
- 传感器高级能力与 H5 接入先保留设计与代码入口
|
||||||
|
- 等企业主体切换完成后,再做专项回归
|
||||||
|
|
||||||
|
详细说明见:
|
||||||
|
|
||||||
|
- [platform-capability-notes.md](D:/dev/cmr-mini/doc/platform-capability-notes.md)
|
||||||
161
event/classic-sequential.json
Normal file
161
event/classic-sequential.json
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1",
|
||||||
|
"version": "2026.03.25",
|
||||||
|
"app": {
|
||||||
|
"id": "sample-classic-001",
|
||||||
|
"title": "顺序赛示例",
|
||||||
|
"locale": "zh-CN"
|
||||||
|
},
|
||||||
|
"map": {
|
||||||
|
"tiles": "../map/lxcb-001/tiles/",
|
||||||
|
"mapmeta": "../map/lxcb-001/tiles/meta.json",
|
||||||
|
"declination": 6.91,
|
||||||
|
"initialView": {
|
||||||
|
"zoom": 17
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playfield": {
|
||||||
|
"kind": "course",
|
||||||
|
"source": {
|
||||||
|
"type": "kml",
|
||||||
|
"url": "../kml/lxcb-001/10/c01.kml"
|
||||||
|
},
|
||||||
|
"CPRadius": 6,
|
||||||
|
"controlOverrides": {
|
||||||
|
"start-1": {
|
||||||
|
"title": "比赛开始",
|
||||||
|
"body": "从这里出发,先熟悉地图方向,再推进到第一个目标点。",
|
||||||
|
"autoPopup": true,
|
||||||
|
"once": true,
|
||||||
|
"priority": 1,
|
||||||
|
"contentExperience": {
|
||||||
|
"type": "h5",
|
||||||
|
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
||||||
|
"bridge": "content-v1"
|
||||||
|
},
|
||||||
|
"clickTitle": "起点说明",
|
||||||
|
"clickBody": "点击起点可再次查看起跑说明与路线背景。",
|
||||||
|
"clickExperience": {
|
||||||
|
"type": "h5",
|
||||||
|
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
||||||
|
"bridge": "content-v1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"control-1": {
|
||||||
|
"title": "图书馆前广场",
|
||||||
|
"body": "这是第一检查点,完成后沿主路继续前进。",
|
||||||
|
"autoPopup": true,
|
||||||
|
"once": false,
|
||||||
|
"priority": 1,
|
||||||
|
"clickTitle": "图书馆前广场",
|
||||||
|
"clickBody": "这里是顺序赛的首个关键点位,适合确认路线方向。",
|
||||||
|
"contentExperience": {
|
||||||
|
"type": "h5",
|
||||||
|
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
||||||
|
"bridge": "content-v1"
|
||||||
|
},
|
||||||
|
"clickExperience": {
|
||||||
|
"type": "h5",
|
||||||
|
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
||||||
|
"bridge": "content-v1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"control-2": {
|
||||||
|
"title": "教学楼南侧",
|
||||||
|
"body": "注意这里地形开阔,适合快速判断下一段方向。",
|
||||||
|
"autoPopup": false,
|
||||||
|
"once": true,
|
||||||
|
"priority": 1,
|
||||||
|
"clickTitle": "教学楼南侧",
|
||||||
|
"clickBody": "这个点配置成点击查看,经过时不会自动弹出。",
|
||||||
|
"clickExperience": {
|
||||||
|
"type": "h5",
|
||||||
|
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
||||||
|
"bridge": "content-v1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"control-3": {
|
||||||
|
"title": "湖边步道",
|
||||||
|
"body": "经过这里时可以观察水边和林带的边界关系。",
|
||||||
|
"autoPopup": true,
|
||||||
|
"once": false,
|
||||||
|
"priority": 1,
|
||||||
|
"clickTitle": "湖边步道",
|
||||||
|
"clickBody": "点击可查看更详细的路线观察建议。"
|
||||||
|
},
|
||||||
|
"finish-1": {
|
||||||
|
"title": "终点到达",
|
||||||
|
"body": "恭喜完成本次顺序赛,准备查看结果。",
|
||||||
|
"autoPopup": true,
|
||||||
|
"once": true,
|
||||||
|
"priority": 2,
|
||||||
|
"clickTitle": "终点说明",
|
||||||
|
"clickBody": "点击终点可再次查看本局结束说明。",
|
||||||
|
"clickExperience": {
|
||||||
|
"type": "h5",
|
||||||
|
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
||||||
|
"bridge": "content-v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"title": "顺序赛路线示例",
|
||||||
|
"code": "classic-001"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"game": {
|
||||||
|
"mode": "classic-sequential",
|
||||||
|
"rulesVersion": "1",
|
||||||
|
"session": {
|
||||||
|
"startManually": true,
|
||||||
|
"requiresStartPunch": true,
|
||||||
|
"requiresFinishPunch": true,
|
||||||
|
"autoFinishOnLastControl": false,
|
||||||
|
"maxDurationSec": 5400
|
||||||
|
},
|
||||||
|
"punch": {
|
||||||
|
"policy": "enter-confirm",
|
||||||
|
"radiusMeters": 5
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"skip": {
|
||||||
|
"enabled": true,
|
||||||
|
"radiusMeters": 30,
|
||||||
|
"requiresConfirm": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guidance": {
|
||||||
|
"showLegs": true,
|
||||||
|
"legAnimation": true,
|
||||||
|
"allowFocusSelection": false
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"revealFullPlayfieldAfterStartPunch": true
|
||||||
|
},
|
||||||
|
"finish": {
|
||||||
|
"finishControlAlwaysSelectable": false
|
||||||
|
},
|
||||||
|
"telemetry": {
|
||||||
|
"heartRate": {
|
||||||
|
"age": 30,
|
||||||
|
"restingHeartRateBpm": 62,
|
||||||
|
"userWeightKg": 65
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"audioProfile": "default",
|
||||||
|
"hapticsProfile": "default",
|
||||||
|
"uiEffectsProfile": "default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"audioProfile": "default",
|
||||||
|
"contentProfile": "default",
|
||||||
|
"themeProfile": "default-race"
|
||||||
|
},
|
||||||
|
"debug": {
|
||||||
|
"allowModeSwitch": false,
|
||||||
|
"allowMockInput": false,
|
||||||
|
"allowSimulator": false
|
||||||
|
}
|
||||||
|
}
|
||||||
198
event/content-h5-test-template.html
Normal file
198
event/content-h5-test-template.html
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<title>CMR 内容体验测试页</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f4f8f5;
|
||||||
|
--fg: #173127;
|
||||||
|
--muted: #5e6f65;
|
||||||
|
--accent: #1f7a5a;
|
||||||
|
--accent-soft: #dcefe6;
|
||||||
|
--card: rgba(255, 255, 255, 0.9);
|
||||||
|
--border: rgba(23, 49, 39, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(31, 122, 90, 0.14), transparent 32%),
|
||||||
|
linear-gradient(180deg, #eef6f1 0%, var(--bg) 100%);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 28px 18px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 18px 42px rgba(24, 49, 39, 0.12);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(23, 49, 39, 0.08);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<section class="card">
|
||||||
|
<p class="eyebrow">content-v1 test</p>
|
||||||
|
<h1 id="title">内容体验测试页</h1>
|
||||||
|
<p class="lead" id="desc">
|
||||||
|
这个页面用于验证小程序内容 H5 容器、上下文传参和关闭/回退链路。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-primary" id="closeBtn">关闭并返回小程序</button>
|
||||||
|
<button class="btn-secondary" id="fallbackBtn">模拟失败并回退原生卡片</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="context">
|
||||||
|
<pre id="contextView">loading...</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getQueryParam(key) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get(key) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContext() {
|
||||||
|
const raw = getQueryParam("cmrContext");
|
||||||
|
if (!raw) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
return { parseError: String(error), raw };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function postToMiniProgram(action, payload) {
|
||||||
|
const message = { action, payload: payload || {} };
|
||||||
|
if (
|
||||||
|
window.wx &&
|
||||||
|
window.wx.miniProgram &&
|
||||||
|
typeof window.wx.miniProgram.postMessage === "function"
|
||||||
|
) {
|
||||||
|
window.wx.miniProgram.postMessage({ data: message });
|
||||||
|
} else if (
|
||||||
|
window.parent &&
|
||||||
|
typeof window.parent.postMessage === "function"
|
||||||
|
) {
|
||||||
|
window.parent.postMessage(message, "*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = parseContext();
|
||||||
|
const titleEl = document.getElementById("title");
|
||||||
|
const descEl = document.getElementById("desc");
|
||||||
|
const contextViewEl = document.getElementById("contextView");
|
||||||
|
|
||||||
|
if (context.title) {
|
||||||
|
titleEl.textContent = context.title;
|
||||||
|
}
|
||||||
|
if (context.body) {
|
||||||
|
descEl.textContent = context.body;
|
||||||
|
}
|
||||||
|
contextViewEl.textContent = JSON.stringify(
|
||||||
|
{
|
||||||
|
bridge: getQueryParam("cmrBridge"),
|
||||||
|
kind: getQueryParam("cmrKind"),
|
||||||
|
context,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
document.getElementById("closeBtn").addEventListener("click", function () {
|
||||||
|
postToMiniProgram("close");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("fallbackBtn").addEventListener("click", function () {
|
||||||
|
postToMiniProgram("fallback");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
177
event/score-o.json
Normal file
177
event/score-o.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
"pages/map/map",
|
"pages/map/map",
|
||||||
|
"pages/experience-webview/experience-webview",
|
||||||
|
"pages/webview-test/webview-test",
|
||||||
"pages/index/index",
|
"pages/index/index",
|
||||||
"pages/logs/logs"
|
"pages/logs/logs"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
|||||||
import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
|
import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
|
||||||
import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel'
|
import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel'
|
||||||
import { GameRuntime } from '../../game/core/gameRuntime'
|
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 { type GameEffect, type GameResult } from '../../game/core/gameResult'
|
||||||
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
|
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
|
||||||
import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
|
import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
|
||||||
@@ -228,6 +229,8 @@ export interface MapEngineViewState {
|
|||||||
contentCardVisible: boolean
|
contentCardVisible: boolean
|
||||||
contentCardTitle: string
|
contentCardTitle: string
|
||||||
contentCardBody: string
|
contentCardBody: string
|
||||||
|
pendingContentEntryVisible: boolean
|
||||||
|
pendingContentEntryText: string
|
||||||
punchButtonFxClass: string
|
punchButtonFxClass: string
|
||||||
panelProgressFxClass: string
|
panelProgressFxClass: string
|
||||||
panelDistanceFxClass: string
|
panelDistanceFxClass: string
|
||||||
@@ -245,6 +248,18 @@ export interface MapEngineViewState {
|
|||||||
|
|
||||||
export interface MapEngineCallbacks {
|
export interface MapEngineCallbacks {
|
||||||
onData: (patch: Partial<MapEngineViewState>) => void
|
onData: (patch: Partial<MapEngineViewState>) => void
|
||||||
|
onOpenH5Experience?: (request: H5ExperienceRequest) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentCardEntry {
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
motionClass: string
|
||||||
|
contentKey: string
|
||||||
|
once: boolean
|
||||||
|
priority: number
|
||||||
|
autoPopup: boolean
|
||||||
|
h5Request: H5ExperienceRequest | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapEngineGameInfoRow {
|
export interface MapEngineGameInfoRow {
|
||||||
@@ -368,6 +383,8 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
|||||||
'contentCardVisible',
|
'contentCardVisible',
|
||||||
'contentCardTitle',
|
'contentCardTitle',
|
||||||
'contentCardBody',
|
'contentCardBody',
|
||||||
|
'pendingContentEntryVisible',
|
||||||
|
'pendingContentEntryText',
|
||||||
'punchButtonFxClass',
|
'punchButtonFxClass',
|
||||||
'panelProgressFxClass',
|
'panelProgressFxClass',
|
||||||
'panelDistanceFxClass',
|
'panelDistanceFxClass',
|
||||||
@@ -889,17 +906,22 @@ export class MapEngine {
|
|||||||
contentCardTimer: number
|
contentCardTimer: number
|
||||||
currentContentCardPriority: number
|
currentContentCardPriority: number
|
||||||
shownContentCardKeys: Record<string, true>
|
shownContentCardKeys: Record<string, true>
|
||||||
|
currentContentCard: ContentCardEntry | null
|
||||||
|
pendingContentCards: ContentCardEntry[]
|
||||||
|
currentH5ExperienceOpen: boolean
|
||||||
mapPulseTimer: number
|
mapPulseTimer: number
|
||||||
stageFxTimer: number
|
stageFxTimer: number
|
||||||
sessionTimerInterval: number
|
sessionTimerInterval: number
|
||||||
hasGpsCenteredOnce: boolean
|
hasGpsCenteredOnce: boolean
|
||||||
gpsLockEnabled: boolean
|
gpsLockEnabled: boolean
|
||||||
|
onOpenH5Experience?: (request: H5ExperienceRequest) => void
|
||||||
|
|
||||||
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
||||||
this.buildVersion = buildVersion
|
this.buildVersion = buildVersion
|
||||||
this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync())
|
this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync())
|
||||||
this.compassTuningProfile = 'balanced'
|
this.compassTuningProfile = 'balanced'
|
||||||
this.onData = callbacks.onData
|
this.onData = callbacks.onData
|
||||||
|
this.onOpenH5Experience = callbacks.onOpenH5Experience
|
||||||
this.accelerometerErrorText = null
|
this.accelerometerErrorText = null
|
||||||
this.renderer = new WebGLMapRenderer(
|
this.renderer = new WebGLMapRenderer(
|
||||||
(stats) => {
|
(stats) => {
|
||||||
@@ -1144,6 +1166,9 @@ export class MapEngine {
|
|||||||
this.contentCardTimer = 0
|
this.contentCardTimer = 0
|
||||||
this.currentContentCardPriority = 0
|
this.currentContentCardPriority = 0
|
||||||
this.shownContentCardKeys = {}
|
this.shownContentCardKeys = {}
|
||||||
|
this.currentContentCard = null
|
||||||
|
this.pendingContentCards = []
|
||||||
|
this.currentH5ExperienceOpen = false
|
||||||
this.mapPulseTimer = 0
|
this.mapPulseTimer = 0
|
||||||
this.stageFxTimer = 0
|
this.stageFxTimer = 0
|
||||||
this.sessionTimerInterval = 0
|
this.sessionTimerInterval = 0
|
||||||
@@ -1258,6 +1283,8 @@ export class MapEngine {
|
|||||||
contentCardVisible: false,
|
contentCardVisible: false,
|
||||||
contentCardTitle: '',
|
contentCardTitle: '',
|
||||||
contentCardBody: '',
|
contentCardBody: '',
|
||||||
|
pendingContentEntryVisible: false,
|
||||||
|
pendingContentEntryText: '',
|
||||||
punchButtonFxClass: '',
|
punchButtonFxClass: '',
|
||||||
panelProgressFxClass: '',
|
panelProgressFxClass: '',
|
||||||
panelDistanceFxClass: '',
|
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 {
|
clearMapPulseTimer(): void {
|
||||||
if (this.mapPulseTimer) {
|
if (this.mapPulseTimer) {
|
||||||
clearTimeout(this.mapPulseTimer)
|
clearTimeout(this.mapPulseTimer)
|
||||||
@@ -1734,6 +1951,8 @@ export class MapEngine {
|
|||||||
contentCardVisible: false,
|
contentCardVisible: false,
|
||||||
contentCardTitle: '',
|
contentCardTitle: '',
|
||||||
contentCardBody: '',
|
contentCardBody: '',
|
||||||
|
pendingContentEntryVisible: this.getPendingManualContentCount() > 0,
|
||||||
|
pendingContentEntryText: this.buildPendingContentEntryText(),
|
||||||
contentCardFxClass: '',
|
contentCardFxClass: '',
|
||||||
mapPulseVisible: false,
|
mapPulseVisible: false,
|
||||||
mapPulseFxClass: '',
|
mapPulseFxClass: '',
|
||||||
@@ -1744,11 +1963,20 @@ export class MapEngine {
|
|||||||
panelDistanceFxClass: '',
|
panelDistanceFxClass: '',
|
||||||
}, true)
|
}, true)
|
||||||
this.currentContentCardPriority = 0
|
this.currentContentCardPriority = 0
|
||||||
|
this.currentContentCard = null
|
||||||
|
this.currentH5ExperienceOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
resetSessionContentExperienceState(): void {
|
resetSessionContentExperienceState(): void {
|
||||||
this.shownContentCardKeys = {}
|
this.shownContentCardKeys = {}
|
||||||
this.currentContentCardPriority = 0
|
this.currentContentCardPriority = 0
|
||||||
|
this.currentContentCard = null
|
||||||
|
this.pendingContentCards = []
|
||||||
|
this.currentH5ExperienceOpen = false
|
||||||
|
this.setState({
|
||||||
|
pendingContentEntryVisible: false,
|
||||||
|
pendingContentEntryText: '',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSessionTimerInterval(): void {
|
clearSessionTimerInterval(): void {
|
||||||
@@ -1909,45 +2137,100 @@ export class MapEngine {
|
|||||||
const once = !!(options && options.once)
|
const once = !!(options && options.once)
|
||||||
const priority = options && typeof options.priority === 'number' ? options.priority : 0
|
const priority = options && typeof options.priority === 'number' ? options.priority : 0
|
||||||
const contentKey = options && options.contentKey ? options.contentKey : ''
|
const contentKey = options && options.contentKey ? options.contentKey : ''
|
||||||
|
const entry = {
|
||||||
if (!autoPopup) {
|
title,
|
||||||
return
|
body,
|
||||||
|
motionClass,
|
||||||
|
contentKey,
|
||||||
|
once,
|
||||||
|
priority,
|
||||||
|
autoPopup,
|
||||||
|
h5Request: this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (once && contentKey && this.shownContentCardKeys[contentKey]) {
|
if (once && contentKey && this.shownContentCardKeys[contentKey]) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.state.contentCardVisible && priority < this.currentContentCardPriority) {
|
|
||||||
|
if (!autoPopup) {
|
||||||
|
this.enqueueContentCard(entry)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clearContentCardTimer()
|
if (this.currentH5ExperienceOpen) {
|
||||||
this.setState({
|
this.enqueueContentCard(entry)
|
||||||
contentCardVisible: true,
|
return
|
||||||
contentCardTitle: title,
|
|
||||||
contentCardBody: body,
|
|
||||||
contentCardFxClass: motionClass,
|
|
||||||
}, true)
|
|
||||||
this.currentContentCardPriority = priority
|
|
||||||
if (once && contentKey) {
|
|
||||||
this.shownContentCardKeys[contentKey] = true
|
|
||||||
}
|
}
|
||||||
this.contentCardTimer = setTimeout(() => {
|
|
||||||
this.contentCardTimer = 0
|
if (this.state.contentCardVisible) {
|
||||||
this.currentContentCardPriority = 0
|
if (priority > this.currentContentCardPriority) {
|
||||||
this.setState({
|
this.openContentCardEntry(entry)
|
||||||
contentCardVisible: false,
|
return
|
||||||
contentCardFxClass: '',
|
}
|
||||||
}, true)
|
|
||||||
}, 2600) as unknown as number
|
this.enqueueContentCard(entry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.openContentCardEntry(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
closeContentCard(): void {
|
closeContentCard(): void {
|
||||||
this.clearContentCardTimer()
|
this.clearContentCardTimer()
|
||||||
this.currentContentCardPriority = 0
|
this.currentContentCardPriority = 0
|
||||||
|
this.currentContentCard = null
|
||||||
|
this.currentH5ExperienceOpen = false
|
||||||
this.setState({
|
this.setState({
|
||||||
contentCardVisible: false,
|
contentCardVisible: false,
|
||||||
contentCardFxClass: '',
|
contentCardFxClass: '',
|
||||||
}, true)
|
}, 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 {
|
applyGameEffects(effects: GameEffect[]): string | null {
|
||||||
@@ -2693,15 +2976,13 @@ export class MapEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleMapTap(stageX: number, stageY: number): void {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.gameRuntime.definition.mode === 'score-o') {
|
||||||
const focusedControlId = this.findFocusableControlAt(stageX, stageY)
|
const focusedControlId = this.findFocusableControlAt(stageX, stageY)
|
||||||
if (focusedControlId === undefined) {
|
if (focusedControlId !== undefined) {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const gameResult = this.gameRuntime.dispatch({
|
const gameResult = this.gameRuntime.dispatch({
|
||||||
type: 'control_focused',
|
type: 'control_focused',
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
@@ -2712,6 +2993,13 @@ export class MapEngine {
|
|||||||
focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`,
|
focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentControlId = this.findContentControlAt(stageX, stageY)
|
||||||
|
if (contentControlId) {
|
||||||
|
this.openControlClickContent(contentControlId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
|
findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
|
||||||
if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
|
if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
|
||||||
@@ -2749,6 +3037,134 @@ export class MapEngine {
|
|||||||
return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId
|
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 {
|
getControlHitRadiusPx(): number {
|
||||||
if (!this.state.tileSizePx) {
|
if (!this.state.tileSizePx) {
|
||||||
return 28
|
return 28
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
type GameContentExperienceConfig,
|
||||||
|
type GameContentExperienceConfigOverride,
|
||||||
type GameDefinition,
|
type GameDefinition,
|
||||||
type GameControl,
|
type GameControl,
|
||||||
type GameControlDisplayContent,
|
type GameControlDisplayContent,
|
||||||
@@ -19,6 +21,35 @@ function buildDisplayBody(label: string, sequence: number | null): string {
|
|||||||
return label
|
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(
|
function applyDisplayContentOverride(
|
||||||
baseContent: GameControlDisplayContent,
|
baseContent: GameControlDisplayContent,
|
||||||
override: GameControlDisplayContentOverride | undefined,
|
override: GameControlDisplayContentOverride | undefined,
|
||||||
@@ -33,6 +64,10 @@ function applyDisplayContentOverride(
|
|||||||
autoPopup: override.autoPopup !== undefined ? override.autoPopup : baseContent.autoPopup,
|
autoPopup: override.autoPopup !== undefined ? override.autoPopup : baseContent.autoPopup,
|
||||||
once: override.once !== undefined ? override.once : baseContent.once,
|
once: override.once !== undefined ? override.once : baseContent.once,
|
||||||
priority: override.priority !== undefined ? override.priority : baseContent.priority,
|
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,
|
autoPopup: true,
|
||||||
once: false,
|
once: false,
|
||||||
priority: 1,
|
priority: 1,
|
||||||
|
clickTitle: '比赛开始',
|
||||||
|
clickBody: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
|
||||||
|
contentExperience: null,
|
||||||
|
clickExperience: null,
|
||||||
}, controlContentOverrides[startId]),
|
}, controlContentOverrides[startId]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -94,6 +133,10 @@ export function buildGameDefinitionFromCourse(
|
|||||||
autoPopup: true,
|
autoPopup: true,
|
||||||
once: false,
|
once: false,
|
||||||
priority: 1,
|
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]),
|
}, controlContentOverrides[controlId]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -116,6 +159,10 @@ export function buildGameDefinitionFromCourse(
|
|||||||
autoPopup: true,
|
autoPopup: true,
|
||||||
once: false,
|
once: false,
|
||||||
priority: 2,
|
priority: 2,
|
||||||
|
clickTitle: '完成路线',
|
||||||
|
clickBody: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
|
||||||
|
contentExperience: null,
|
||||||
|
clickExperience: null,
|
||||||
}, controlContentOverrides[finishId] || controlContentOverrides[legacyFinishId]),
|
}, controlContentOverrides[finishId] || controlContentOverrides[legacyFinishId]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,30 @@ export type GameMode = 'classic-sequential' | 'score-o'
|
|||||||
export type GameControlKind = 'start' | 'control' | 'finish'
|
export type GameControlKind = 'start' | 'control' | 'finish'
|
||||||
export type PunchPolicyType = 'enter' | 'enter-confirm'
|
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 {
|
export interface GameControlDisplayContent {
|
||||||
title: string
|
title: string
|
||||||
body: string
|
body: string
|
||||||
autoPopup: boolean
|
autoPopup: boolean
|
||||||
once: boolean
|
once: boolean
|
||||||
priority: number
|
priority: number
|
||||||
|
clickTitle: string | null
|
||||||
|
clickBody: string | null
|
||||||
|
contentExperience: GameContentExperienceConfig | null
|
||||||
|
clickExperience: GameContentExperienceConfig | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameControlDisplayContentOverride {
|
export interface GameControlDisplayContentOverride {
|
||||||
@@ -19,6 +37,10 @@ export interface GameControlDisplayContentOverride {
|
|||||||
autoPopup?: boolean
|
autoPopup?: boolean
|
||||||
once?: boolean
|
once?: boolean
|
||||||
priority?: number
|
priority?: number
|
||||||
|
clickTitle?: string
|
||||||
|
clickBody?: string
|
||||||
|
contentExperience?: GameContentExperienceConfigOverride
|
||||||
|
clickExperience?: GameContentExperienceConfigOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameControl {
|
export interface GameControl {
|
||||||
|
|||||||
26
miniprogram/game/experience/h5Experience.ts
Normal file
26
miniprogram/game/experience/h5Experience.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export type H5ExperienceKind = 'content' | 'result'
|
||||||
|
|
||||||
|
export interface H5ExperienceFallbackPayload {
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
motionClass: string
|
||||||
|
contentKey: string
|
||||||
|
once: boolean
|
||||||
|
priority: number
|
||||||
|
autoPopup: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface H5ExperienceRequest {
|
||||||
|
kind: H5ExperienceKind
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
bridgeVersion: string
|
||||||
|
context: Record<string, unknown>
|
||||||
|
fallback: H5ExperienceFallbackPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface H5BridgeMessage {
|
||||||
|
action?: string
|
||||||
|
type?: string
|
||||||
|
payload?: Record<string, unknown>
|
||||||
|
}
|
||||||
@@ -279,7 +279,10 @@ function getInitialTargetId(definition: GameDefinition): string | null {
|
|||||||
return firstTarget ? firstTarget.id : 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') {
|
if (control.kind === 'start') {
|
||||||
return {
|
return {
|
||||||
type: 'control_completed',
|
type: 'control_completed',
|
||||||
@@ -289,7 +292,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
|
|||||||
label: control.label,
|
label: control.label,
|
||||||
displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
|
displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
|
||||||
displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,前往 1 号点。',
|
displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,前往 1 号点。',
|
||||||
displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
|
displayAutoPopup: allowAutoPopup,
|
||||||
displayOnce: control.displayContent ? control.displayContent.once : false,
|
displayOnce: control.displayContent ? control.displayContent.once : false,
|
||||||
displayPriority: control.displayContent ? control.displayContent.priority : 1,
|
displayPriority: control.displayContent ? control.displayContent.priority : 1,
|
||||||
}
|
}
|
||||||
@@ -304,7 +307,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
|
|||||||
label: control.label,
|
label: control.label,
|
||||||
displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
|
displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
|
||||||
displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
|
displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
|
||||||
displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
|
displayAutoPopup: allowAutoPopup,
|
||||||
displayOnce: control.displayContent ? control.displayContent.once : false,
|
displayOnce: control.displayContent ? control.displayContent.once : false,
|
||||||
displayPriority: control.displayContent ? control.displayContent.priority : 2,
|
displayPriority: control.displayContent ? control.displayContent.priority : 2,
|
||||||
}
|
}
|
||||||
@@ -322,7 +325,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
|
|||||||
label: control.label,
|
label: control.label,
|
||||||
displayTitle,
|
displayTitle,
|
||||||
displayBody,
|
displayBody,
|
||||||
displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
|
displayAutoPopup: allowAutoPopup,
|
||||||
displayOnce: control.displayContent ? control.displayContent.once : false,
|
displayOnce: control.displayContent ? control.displayContent.once : false,
|
||||||
displayPriority: control.displayContent ? control.displayContent.priority : 1,
|
displayPriority: control.displayContent ? control.displayContent.priority : 1,
|
||||||
}
|
}
|
||||||
@@ -353,7 +356,7 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
|
|||||||
phase: resolveClassicPhase(nextTarget, currentTarget, finished),
|
phase: resolveClassicPhase(nextTarget, currentTarget, finished),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
|
const effects: GameEffect[] = [buildCompletedEffect(currentTarget, definition.punchPolicy)]
|
||||||
|
|
||||||
if (finished) {
|
if (finished) {
|
||||||
effects.push({ type: 'session_finished' })
|
effects.push({ type: 'session_finished' })
|
||||||
|
|||||||
@@ -241,7 +241,10 @@ function buildPunchHintText(
|
|||||||
: `进入${targetLabel}后点击打点`
|
: `进入${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') {
|
if (control.kind === 'start') {
|
||||||
return {
|
return {
|
||||||
type: 'control_completed',
|
type: 'control_completed',
|
||||||
@@ -251,7 +254,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
|
|||||||
label: control.label,
|
label: control.label,
|
||||||
displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
|
displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
|
||||||
displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,开始自由打点。',
|
displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,开始自由打点。',
|
||||||
displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
|
displayAutoPopup: allowAutoPopup,
|
||||||
displayOnce: control.displayContent ? control.displayContent.once : false,
|
displayOnce: control.displayContent ? control.displayContent.once : false,
|
||||||
displayPriority: control.displayContent ? control.displayContent.priority : 1,
|
displayPriority: control.displayContent ? control.displayContent.priority : 1,
|
||||||
}
|
}
|
||||||
@@ -266,7 +269,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
|
|||||||
label: control.label,
|
label: control.label,
|
||||||
displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
|
displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
|
||||||
displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
|
displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
|
||||||
displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
|
displayAutoPopup: allowAutoPopup,
|
||||||
displayOnce: control.displayContent ? control.displayContent.once : false,
|
displayOnce: control.displayContent ? control.displayContent.once : false,
|
||||||
displayPriority: control.displayContent ? control.displayContent.priority : 2,
|
displayPriority: control.displayContent ? control.displayContent.priority : 2,
|
||||||
}
|
}
|
||||||
@@ -281,7 +284,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
|
|||||||
label: control.label,
|
label: control.label,
|
||||||
displayTitle: control.displayContent ? control.displayContent.title : `收集 ${sequenceText}`,
|
displayTitle: control.displayContent ? control.displayContent.title : `收集 ${sequenceText}`,
|
||||||
displayBody: control.displayContent ? control.displayContent.body : control.label,
|
displayBody: control.displayContent ? control.displayContent.body : control.label,
|
||||||
displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
|
displayAutoPopup: allowAutoPopup,
|
||||||
displayOnce: control.displayContent ? control.displayContent.once : false,
|
displayOnce: control.displayContent ? control.displayContent.once : false,
|
||||||
displayPriority: control.displayContent ? control.displayContent.priority : 1,
|
displayPriority: control.displayContent ? control.displayContent.priority : 1,
|
||||||
}
|
}
|
||||||
@@ -435,7 +438,7 @@ function applyCompletion(
|
|||||||
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
|
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
|
||||||
}, nextModeState)
|
}, nextModeState)
|
||||||
|
|
||||||
const effects: GameEffect[] = [buildCompletedEffect(control)]
|
const effects: GameEffect[] = [buildCompletedEffect(control, definition.punchPolicy)]
|
||||||
if (control.kind === 'finish') {
|
if (control.kind === 'finish') {
|
||||||
effects.push({ type: 'session_finished' })
|
effects.push({ type: 'session_finished' })
|
||||||
}
|
}
|
||||||
|
|||||||
127
miniprogram/pages/experience-webview/experience-webview.js
Normal file
127
miniprogram/pages/experience-webview/experience-webview.js
Normal file
@@ -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()
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "内容体验"
|
||||||
|
}
|
||||||
136
miniprogram/pages/experience-webview/experience-webview.ts
Normal file
136
miniprogram/pages/experience-webview/experience-webview.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { type H5BridgeMessage, type H5ExperienceRequest } from '../../game/experience/h5Experience'
|
||||||
|
|
||||||
|
type ExperienceWebViewPageData = {
|
||||||
|
webViewSrc: string
|
||||||
|
webViewReady: boolean
|
||||||
|
loadErrorText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentRequest: H5ExperienceRequest | null = null
|
||||||
|
let currentEventChannel: WechatMiniprogram.EventChannel | null = null
|
||||||
|
let pageResolved = false
|
||||||
|
|
||||||
|
function appendQueryParam(url: string, key: string, value: string): string {
|
||||||
|
const separator = url.indexOf('?') >= 0 ? '&' : '?'
|
||||||
|
return `${url}${separator}${key}=${encodeURIComponent(value)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebViewSrc(request: H5ExperienceRequest): string {
|
||||||
|
let nextUrl = request.url
|
||||||
|
nextUrl = appendQueryParam(nextUrl, 'cmrBridge', request.bridgeVersion)
|
||||||
|
nextUrl = appendQueryParam(nextUrl, 'cmrKind', request.kind)
|
||||||
|
return nextUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitFallbackAndClose() {
|
||||||
|
if (!currentRequest || !currentEventChannel) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!pageResolved) {
|
||||||
|
pageResolved = true
|
||||||
|
currentEventChannel.emit('fallback', currentRequest.fallback)
|
||||||
|
}
|
||||||
|
wx.navigateBack({
|
||||||
|
fail: () => {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitCloseAndBack(payload?: Record<string, unknown>) {
|
||||||
|
if (currentEventChannel && !pageResolved) {
|
||||||
|
pageResolved = true
|
||||||
|
currentEventChannel.emit('close', payload || {})
|
||||||
|
}
|
||||||
|
wx.navigateBack({
|
||||||
|
fail: () => {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<ExperienceWebViewPageData, WechatMiniprogram.IAnyObject>({
|
||||||
|
data: {
|
||||||
|
webViewSrc: '',
|
||||||
|
webViewReady: false,
|
||||||
|
loadErrorText: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
pageResolved = false
|
||||||
|
currentRequest = null
|
||||||
|
currentEventChannel = null
|
||||||
|
this.setData({
|
||||||
|
webViewSrc: '',
|
||||||
|
webViewReady: false,
|
||||||
|
loadErrorText: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentEventChannel = this.getOpenerEventChannel()
|
||||||
|
} catch {
|
||||||
|
currentEventChannel = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentEventChannel) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEventChannel.on('init', (request: H5ExperienceRequest) => {
|
||||||
|
currentRequest = request
|
||||||
|
wx.setNavigationBarTitle({
|
||||||
|
title: request.title || '内容体验',
|
||||||
|
fail: () => {},
|
||||||
|
})
|
||||||
|
this.setData({
|
||||||
|
webViewSrc: buildWebViewSrc(request),
|
||||||
|
webViewReady: true,
|
||||||
|
loadErrorText: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload() {
|
||||||
|
if (currentEventChannel && !pageResolved) {
|
||||||
|
currentEventChannel.emit('close', {})
|
||||||
|
}
|
||||||
|
pageResolved = false
|
||||||
|
currentRequest = null
|
||||||
|
currentEventChannel = null
|
||||||
|
},
|
||||||
|
|
||||||
|
handleWebViewMessage(event: WechatMiniprogram.CustomEvent) {
|
||||||
|
const dataList = event.detail && Array.isArray(event.detail.data)
|
||||||
|
? event.detail.data
|
||||||
|
: []
|
||||||
|
const rawMessage = dataList.length ? dataList[dataList.length - 1] : null
|
||||||
|
if (!rawMessage || typeof rawMessage !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = rawMessage as H5BridgeMessage
|
||||||
|
const action = message.action || message.type || ''
|
||||||
|
if (!action) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'close') {
|
||||||
|
emitCloseAndBack(message.payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'submitResult') {
|
||||||
|
if (currentEventChannel) {
|
||||||
|
currentEventChannel.emit('submitResult', message.payload || {})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'fallback') {
|
||||||
|
emitFallbackAndClose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleWebViewError() {
|
||||||
|
this.setData({
|
||||||
|
loadErrorText: '页面打开失败,已回退原生内容',
|
||||||
|
})
|
||||||
|
emitFallbackAndClose()
|
||||||
|
},
|
||||||
|
})
|
||||||
11
miniprogram/pages/experience-webview/experience-webview.wxml
Normal file
11
miniprogram/pages/experience-webview/experience-webview.wxml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<view wx:if="{{!webViewReady}}" class="experience-webview__loading">
|
||||||
|
<view class="experience-webview__loading-title">内容页加载中</view>
|
||||||
|
<view wx:if="{{loadErrorText}}" class="experience-webview__loading-error">{{loadErrorText}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<web-view
|
||||||
|
wx:if="{{webViewReady && webViewSrc}}"
|
||||||
|
src="{{webViewSrc}}"
|
||||||
|
bindmessage="handleWebViewMessage"
|
||||||
|
binderror="handleWebViewError"
|
||||||
|
></web-view>
|
||||||
27
miniprogram/pages/experience-webview/experience-webview.wxss
Normal file
27
miniprogram/pages/experience-webview/experience-webview.wxss
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '../../engine/map/mapEngine'
|
} from '../../engine/map/mapEngine'
|
||||||
import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
|
import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
|
||||||
import { type AnimationLevel } from '../../utils/animationLevel'
|
import { type AnimationLevel } from '../../utils/animationLevel'
|
||||||
|
import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
|
||||||
type CompassTickData = {
|
type CompassTickData = {
|
||||||
angle: number
|
angle: number
|
||||||
long: boolean
|
long: boolean
|
||||||
@@ -992,6 +993,9 @@ Page({
|
|||||||
this.scheduleGameInfoPanelSnapshotSync()
|
this.scheduleGameInfoPanelSnapshotSync()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onOpenH5Experience: (request) => {
|
||||||
|
this.openH5Experience(request)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const storedUserSettings = loadStoredUserSettings()
|
const storedUserSettings = loadStoredUserSettings()
|
||||||
@@ -1390,6 +1394,12 @@ Page({
|
|||||||
mapEngine.handleConnectMockHeartRateBridge()
|
mapEngine.handleConnectMockHeartRateBridge()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleOpenWebViewTest() {
|
||||||
|
wx.navigateTo({
|
||||||
|
url: '/pages/webview-test/webview-test',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
handleMockBridgeUrlInput(event: WechatMiniprogram.Input) {
|
handleMockBridgeUrlInput(event: WechatMiniprogram.Input) {
|
||||||
this.setData({
|
this.setData({
|
||||||
mockBridgeUrlDraft: event.detail.value,
|
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() {
|
handleCloseContentCard() {
|
||||||
if (mapEngine) {
|
if (mapEngine) {
|
||||||
mapEngine.closeContentCard()
|
mapEngine.closeContentCard()
|
||||||
|
|||||||
@@ -115,6 +115,10 @@
|
|||||||
<cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view>
|
<cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view>
|
||||||
</cover-view>
|
</cover-view>
|
||||||
|
|
||||||
|
<cover-view class="map-content-entry" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel && pendingContentEntryVisible}}" bindtap="handleOpenPendingContentCard">
|
||||||
|
<cover-view class="map-content-entry__text">{{pendingContentEntryText}}</cover-view>
|
||||||
|
</cover-view>
|
||||||
|
|
||||||
<cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel && showBottomDebugButton && gameSessionStatus !== 'running'}}" bindtap="handleStartGame">
|
<cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel && showBottomDebugButton && gameSessionStatus !== 'running'}}" bindtap="handleStartGame">
|
||||||
<cover-view class="screen-button-layer__text screen-button-layer__text--start">开始</cover-view>
|
<cover-view class="screen-button-layer__text screen-button-layer__text--start">开始</cover-view>
|
||||||
</cover-view>
|
</cover-view>
|
||||||
@@ -552,6 +556,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="control-row">
|
<view class="control-row">
|
||||||
<view class="control-chip control-chip--primary" bindtap="handleConnectAllMockSources">一键连接模拟源</view>
|
<view class="control-chip control-chip--primary" bindtap="handleConnectAllMockSources">一键连接模拟源</view>
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleOpenWebViewTest">测试 H5</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-group-title">定位</view>
|
<view class="debug-group-title">定位</view>
|
||||||
<view class="info-panel__row">
|
<view class="info-panel__row">
|
||||||
|
|||||||
@@ -1155,6 +1155,27 @@
|
|||||||
animation: punch-button-warning 0.56s ease-in-out 1;
|
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 {
|
.race-panel__line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
24
miniprogram/pages/webview-test/webview-test.js
Normal file
24
miniprogram/pages/webview-test/webview-test.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const WEB_VIEW_TEST_URL = 'https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html'
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
webViewSrc: '',
|
||||||
|
webViewReady: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
this.setData({
|
||||||
|
webViewSrc: WEB_VIEW_TEST_URL,
|
||||||
|
webViewReady: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleWebViewError() {
|
||||||
|
wx.showModal({
|
||||||
|
title: 'H5 打开失败',
|
||||||
|
content: WEB_VIEW_TEST_URL,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '知道了',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
3
miniprogram/pages/webview-test/webview-test.json
Normal file
3
miniprogram/pages/webview-test/webview-test.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "WebView 测试"
|
||||||
|
}
|
||||||
29
miniprogram/pages/webview-test/webview-test.ts
Normal file
29
miniprogram/pages/webview-test/webview-test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
type WebViewTestPageData = {
|
||||||
|
webViewSrc: string
|
||||||
|
webViewReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEB_VIEW_TEST_URL = 'https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html'
|
||||||
|
|
||||||
|
Page<WebViewTestPageData, WechatMiniprogram.IAnyObject>({
|
||||||
|
data: {
|
||||||
|
webViewSrc: '',
|
||||||
|
webViewReady: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
this.setData({
|
||||||
|
webViewSrc: WEB_VIEW_TEST_URL,
|
||||||
|
webViewReady: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleWebViewError() {
|
||||||
|
wx.showModal({
|
||||||
|
title: 'H5 打开失败',
|
||||||
|
content: WEB_VIEW_TEST_URL,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '知道了',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
11
miniprogram/pages/webview-test/webview-test.wxml
Normal file
11
miniprogram/pages/webview-test/webview-test.wxml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<view class="webview-test-page">
|
||||||
|
<view class="webview-test-page__loading" wx:if="{{!webViewReady}}">
|
||||||
|
<view class="webview-test-page__title">H5 测试页加载中</view>
|
||||||
|
<view class="webview-test-page__desc">{{webViewSrc}}</view>
|
||||||
|
</view>
|
||||||
|
<web-view
|
||||||
|
wx:if="{{webViewReady && webViewSrc}}"
|
||||||
|
src="{{webViewSrc}}"
|
||||||
|
binderror="handleWebViewError"
|
||||||
|
></web-view>
|
||||||
|
</view>
|
||||||
24
miniprogram/pages/webview-test/webview-test.wxss
Normal file
24
miniprogram/pages/webview-test/webview-test.wxss
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './proj
|
|||||||
import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
|
import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
|
||||||
import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
|
import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
|
||||||
import { mergeTelemetryConfig, type TelemetryConfig } from '../game/telemetry/telemetryConfig'
|
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 {
|
import {
|
||||||
mergeGameHapticsConfig,
|
mergeGameHapticsConfig,
|
||||||
mergeGameUiEffectsConfig,
|
mergeGameUiEffectsConfig,
|
||||||
@@ -233,6 +236,44 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
|
|||||||
return rawValue === 'enter' ? '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' {
|
function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
|
||||||
if (typeof rawValue !== 'string') {
|
if (typeof rawValue !== 'string') {
|
||||||
return 'classic-sequential'
|
return 'classic-sequential'
|
||||||
@@ -780,19 +821,41 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
const bodyValue = typeof (item as Record<string, unknown>).body === 'string'
|
const bodyValue = typeof (item as Record<string, unknown>).body === 'string'
|
||||||
? ((item as Record<string, unknown>).body as string).trim()
|
? ((item as Record<string, unknown>).body as string).trim()
|
||||||
: ''
|
: ''
|
||||||
|
const clickTitleValue = typeof (item as Record<string, unknown>).clickTitle === 'string'
|
||||||
|
? ((item as Record<string, unknown>).clickTitle as string).trim()
|
||||||
|
: ''
|
||||||
|
const clickBodyValue = typeof (item as Record<string, unknown>).clickBody === 'string'
|
||||||
|
? ((item as Record<string, unknown>).clickBody as string).trim()
|
||||||
|
: ''
|
||||||
const autoPopupValue = (item as Record<string, unknown>).autoPopup
|
const autoPopupValue = (item as Record<string, unknown>).autoPopup
|
||||||
const onceValue = (item as Record<string, unknown>).once
|
const onceValue = (item as Record<string, unknown>).once
|
||||||
const priorityNumeric = Number((item as Record<string, unknown>).priority)
|
const priorityNumeric = Number((item as Record<string, unknown>).priority)
|
||||||
|
const contentExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).contentExperience, gameConfigUrl)
|
||||||
|
const clickExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).clickExperience, gameConfigUrl)
|
||||||
const hasAutoPopup = typeof autoPopupValue === 'boolean'
|
const hasAutoPopup = typeof autoPopupValue === 'boolean'
|
||||||
const hasOnce = typeof onceValue === 'boolean'
|
const hasOnce = typeof onceValue === 'boolean'
|
||||||
const hasPriority = Number.isFinite(priorityNumeric)
|
const hasPriority = Number.isFinite(priorityNumeric)
|
||||||
if (titleValue || bodyValue || hasAutoPopup || hasOnce || hasPriority) {
|
if (
|
||||||
|
titleValue
|
||||||
|
|| bodyValue
|
||||||
|
|| clickTitleValue
|
||||||
|
|| clickBodyValue
|
||||||
|
|| hasAutoPopup
|
||||||
|
|| hasOnce
|
||||||
|
|| hasPriority
|
||||||
|
|| contentExperienceValue
|
||||||
|
|| clickExperienceValue
|
||||||
|
) {
|
||||||
controlContentOverrides[key] = {
|
controlContentOverrides[key] = {
|
||||||
...(titleValue ? { title: titleValue } : {}),
|
...(titleValue ? { title: titleValue } : {}),
|
||||||
...(bodyValue ? { body: bodyValue } : {}),
|
...(bodyValue ? { body: bodyValue } : {}),
|
||||||
|
...(clickTitleValue ? { clickTitle: clickTitleValue } : {}),
|
||||||
|
...(clickBodyValue ? { clickBody: clickBodyValue } : {}),
|
||||||
...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
|
...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
|
||||||
...(hasOnce ? { once: !!onceValue } : {}),
|
...(hasOnce ? { once: !!onceValue } : {}),
|
||||||
...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
|
...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
|
||||||
|
...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}),
|
||||||
|
...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user