feat: 收敛玩法运行时配置并加入故障恢复
This commit is contained in:
405
doc/config/全局规则与配置维度清单.md
Normal file
405
doc/config/全局规则与配置维度清单.md
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
# 全局规则与配置维度清单
|
||||||
|
|
||||||
|
本文档用于定义当前系统中**跨玩法共用**的全局规则块和配置维度,作为后续所有玩法设计文档、配置文件设计、后台录入和联调的统一骨架。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 统一“一个玩法设计文档至少要覆盖哪些公共规则块”
|
||||||
|
- 统一“一个游戏配置文件通常会包含哪些跨玩法公共配置”
|
||||||
|
- 为后续新增系统能力时提供持续维护入口
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 本文档讲的是**全局规则块**
|
||||||
|
- 它不替代具体玩法规则文档
|
||||||
|
- 它也不替代具体玩法的可配置项清单
|
||||||
|
- 推荐和 [顺序打点规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md)、[顺序打点游戏配置项](D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md)、[配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) 配合阅读
|
||||||
|
- 后续玩法设计建议统一使用 [玩法设计文档模板](D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 设计原则
|
||||||
|
|
||||||
|
后续每个玩法设计文档,至少建议覆盖以下三层:
|
||||||
|
|
||||||
|
1. 玩法专属规则
|
||||||
|
2. 全局规则块选型
|
||||||
|
3. 配置落点与默认值
|
||||||
|
|
||||||
|
也就是说,后续写一个新玩法时,不应该只写:
|
||||||
|
|
||||||
|
- 怎么玩
|
||||||
|
- 怎么赢
|
||||||
|
|
||||||
|
还应该明确:
|
||||||
|
|
||||||
|
- 用哪套定位点样式
|
||||||
|
- 用哪套轨迹策略
|
||||||
|
- 是否启用腿线动画
|
||||||
|
- 是否启用内容弹层
|
||||||
|
- 是否启用音效、震动和 HUD 反馈
|
||||||
|
- 哪些沿用系统默认值,哪些做玩法覆盖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 推荐公共骨架
|
||||||
|
|
||||||
|
当前推荐所有玩法配置继续沿用以下顶层骨架:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": "1",
|
||||||
|
"version": "2026.03.31",
|
||||||
|
"app": {},
|
||||||
|
"map": {},
|
||||||
|
"playfield": {},
|
||||||
|
"game": {},
|
||||||
|
"resources": {},
|
||||||
|
"debug": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `app` 管活动级基础信息
|
||||||
|
- `map` 管地图底图和视口底座
|
||||||
|
- `playfield` 管场地对象、路线和点位内容
|
||||||
|
- `game` 管玩法规则和全局运行规则
|
||||||
|
- `resources` 管资源档和主题档
|
||||||
|
- `debug` 管调试与模拟能力
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 全局规则块总表
|
||||||
|
|
||||||
|
| 规则块 | 建议落点 | 作用 | 是否建议每个玩法都明确 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 活动基础信息 | `app.*` | 定义活动身份、标题和语言环境 | 是 |
|
||||||
|
| 地图底座 | `map.*` | 定义地图来源、磁偏角和初始视口 | 是 |
|
||||||
|
| 场地对象 | `playfield.*` | 定义路线、控制点、对象集和内容覆盖 | 是 |
|
||||||
|
| 对局流程 | `game.session.*` | 定义开局、结束、时长和起终点要求 | 是 |
|
||||||
|
| 打点判定 | `game.punch.*` | 定义打点触发方式和判定半径 | 是 |
|
||||||
|
| 顺序推进 / 跳点 | `game.sequence.*` | 定义顺序赛推进和跳点规则 | 顺序类玩法必须明确 |
|
||||||
|
| 计分模型 | `game.scoring.*` | 定义分值模型和点位得分规则 | 有计分时必须明确 |
|
||||||
|
| 引导显示 | `game.guidance.*` | 定义腿线、目标聚焦和地图引导 | 是 |
|
||||||
|
| 可见性策略 | `game.visibility.*` | 定义开局是否隐藏对象、何时揭示全场 | 是 |
|
||||||
|
| 完赛规则 | `game.finish.*` | 定义终点生效条件和结束逻辑 | 是 |
|
||||||
|
| 内容体验 | `playfield.controlOverrides.*` | 定义点位弹窗、H5、点击内容 | 有内容玩法建议明确 |
|
||||||
|
| 点位表现 | `game.presentation.*.controls` | 定义控制点不同状态样式 | 是 |
|
||||||
|
| 腿线表现 | `game.presentation.*.legs` | 定义路线连接线样式和动效 | 有路线玩法建议明确 |
|
||||||
|
| 轨迹表现 | `game.presentation.track.*` | 定义玩家轨迹展示策略 | 是 |
|
||||||
|
| 定位点表现 | `game.presentation.gpsMarker.*` | 定义 GPS 点样式和动画 | 是 |
|
||||||
|
| 遥测参数 | `game.telemetry.*` | 定义心率等计算参数,作为活动默认值 | 用到相关能力时明确 |
|
||||||
|
| 反馈系统 | `game.feedback.*` | 定义音效、震动、UI 动效 | 是 |
|
||||||
|
| 资源档 | `resources.*` | 定义音频、主题、内容资源档 | 是 |
|
||||||
|
| 调试能力 | `debug.*` | 定义模拟输入和调试开关 | 开发阶段建议明确 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 各规则块说明
|
||||||
|
|
||||||
|
### 4.1 活动基础信息
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 活动 ID | `app.id` | 活动或配置实例唯一标识 | 任意字符串 | 无,建议必填 |
|
||||||
|
| 活动标题 | `app.title` | 页面展示和结算展示的标题 | 任意字符串 | 无,建议必填 |
|
||||||
|
| 语言环境 | `app.locale` | 文案和内容环境 | 当前常用:`zh-CN` | `zh-CN` |
|
||||||
|
|
||||||
|
### 4.2 地图底座
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 瓦片路径 | `map.tiles` | 地图瓦片资源位置 | 路径字符串 | 无,建议必填 |
|
||||||
|
| 地图元数据 | `map.mapmeta` | 地图 meta 文件 | 路径字符串 | 无,建议必填 |
|
||||||
|
| 磁偏角 | `map.declination` | 影响真北/磁北换算 | `number` | `0` |
|
||||||
|
| 初始缩放 | `map.initialView.zoom` | 地图初始缩放级别 | `number` | 由客户端初始视口逻辑接管,建议 `17` |
|
||||||
|
|
||||||
|
### 4.3 场地对象
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 场地类型 | `playfield.kind` | 定义玩法使用的对象集合类型 | `course` `control-set` | 由玩法决定,顺序赛常用 `course` |
|
||||||
|
| 场地来源类型 | `playfield.source.type` | 定义空间底稿来源 | 当前支持:`kml` | `kml` |
|
||||||
|
| 场地来源地址 | `playfield.source.url` | KML 或其他空间资源地址 | 路径字符串 | 无,建议必填 |
|
||||||
|
| 控制点绘制半径 | `playfield.CPRadius` | 影响地图上控制点圈的展示大小 | `number` | `6` |
|
||||||
|
| 路线标题 | `playfield.metadata.title` | 路线或对象集标题 | 任意字符串 | 无 |
|
||||||
|
| 路线编码 | `playfield.metadata.code` | 路线或对象集编码 | 任意字符串 | 无 |
|
||||||
|
|
||||||
|
### 4.4 对局流程
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 手动开始 | `game.session.startManually` | 是否需要玩家主动点开始按钮 | `true` `false` | 顺序赛默认 `false` |
|
||||||
|
| 必须打起点 | `game.session.requiresStartPunch` | 是否要求起点打卡后才正式开赛 | `true` `false` | 顺序赛默认 `true` |
|
||||||
|
| 必须打终点 | `game.session.requiresFinishPunch` | 是否要求终点打卡才能完赛 | `true` `false` | 顺序赛默认 `true` |
|
||||||
|
| 最后点自动结束 | `game.session.autoFinishOnLastControl` | 最后一个普通点完成后是否直接结束 | `true` `false` | `false` |
|
||||||
|
| 最大时长 | `game.session.maxDurationSec` | 单局允许的最大比赛时长 | `number` | `5400` |
|
||||||
|
|
||||||
|
### 4.5 打点判定
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 打点策略 | `game.punch.policy` | 控制进入范围后如何完成打点 | `enter-confirm` `enter` | `enter-confirm` |
|
||||||
|
| 打点半径 | `game.punch.radiusMeters` | 打点命中的半径阈值 | `number` | `5` |
|
||||||
|
| 必须先聚焦目标 | `game.punch.requiresFocusSelection` | 是否必须先选中目标点才能打卡 | `true` `false` | 顺序赛默认 `false`,积分赛默认 `true` |
|
||||||
|
|
||||||
|
### 4.6 顺序推进 / 跳点
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 是否允许跳点 | `game.sequence.skip.enabled` | 顺序玩法是否允许跳过当前目标点 | `true` `false` | 顺序赛默认 `true` |
|
||||||
|
| 跳点半径 | `game.sequence.skip.radiusMeters` | 触发跳点的距离阈值 | `number` | `game.punch.radiusMeters * 2` |
|
||||||
|
| 跳点确认 | `game.sequence.skip.requiresConfirm` | 触发跳点时是否需要二次确认 | `true` `false` | `false` |
|
||||||
|
|
||||||
|
### 4.7 计分模型
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 计分模型 | `game.scoring.type` | 定义玩法采用哪种积分模型 | 当前建议:`score` | `score` |
|
||||||
|
| 默认控制点分值 | `game.scoring.defaultControlScore` | 普通点未单独配置时的默认分值 | `number` | 顺序赛默认 `1`,积分赛默认 `10` |
|
||||||
|
|
||||||
|
备注:
|
||||||
|
|
||||||
|
- 顺序赛当前基础分、答题奖励分等更细规则仍以玩法规则文档为准,但系统默认基础分已按 `1` 落地
|
||||||
|
- 后续如果顺序赛积分逻辑进一步配置化,应优先补到 `game.scoring` 下
|
||||||
|
|
||||||
|
### 4.8 引导显示
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 显示腿线 | `game.guidance.showLegs` | 是否绘制路线连接线 | `true` `false` | 顺序赛默认 `true` |
|
||||||
|
| 腿线动画 | `game.guidance.legAnimation` | 是否启用腿线动画效果 | `true` `false` | 顺序赛默认 `true` |
|
||||||
|
| 允许地图选点 | `game.guidance.allowFocusSelection` | 是否允许用户点击地图切换目标 | `true` `false` | 顺序赛默认 `false` |
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- 黑色顶部引导提示条属于公共引导层。
|
||||||
|
- 当前默认在引导文案发生有效变化时,提示条会播放一次轻量入场动画,并辅以一次轻震动。
|
||||||
|
- 当内容卡、答题卡或结果页出现时,引导提示条默认让位。
|
||||||
|
- 顶部引导提示条的反馈与距离引导反馈分离管理,不能互相替代。
|
||||||
|
- 当前距离引导反馈默认分为三档:
|
||||||
|
- 远距离:弱提醒,间隔更长
|
||||||
|
- 接近目标:提醒频率提升
|
||||||
|
- 可打点:高频确认提醒
|
||||||
|
|
||||||
|
### 4.9 可见性策略
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 起点后揭示全场 | `game.visibility.revealFullPlayfieldAfterStartPunch` | 打完起点后是否显示全部控制点与路线 | `true` `false` | `true` |
|
||||||
|
|
||||||
|
### 4.10 完赛规则
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 终点始终可选 | `game.finish.finishControlAlwaysSelectable` | 终点是否无条件可生效 | `true` `false` | 顺序赛默认 `false` |
|
||||||
|
|
||||||
|
### 4.11 内容体验
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 自动弹窗模板 | `playfield.controlOverrides.<key>.template` | 点位完成后默认卡片模板 | `minimal` `story` `focus` | 起终点常用 `focus`,普通点常用 `story` |
|
||||||
|
| 自动弹窗开关 | `playfield.controlOverrides.<key>.autoPopup` | 完成点位后是否自动弹内容 | `true` `false` | 最小模板默认 `false` |
|
||||||
|
| 自动内容仅一次 | `playfield.controlOverrides.<key>.once` | 本局是否只自动展示一次 | `true` `false` | `false` |
|
||||||
|
| 内容承载方式 | `playfield.controlOverrides.<key>.contentExperience.type` | 自动内容使用原生还是 H5 | `native` `h5` | 当前按点位配置 |
|
||||||
|
| 内容展示形态 | `playfield.controlOverrides.<key>.contentExperience.presentation` | H5 内容如何呈现 | `sheet` `dialog` `fullscreen` | `sheet` |
|
||||||
|
| 点击承载方式 | `playfield.controlOverrides.<key>.clickExperience.type` | 点击点位时使用原生还是 H5 | `native` `h5` | 当前按点位配置 |
|
||||||
|
| 点击展示形态 | `playfield.controlOverrides.<key>.clickExperience.presentation` | 点击 H5 如何呈现 | `sheet` `dialog` `fullscreen` | `sheet` |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 最小模板下,点击检查点默认不弹任何详情卡,也不直接打开答题卡。
|
||||||
|
- 最小模板下,起点和普通点完成后默认不弹白色内容卡。
|
||||||
|
- 点击内容能力改为显式配置能力;只有配置了 `clickTitle` / `clickBody` / `clickExperience` 之一时,点击点位才会产生内容反馈。
|
||||||
|
- 完成后自动弹白卡也改为显式配置能力;只有明确开启 `autoPopup = true` 时,完成点位后才会弹出白色内容卡。
|
||||||
|
- 点击详情卡片当前默认不展示 H5 详情按钮,但 `clickExperience` 和 CTA 能力保留。
|
||||||
|
- 连续点击不同检查点时,新的点击卡片会直接替换当前卡片,不进入手动关闭队列。
|
||||||
|
- 黑色顶部提示条只承担操作引导,不承载点位内容或结果信息。
|
||||||
|
- 当白色内容卡、答题卡或结果页出现时,黑色顶部提示条默认让位,不与内容层抢注意力。
|
||||||
|
- 白色内容卡当前分为两类:
|
||||||
|
- 浏览型:点击点位查看说明,无按钮,默认约 `4` 秒自动消失,点击屏幕任意位置可关闭。
|
||||||
|
- 交互型:打点完成后的即时内容卡,可带 CTA 或进入答题流程。
|
||||||
|
- 终点完成默认直接进入原生成绩总览页,不再额外叠加终点白色内容卡;如需再次查看终点说明,需显式配置点击内容能力。
|
||||||
|
|
||||||
|
### 4.12 点位表现
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 点位样式 | `game.presentation.<mode>.controls.<state>.style` | 控制点形状和结构 | `classic-ring` `solid-dot` `double-ring` `badge` `pulse-core` | 由玩法状态决定 |
|
||||||
|
| 点位主色 | `game.presentation.<mode>.controls.<state>.colorHex` | 控制点颜色 | 十六进制颜色字符串 | 顺序赛默认参考传统定向紫红色 |
|
||||||
|
| 点位尺寸倍率 | `game.presentation.<mode>.controls.<state>.sizeScale` | 控制点大小缩放 | `number` | `1` 或按状态定制 |
|
||||||
|
| 强调环倍率 | `game.presentation.<mode>.controls.<state>.accentRingScale` | 外环强调强度 | `number` | 按状态定制 |
|
||||||
|
| 光晕强度 | `game.presentation.<mode>.controls.<state>.glowStrength` | 点位光晕表现 | 建议 `0 ~ 1` | 按状态定制 |
|
||||||
|
| 标签倍率 | `game.presentation.<mode>.controls.<state>.labelScale` | 点位编号大小 | `number` | `1` 或按状态定制 |
|
||||||
|
| 标签颜色 | `game.presentation.<mode>.controls.<state>.labelColorHex` | 点位编号颜色 | 十六进制颜色字符串 | 按状态定制 |
|
||||||
|
|
||||||
|
状态建议至少考虑:
|
||||||
|
|
||||||
|
- `default`
|
||||||
|
- `current`
|
||||||
|
- `completed`
|
||||||
|
- `skipped`
|
||||||
|
- `start`
|
||||||
|
- `finish`
|
||||||
|
|
||||||
|
### 4.13 腿线表现
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 腿线样式 | `game.presentation.<mode>.legs.<state>.style` | 连接线风格 | `classic-leg` `dashed-leg` `glow-leg` `progress-leg` | 顺序赛默认建议 `classic-leg` |
|
||||||
|
| 腿线主色 | `game.presentation.<mode>.legs.<state>.colorHex` | 连接线颜色 | 十六进制颜色字符串 | 顺序赛默认建议传统定向紫红色 |
|
||||||
|
| 腿线宽度倍率 | `game.presentation.<mode>.legs.<state>.widthScale` | 连接线粗细 | `number` | 视玩法决定 |
|
||||||
|
| 腿线光晕强度 | `game.presentation.<mode>.legs.<state>.glowStrength` | 连接线发光程度 | 建议 `0 ~ 1` | 视玩法决定 |
|
||||||
|
|
||||||
|
状态建议至少考虑:
|
||||||
|
|
||||||
|
- `default`
|
||||||
|
- `completed`
|
||||||
|
|
||||||
|
### 4.14 轨迹表现
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 轨迹模式 | `game.presentation.track.mode` | 决定是否显示轨迹以及显示方式 | `none` `tail` `full` | 顺序赛建议 `full` |
|
||||||
|
| 轨迹风格 | `game.presentation.track.style` | 整体视觉风格 | `classic` `neon` | 当前默认 `neon` |
|
||||||
|
| 拖尾档位 | `game.presentation.track.tailLength` | 拖尾长度档位 | `short` `medium` `long` | 视玩法决定 |
|
||||||
|
| 预设色盘 | `game.presentation.track.colorPreset` | 轨迹预设颜色方案 | `mint` `cyan` `sky` `blue` `violet` `pink` `orange` `yellow` | 按玩法决定 |
|
||||||
|
| 拖尾长度 | `game.presentation.track.tailMeters` | 实际拖尾长度 | `number` | 可覆盖档位映射 |
|
||||||
|
| 拖尾时窗 | `game.presentation.track.tailMaxSeconds` | 最大拖尾时间窗口 | `number` | 选填 |
|
||||||
|
| 静止淡出 | `game.presentation.track.fadeOutWhenStill` | 静止时是否逐步淡出 | `true` `false` | 选填 |
|
||||||
|
| 静止阈值 | `game.presentation.track.stillSpeedKmh` | 判定静止的速度阈值 | `number` | 选填 |
|
||||||
|
| 淡出时长 | `game.presentation.track.fadeOutDurationMs` | 静止后淡出时长 | `number` | 选填 |
|
||||||
|
| 轨迹主色 | `game.presentation.track.colorHex` | 主体轨迹颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 |
|
||||||
|
| 轨迹头部色 | `game.presentation.track.headColorHex` | 轨迹头部高亮颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 |
|
||||||
|
| 轨迹宽度 | `game.presentation.track.widthPx` | 主体轨迹宽度 | `number` | 选填 |
|
||||||
|
| 头部宽度 | `game.presentation.track.headWidthPx` | 头部高亮宽度 | `number` | 选填 |
|
||||||
|
| 轨迹光晕 | `game.presentation.track.glowStrength` | 轨迹光晕强度 | 建议 `0 ~ 1` | 选填 |
|
||||||
|
|
||||||
|
### 4.15 定位点表现
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 是否显示定位点 | `game.presentation.gpsMarker.visible` | 控制 GPS 点是否可见 | `true` `false` | `true` |
|
||||||
|
| 定位点样式 | `game.presentation.gpsMarker.style` | GPS 点基础形态 | `dot` `beacon` `disc` `badge` | `beacon` |
|
||||||
|
| 定位点尺寸 | `game.presentation.gpsMarker.size` | GPS 点大小档位 | `small` `medium` `large` | `medium` |
|
||||||
|
| 定位点预设色盘 | `game.presentation.gpsMarker.colorPreset` | GPS 点默认色彩方案 | `mint` `cyan` `sky` `blue` `violet` `pink` `orange` `yellow` | `cyan` |
|
||||||
|
| 定位点主色 | `game.presentation.gpsMarker.colorHex` | GPS 点主体颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 |
|
||||||
|
| 外环颜色 | `game.presentation.gpsMarker.ringColorHex` | GPS 点外环颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 |
|
||||||
|
| 朝向指示颜色 | `game.presentation.gpsMarker.indicatorColorHex` | 小三角或朝向指示颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 |
|
||||||
|
| 显示朝向指示 | `game.presentation.gpsMarker.showHeadingIndicator` | 是否显示跟随朝向旋转的指示三角 | `true` `false` | `true` |
|
||||||
|
| 定位点动画档 | `game.presentation.gpsMarker.animationProfile` | GPS 点动画风格 | `minimal` `dynamic-runner` `warning-reactive` | `dynamic-runner` |
|
||||||
|
| 中心 logo 地址 | `game.presentation.gpsMarker.logoUrl` | 品牌中心贴片资源 | URL 字符串 | 选填 |
|
||||||
|
| logo 嵌入方式 | `game.presentation.gpsMarker.logoMode` | 品牌贴片嵌入方式 | `center-badge` | 当前支持该值 |
|
||||||
|
|
||||||
|
### 4.16 遥测参数
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 年龄 | `game.telemetry.heartRate.age` | 心率估算的年龄参数 | `number` | `30` |
|
||||||
|
| 静息心率 | `game.telemetry.heartRate.restingHeartRateBpm` | 心率估算基础参数 | `number` | `62` |
|
||||||
|
| 体重 | `game.telemetry.heartRate.userWeightKg` | 卡路里或体征估算参数 | `number` | `65` |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 这里定义的是活动级默认值
|
||||||
|
- 如果后续接入线上玩家身体数据接口,线上数据应覆盖这里的同名字段
|
||||||
|
|
||||||
|
### 4.17 HUD 信息面板
|
||||||
|
|
||||||
|
| 名称 | 字段 / 归属 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| HUD 页数 | 公共 UI 壳子 | 当前 HUD 固定为 `2` 页 | `2` | `2` |
|
||||||
|
| HUD 第 1 页 | 公共 UI 壳子 | 比赛主信息页,承载动作、时间、里程、目标距离、进度等异型槽位 | 固定结构 | 启用 |
|
||||||
|
| HUD 第 2 页 | 公共 UI 壳子 | 心率 / 遥测页,承载心率、卡路里、均速、精度等异型槽位 | 固定结构 | 启用 |
|
||||||
|
| HUD 项目映射 | 玩法映射 | 不同玩法可将不同数据映射到相同 HUD 槽位 | 按玩法定义 | 按玩法默认 |
|
||||||
|
| HUD 目标摘要 | 玩法映射 | 第 1 页动作区下方的摘要文案,用于显示当前目标或提示信息 | `string` | 按玩法默认 |
|
||||||
|
| HUD 进度摘要 | 玩法映射 | 第 1 页进度区显示的核心摘要,不同玩法可映射为总分、完成进度、跳点数等 | `string` | 按玩法默认 |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- HUD 属于公共层能力,不属于某一个玩法专属规则。
|
||||||
|
- 当前系统采用“公共 HUD 壳子 + 玩法项目映射”的方式。
|
||||||
|
- 例如:
|
||||||
|
- 积分赛可将进度位映射为“总分 + 收集进度”
|
||||||
|
- 顺序打点可将进度位映射为“完成进度 + 跳点数”
|
||||||
|
|
||||||
|
### 4.18 反馈系统
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 音效反馈档 | `game.feedback.audioProfile` | 控制音效方案 | `string` | `default` |
|
||||||
|
| 震动反馈档 | `game.feedback.hapticsProfile` | 控制震动策略 | `string` | `default` |
|
||||||
|
| UI 动效档 | `game.feedback.uiEffectsProfile` | 控制 UI 动效策略 | `string` | `default` |
|
||||||
|
| 距离提示音阈值 | `game.audio.*DistanceMeters` | 控制远距离 / 接近 / 可打点三档距离提示的生效距离 | `number` | 远距离 `80`,接近 `20`,可打点 `5` |
|
||||||
|
| 距离提示音间隔 | `game.audio.cues.guidance:*.*` | 控制三档距离提示音的循环间隔、音量和音源 | `number` / `string` / `boolean` | 远距离 `4800ms`,接近 `950ms`,可打点 `650ms` |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 系统当前已支持音效反馈能力。
|
||||||
|
- 默认答题正确复用控制点完成音效,答题错误和答题超时复用警告音效。
|
||||||
|
- 引导提示条当前默认改用轻震动,不再播放引导提示音。
|
||||||
|
- 目标距离引导当前默认分为 `distant / approaching / ready` 三档,作为独立反馈链路,可使用距离提示音,不与顶部引导提示绑定。
|
||||||
|
- 当前三档距离提示已支持分别配置距离阈值与循环间隔。
|
||||||
|
|
||||||
|
### 4.19 资源档
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 音效资源档 | `resources.audioProfile` | 选择音频资源包 | `string` | `default` |
|
||||||
|
| 内容资源档 | `resources.contentProfile` | 选择内容资源包 | `string` | `default` |
|
||||||
|
| 主题资源档 | `resources.themeProfile` | 选择主题和视觉资源包 | `string` | `default-race` |
|
||||||
|
|
||||||
|
### 4.20 调试能力
|
||||||
|
|
||||||
|
| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 允许玩法切换 | `debug.allowModeSwitch` | 是否允许在调试时切玩法 | `true` `false` | `false` |
|
||||||
|
| 允许模拟输入 | `debug.allowMockInput` | 是否允许用模拟数据代替真实输入 | `true` `false` | `false` |
|
||||||
|
| 允许模拟器 | `debug.allowSimulator` | 是否开放调试模拟器面板 | `true` `false` | `false` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 后续所有玩法设计文档的建议结构
|
||||||
|
|
||||||
|
后续每新增一种玩法,设计文档建议至少包含以下章节:
|
||||||
|
|
||||||
|
1. 玩法目标与一句话规则
|
||||||
|
2. 开局流程与结束流程
|
||||||
|
3. 核心判定与胜负条件
|
||||||
|
4. 计分规则
|
||||||
|
5. 对象模型与场地对象要求
|
||||||
|
6. 全局规则块选型
|
||||||
|
7. 默认值与玩法覆盖项
|
||||||
|
8. 最小可跑配置示例
|
||||||
|
|
||||||
|
其中第 6 节“全局规则块选型”建议至少回答:
|
||||||
|
|
||||||
|
- 用哪套定位点样式
|
||||||
|
- 用哪套轨迹显示策略
|
||||||
|
- 是否显示腿线和腿线动画
|
||||||
|
- 起点后是否揭示全场
|
||||||
|
- 是否需要内容弹层和 H5 承载
|
||||||
|
- 用哪套反馈档
|
||||||
|
- 是否依赖心率等遥测参数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 维护约定
|
||||||
|
|
||||||
|
后续每次新增全局系统能力时,建议至少同步更新以下文档:
|
||||||
|
|
||||||
|
1. 本文档
|
||||||
|
2. [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
|
3. [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
||||||
|
4. 对应能力的专项文档
|
||||||
|
- 例如轨迹、GPS 点样式、GPS 点动画、内容体验、反馈系统
|
||||||
|
5. 至少一个玩法的配置样例
|
||||||
|
|
||||||
|
这样可以保证:
|
||||||
|
|
||||||
|
- 玩法设计有统一骨架
|
||||||
|
- 配置字段有统一归档
|
||||||
|
- 后台配置管理有明确输入目标
|
||||||
|
- 后续扩展不会只长代码、不长文档
|
||||||
|
|
||||||
@@ -27,6 +27,24 @@
|
|||||||
"title": "完整配置示例",
|
"title": "完整配置示例",
|
||||||
"locale": "zh-CN"
|
"locale": "zh-CN"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"autoRotateEnabled": {
|
||||||
|
"value": true,
|
||||||
|
"isLocked": false
|
||||||
|
},
|
||||||
|
"trackDisplayMode": {
|
||||||
|
"value": "tail",
|
||||||
|
"isLocked": false
|
||||||
|
},
|
||||||
|
"gpsMarkerStyle": {
|
||||||
|
"value": "beacon",
|
||||||
|
"isLocked": true
|
||||||
|
},
|
||||||
|
"showCenterScaleRuler": {
|
||||||
|
"value": false,
|
||||||
|
"isLocked": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"map": {
|
"map": {
|
||||||
"tiles": "../map/lxcb-001/tiles/",
|
"tiles": "../map/lxcb-001/tiles/",
|
||||||
"mapmeta": "../map/lxcb-001/tiles/meta.json",
|
"mapmeta": "../map/lxcb-001/tiles/meta.json",
|
||||||
@@ -42,6 +60,13 @@
|
|||||||
"url": "../kml/lxcb-001/10/c01.kml"
|
"url": "../kml/lxcb-001/10/c01.kml"
|
||||||
},
|
},
|
||||||
"CPRadius": 6,
|
"CPRadius": 6,
|
||||||
|
"controlDefaults": {
|
||||||
|
"score": 10,
|
||||||
|
"template": "story",
|
||||||
|
"autoPopup": false,
|
||||||
|
"pointStyle": "classic-ring",
|
||||||
|
"pointColorHex": "#cc006b"
|
||||||
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"title": "完整路线示例",
|
"title": "完整路线示例",
|
||||||
"code": "full-001"
|
"code": "full-001"
|
||||||
@@ -108,7 +133,7 @@
|
|||||||
"body": "恭喜完成本次路线。",
|
"body": "恭喜完成本次路线。",
|
||||||
"clickTitle": "终点说明",
|
"clickTitle": "终点说明",
|
||||||
"clickBody": "点击终点可再次查看结束说明。",
|
"clickBody": "点击终点可再次查看结束说明。",
|
||||||
"autoPopup": true,
|
"autoPopup": false,
|
||||||
"once": true,
|
"once": true,
|
||||||
"priority": 2,
|
"priority": 2,
|
||||||
"clickExperience": {
|
"clickExperience": {
|
||||||
@@ -118,16 +143,22 @@
|
|||||||
"presentation": "dialog"
|
"presentation": "dialog"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"legDefaults": {
|
||||||
|
"style": "classic-leg",
|
||||||
|
"colorHex": "#cc006b",
|
||||||
|
"widthScale": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"mode": "classic-sequential",
|
"mode": "classic-sequential",
|
||||||
"rulesVersion": "1",
|
"rulesVersion": "1",
|
||||||
"session": {
|
"session": {
|
||||||
"startManually": true,
|
"startManually": false,
|
||||||
"requiresStartPunch": true,
|
"requiresStartPunch": true,
|
||||||
"requiresFinishPunch": true,
|
"requiresFinishPunch": true,
|
||||||
"autoFinishOnLastControl": false,
|
"autoFinishOnLastControl": false,
|
||||||
|
"minCompletedControlsBeforeFinish": 1,
|
||||||
"maxDurationSec": 5400
|
"maxDurationSec": 5400
|
||||||
},
|
},
|
||||||
"punch": {
|
"punch": {
|
||||||
@@ -138,14 +169,10 @@
|
|||||||
"sequence": {
|
"sequence": {
|
||||||
"skip": {
|
"skip": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"radiusMeters": 30,
|
"radiusMeters": 10,
|
||||||
"requiresConfirm": true
|
"requiresConfirm": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scoring": {
|
|
||||||
"type": "score",
|
|
||||||
"defaultControlScore": 10
|
|
||||||
},
|
|
||||||
"guidance": {
|
"guidance": {
|
||||||
"showLegs": true,
|
"showLegs": true,
|
||||||
"legAnimation": true,
|
"legAnimation": true,
|
||||||
@@ -164,6 +191,25 @@
|
|||||||
"userWeightKg": 65
|
"userWeightKg": 65
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"audio": {
|
||||||
|
"distantDistanceMeters": 80,
|
||||||
|
"approachDistanceMeters": 20,
|
||||||
|
"readyDistanceMeters": 5,
|
||||||
|
"cues": {
|
||||||
|
"guidance:distant": {
|
||||||
|
"loopGapMs": 4800,
|
||||||
|
"volume": 0.34
|
||||||
|
},
|
||||||
|
"guidance:approaching": {
|
||||||
|
"loopGapMs": 950,
|
||||||
|
"volume": 0.58
|
||||||
|
},
|
||||||
|
"guidance:ready": {
|
||||||
|
"loopGapMs": 650,
|
||||||
|
"volume": 0.68
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"audioProfile": "default",
|
"audioProfile": "default",
|
||||||
"hapticsProfile": "default",
|
"hapticsProfile": "default",
|
||||||
@@ -327,7 +373,23 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. `playfield.controlOverrides` 字段说明
|
## 6. `playfield.controlDefaults` / `playfield.controlOverrides` 字段说明
|
||||||
|
|
||||||
|
推荐优先级:
|
||||||
|
|
||||||
|
`系统默认值 -> 玩法默认值 -> playfield.controlDefaults -> playfield.controlOverrides`
|
||||||
|
|
||||||
|
### `playfield.controlDefaults`
|
||||||
|
|
||||||
|
- 类型:`object`
|
||||||
|
- 说明:普通检查点的活动级默认配置
|
||||||
|
- 作用:减少重复书写,未单独覆盖的普通检查点默认继承这里
|
||||||
|
|
||||||
|
### `playfield.controlOverrides`
|
||||||
|
|
||||||
|
- 类型:`object`
|
||||||
|
- 说明:起点、普通点、终点的单点覆盖
|
||||||
|
- 作用:只写与活动默认不同的点
|
||||||
|
|
||||||
### key 命名规则
|
### key 命名规则
|
||||||
|
|
||||||
@@ -365,20 +427,22 @@
|
|||||||
|
|
||||||
- 类型:`string`
|
- 类型:`string`
|
||||||
- 说明:点击点位时弹出的标题
|
- 说明:点击点位时弹出的标题
|
||||||
- 默认逻辑:未配置时回退到 `title`
|
- 默认逻辑:最小模板下默认不启用;仅在显式配置点击内容能力时生效
|
||||||
|
|
||||||
#### `clickBody`
|
#### `clickBody`
|
||||||
|
|
||||||
- 类型:`string`
|
- 类型:`string`
|
||||||
- 说明:点击点位时弹出的正文
|
- 说明:点击点位时弹出的正文
|
||||||
- 默认逻辑:未配置时回退到 `body`
|
- 默认逻辑:最小模板下默认不启用;仅在显式配置点击内容能力时生效
|
||||||
|
|
||||||
#### `autoPopup`
|
#### `autoPopup`
|
||||||
|
|
||||||
- 类型:`boolean`
|
- 类型:`boolean`
|
||||||
- 说明:打点完成后是否自动弹出
|
- 说明:打点完成后是否自动弹出
|
||||||
- 默认逻辑:`true`
|
- 默认逻辑:最小模板下默认 `false`
|
||||||
- 特殊逻辑:`game.punch.policy = "enter"` 时不自动弹原生内容
|
- 特殊逻辑:`game.punch.policy = "enter"` 时不自动弹原生内容
|
||||||
|
- 补充说明:白色内容卡已改为显式配置启用;普通点只有显式设置 `autoPopup = true` 才会在打点后先弹白卡
|
||||||
|
- 补充说明:终点完成后默认直接进入结果页,不走白色内容卡链路
|
||||||
|
|
||||||
#### `once`
|
#### `once`
|
||||||
|
|
||||||
@@ -454,6 +518,7 @@
|
|||||||
|
|
||||||
- 类型:`boolean`
|
- 类型:`boolean`
|
||||||
- 说明:是否手动开始
|
- 说明:是否手动开始
|
||||||
|
- 顺序赛建议默认值:`false`
|
||||||
|
|
||||||
### `game.session.requiresStartPunch`
|
### `game.session.requiresStartPunch`
|
||||||
|
|
||||||
@@ -470,6 +535,14 @@
|
|||||||
- 类型:`boolean`
|
- 类型:`boolean`
|
||||||
- 说明:最后一个目标完成后是否自动结束
|
- 说明:最后一个目标完成后是否自动结束
|
||||||
|
|
||||||
|
### `game.session.minCompletedControlsBeforeFinish`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 说明:终点生效前至少需要完成的普通检查点数量
|
||||||
|
- 建议默认值:
|
||||||
|
- 顺序赛:`0`
|
||||||
|
- 积分赛:`1`
|
||||||
|
|
||||||
### `game.session.maxDurationSec`
|
### `game.session.maxDurationSec`
|
||||||
|
|
||||||
- 类型:`number`
|
- 类型:`number`
|
||||||
@@ -492,6 +565,9 @@
|
|||||||
|
|
||||||
- 类型:`boolean`
|
- 类型:`boolean`
|
||||||
- 说明:是否需要先聚焦/选中目标再打点
|
- 说明:是否需要先聚焦/选中目标再打点
|
||||||
|
- 建议默认值:
|
||||||
|
- 顺序赛:`false`
|
||||||
|
- 积分赛:`false`
|
||||||
|
|
||||||
### `game.sequence.skip.enabled`
|
### `game.sequence.skip.enabled`
|
||||||
|
|
||||||
@@ -502,22 +578,13 @@
|
|||||||
|
|
||||||
- 类型:`number`
|
- 类型:`number`
|
||||||
- 说明:跳点可用半径
|
- 说明:跳点可用半径
|
||||||
|
- 顺序赛建议默认值:打点半径的 `2` 倍
|
||||||
|
|
||||||
### `game.sequence.skip.requiresConfirm`
|
### `game.sequence.skip.requiresConfirm`
|
||||||
|
|
||||||
- 类型:`boolean`
|
- 类型:`boolean`
|
||||||
- 说明:跳点是否需要二次确认
|
- 说明:跳点是否需要二次确认
|
||||||
|
- 顺序赛建议默认值:`false`
|
||||||
### `game.scoring.type`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 说明:积分模式类型
|
|
||||||
- 当前常用值:`score`
|
|
||||||
|
|
||||||
### `game.scoring.defaultControlScore`
|
|
||||||
|
|
||||||
- 类型:`number`
|
|
||||||
- 说明:默认控制点分值
|
|
||||||
|
|
||||||
### `game.guidance.showLegs`
|
### `game.guidance.showLegs`
|
||||||
|
|
||||||
@@ -574,6 +641,41 @@
|
|||||||
- 类型:`string`
|
- 类型:`string`
|
||||||
- 说明:UI 动效 profile
|
- 说明:UI 动效 profile
|
||||||
|
|
||||||
|
### `game.audio`
|
||||||
|
|
||||||
|
- 类型:`object`
|
||||||
|
- 说明:高级音频运行时配置,用于控制三档距离提示音的距离阈值和 cue 参数
|
||||||
|
|
||||||
|
### `game.audio.distantDistanceMeters`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 说明:远距离提示音阈值
|
||||||
|
- 建议默认值:`80`
|
||||||
|
|
||||||
|
### `game.audio.approachDistanceMeters`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 说明:接近提示音阈值
|
||||||
|
- 建议默认值:`20`
|
||||||
|
|
||||||
|
### `game.audio.readyDistanceMeters`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 说明:可打点提示音阈值
|
||||||
|
- 建议默认值:`5`
|
||||||
|
- 备注:
|
||||||
|
- 运行时不会低于 `game.punch.radiusMeters`
|
||||||
|
|
||||||
|
### `game.audio.cues["guidance:distant" | "guidance:approaching" | "guidance:ready"]`
|
||||||
|
|
||||||
|
- 类型:`object`
|
||||||
|
- 说明:三档距离提示音的 cue 级配置
|
||||||
|
- 当前支持字段:
|
||||||
|
- `src`
|
||||||
|
- `volume`
|
||||||
|
- `loop`
|
||||||
|
- `loopGapMs`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. `resources` 字段说明
|
## 8. `resources` 字段说明
|
||||||
@@ -625,6 +727,12 @@
|
|||||||
},
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"mode": "score-o",
|
"mode": "score-o",
|
||||||
|
"session": {
|
||||||
|
"startManually": false
|
||||||
|
},
|
||||||
|
"punch": {
|
||||||
|
"requiresFocusSelection": true
|
||||||
|
},
|
||||||
"guidance": {
|
"guidance": {
|
||||||
"showLegs": false,
|
"showLegs": false,
|
||||||
"legAnimation": false,
|
"legAnimation": false,
|
||||||
@@ -637,7 +745,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
并在 `playfield.controlOverrides` 中为普通点补:
|
并在 `playfield.controlDefaults` 中先写普通点统一默认,必要时再在 `playfield.controlOverrides` 中为少量特殊点补:
|
||||||
|
|
||||||
- `score`
|
- `score`
|
||||||
|
|
||||||
@@ -648,4 +756,3 @@
|
|||||||
- [D:\dev\cmr-mini\doc\config-template-minimal-game.md](D:/dev/cmr-mini/doc/config/最小游戏配置模板.md)
|
- [D:\dev\cmr-mini\doc\config-template-minimal-game.md](D:/dev/cmr-mini/doc/config/最小游戏配置模板.md)
|
||||||
- [D:\dev\cmr-mini\doc\config-option-dictionary.md](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
- [D:\dev\cmr-mini\doc\config-option-dictionary.md](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
- [D:\dev\cmr-mini\doc\config-docs-index.md](D:/dev/cmr-mini/doc/config/配置文档索引.md)
|
- [D:\dev\cmr-mini\doc\config-docs-index.md](D:/dev/cmr-mini/doc/config/配置文档索引.md)
|
||||||
|
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
# 积分赛最小配置模板
|
|
||||||
|
|
||||||
本文档提供一份 **积分赛(`score-o`)最小可跑配置模板**。
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
- 只保留积分赛跑通所需的最少字段
|
|
||||||
- 适合快速起活动、联调、排查配置链
|
|
||||||
- 每个字段都带简要说明
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 最小模板
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schemaVersion": "1",
|
|
||||||
"version": "2026.03.30",
|
|
||||||
"app": {
|
|
||||||
"id": "sample-score-o-minimal-001",
|
|
||||||
"title": "积分赛最小示例"
|
|
||||||
},
|
|
||||||
"map": {
|
|
||||||
"tiles": "../map/lxcb-001/tiles/",
|
|
||||||
"mapmeta": "../map/lxcb-001/tiles/meta.json"
|
|
||||||
},
|
|
||||||
"playfield": {
|
|
||||||
"kind": "control-set",
|
|
||||||
"source": {
|
|
||||||
"type": "kml",
|
|
||||||
"url": "../kml/lxcb-001/10/c01.kml"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"game": {
|
|
||||||
"mode": "score-o",
|
|
||||||
"punch": {
|
|
||||||
"policy": "enter-confirm",
|
|
||||||
"radiusMeters": 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 字段说明
|
|
||||||
|
|
||||||
### `schemaVersion`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:配置结构版本
|
|
||||||
- 当前建议值:`"1"`
|
|
||||||
|
|
||||||
### `version`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:配置版本号
|
|
||||||
|
|
||||||
### `app.id`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:活动配置实例 ID
|
|
||||||
|
|
||||||
### `app.title`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:活动标题 / 比赛名称
|
|
||||||
|
|
||||||
### `map.tiles`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:地图瓦片根路径
|
|
||||||
|
|
||||||
### `map.mapmeta`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:地图 meta 文件路径
|
|
||||||
|
|
||||||
### `playfield.kind`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:空间对象类型
|
|
||||||
- 积分赛固定使用:`control-set`
|
|
||||||
|
|
||||||
### `playfield.source.type`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:空间底稿来源类型
|
|
||||||
- 当前推荐值:`kml`
|
|
||||||
|
|
||||||
### `playfield.source.url`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:KML 文件路径
|
|
||||||
|
|
||||||
### `game.mode`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:玩法模式
|
|
||||||
- 积分赛固定值:`score-o`
|
|
||||||
|
|
||||||
### `game.punch.policy`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:打点策略
|
|
||||||
- 当前常用值:
|
|
||||||
- `enter-confirm`
|
|
||||||
- `enter`
|
|
||||||
|
|
||||||
### `game.punch.radiusMeters`
|
|
||||||
|
|
||||||
- 类型:`number`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:打点判定半径,单位米
|
|
||||||
- 建议默认值:`5`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 当前默认逻辑
|
|
||||||
|
|
||||||
如果你不写下面这些字段,积分赛会按当前客户端默认逻辑运行:
|
|
||||||
|
|
||||||
- `map.declination`
|
|
||||||
- 默认按 `0` 处理
|
|
||||||
- `map.initialView.zoom`
|
|
||||||
- 默认由客户端初始视口逻辑接管
|
|
||||||
- `playfield.CPRadius`
|
|
||||||
- 默认按客户端内置值处理
|
|
||||||
- `playfield.controlOverrides.*.score`
|
|
||||||
- 没配时走 `game.scoring.defaultControlScore` 或玩法默认值
|
|
||||||
- `game.session.*`
|
|
||||||
- 默认手动开始
|
|
||||||
- `game.guidance.allowFocusSelection`
|
|
||||||
- 默认按积分赛逻辑允许选点
|
|
||||||
- `game.finish.finishControlAlwaysSelectable`
|
|
||||||
- 默认按积分赛逻辑处理终点可选
|
|
||||||
- `resources.*`
|
|
||||||
- 默认 profile
|
|
||||||
- `debug.*`
|
|
||||||
- 默认关闭
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 推荐补充字段
|
|
||||||
|
|
||||||
如果你要让积分赛更接近正式活动,通常很快会补这几项:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"playfield": {
|
|
||||||
"controlOverrides": {
|
|
||||||
"control-1": {
|
|
||||||
"score": 10
|
|
||||||
},
|
|
||||||
"control-2": {
|
|
||||||
"score": 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"game": {
|
|
||||||
"scoring": {
|
|
||||||
"type": "score",
|
|
||||||
"defaultControlScore": 10
|
|
||||||
},
|
|
||||||
"guidance": {
|
|
||||||
"allowFocusSelection": true
|
|
||||||
},
|
|
||||||
"finish": {
|
|
||||||
"finishControlAlwaysSelectable": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 适用场景
|
|
||||||
|
|
||||||
这份模板适合:
|
|
||||||
|
|
||||||
- 快速验证积分赛主流程
|
|
||||||
- 联调自由选点、积分累加、终点结束
|
|
||||||
- 后台先跑通最小积分赛配置
|
|
||||||
|
|
||||||
如果要看更完整版本,请继续参考:
|
|
||||||
|
|
||||||
- [D:\dev\cmr-mini\doc\config-template-full-current.md](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
|
||||||
- [D:\dev\cmr-mini\event\score-o.json](D:/dev/cmr-mini/event/score-o.json)
|
|
||||||
|
|
||||||
226
doc/config/配置分级总表.md
Normal file
226
doc/config/配置分级总表.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# 配置分级总表
|
||||||
|
|
||||||
|
本文档用于把当前配置体系按“核心必需项 / 常用活动项 / 高级实验项”三层整理,作为后续后台配置设计、活动装配和字段治理的统一依据。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 防止把所有内部变量都直接暴露成后台配置
|
||||||
|
- 明确哪些字段适合最先做成后台表单
|
||||||
|
- 明确哪些字段只应保留在高级配置区或 JSON 扩展区
|
||||||
|
- 支撑“默认能跑、少配能跑、多配能扩”的可伸缩配置方案
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 本文档关注的是“字段开放策略”,不是字段字典本身
|
||||||
|
- 字段定义、类型、默认值仍以 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) 为准
|
||||||
|
- 玩法专属使用范围仍以各玩法目录下的文档为准
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 分级原则
|
||||||
|
|
||||||
|
### 1.1 核心必需项
|
||||||
|
|
||||||
|
满足以下条件的字段应归入核心必需项:
|
||||||
|
|
||||||
|
- 不填就无法正常进入游戏
|
||||||
|
- 不填会导致地图、场地或玩法主流程无法成立
|
||||||
|
- 后台创建一个新活动时几乎一定会填写
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- 数量要少
|
||||||
|
- 后台首屏就能录入
|
||||||
|
- 应优先做成明确表单项
|
||||||
|
|
||||||
|
### 1.2 常用活动项
|
||||||
|
|
||||||
|
满足以下条件的字段应归入常用活动项:
|
||||||
|
|
||||||
|
- 不填也能跑,但活动运营经常会改
|
||||||
|
- 改动后会明显影响玩家体验或活动策略
|
||||||
|
- 属于“常见活动差异”,而不是内部实现细节
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- 可以在后台做成“高级设置”分组
|
||||||
|
- 建议有默认值
|
||||||
|
- 应控制数量,避免首版后台过重
|
||||||
|
|
||||||
|
### 1.3 高级实验项
|
||||||
|
|
||||||
|
满足以下条件的字段应归入高级实验项:
|
||||||
|
|
||||||
|
- 只在少数玩法或实验活动中才需要
|
||||||
|
- 更偏表现调优、体验实验、联调或研发控制
|
||||||
|
- 暂时不适合做成常规后台表单
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- 默认不暴露给普通运营
|
||||||
|
- 更适合放在高级 JSON 区或内部配置区
|
||||||
|
- 后续可以按实际使用频率再降级到常用活动项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 推荐后台策略
|
||||||
|
|
||||||
|
建议后续后台按三层展示:
|
||||||
|
|
||||||
|
1. 基础信息
|
||||||
|
2. 常用活动设置
|
||||||
|
3. 高级配置
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- 基础信息只对应核心必需项
|
||||||
|
- 常用活动设置对应常用活动项
|
||||||
|
- 高级配置对应高级实验项和 JSON 扩展
|
||||||
|
|
||||||
|
不建议:
|
||||||
|
|
||||||
|
- 首版后台直接开放全部字段
|
||||||
|
- 把玩家个人设置和活动规则配置混在一起
|
||||||
|
- 把纯运行时状态做成配置项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 当前分级总表
|
||||||
|
|
||||||
|
### 3.1 核心必需项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `schemaVersion` | 配置结构版本 | 建议固定为 `"1"` |
|
||||||
|
| `version` | 配置版本号 | 建议按发布日期或发布号维护 |
|
||||||
|
| `app.id` | 活动实例 ID | 必填 |
|
||||||
|
| `app.title` | 活动标题 | 必填 |
|
||||||
|
| `map.tiles` | 瓦片根路径 | 必填 |
|
||||||
|
| `map.mapmeta` | 地图元数据路径 | 必填 |
|
||||||
|
| `playfield.kind` | 场地类型 | 顺序赛常用 `course`,积分赛常用 `control-set` |
|
||||||
|
| `playfield.source.type` | 场地来源类型 | 当前推荐 `kml` |
|
||||||
|
| `playfield.source.url` | 场地源文件路径 | 必填 |
|
||||||
|
| `game.mode` | 玩法模式 | 当前核心玩法:`classic-sequential` / `score-o` |
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 核心必需项应控制在 `10` 个左右
|
||||||
|
- 新活动创建时,后台优先只要求填写这一层
|
||||||
|
|
||||||
|
### 3.2 常用活动项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `app.locale` | 语言环境 | 常见默认值 `zh-CN` |
|
||||||
|
| `settings.*.value` | 系统设置默认值 | 活动可覆盖玩家默认体验 |
|
||||||
|
| `settings.*.isLocked` | 系统设置锁态 | 只在本局生命周期内生效 |
|
||||||
|
| `map.declination` | 磁偏角 | 地图类活动常用 |
|
||||||
|
| `map.initialView.zoom` | 初始缩放 | 常见活动会调 |
|
||||||
|
| `playfield.CPRadius` | 控制点绘制半径 | 常用地图表现项 |
|
||||||
|
| `playfield.metadata.title` | 路线标题 | 常用展示信息 |
|
||||||
|
| `playfield.metadata.code` | 路线编码 | 常用管理字段 |
|
||||||
|
| `playfield.controlOverrides.<key>.score` | 点位分值覆盖 | 积分赛常用 |
|
||||||
|
| `game.session.requiresStartPunch` | 是否要求起点打卡 | 常用局流程控制 |
|
||||||
|
| `game.session.requiresFinishPunch` | 是否要求终点打卡 | 常用局流程控制 |
|
||||||
|
| `game.session.autoFinishOnLastControl` | 最后点自动结束 | 常用局流程控制 |
|
||||||
|
| `game.session.maxDurationSec` | 最大时长 / 关门时间 | 常用赛事规则项 |
|
||||||
|
| `game.punch.policy` | 打点方式 | 常用玩法差异项 |
|
||||||
|
| `game.punch.radiusMeters` | 打点半径 | 常用活动调节项 |
|
||||||
|
| `game.punch.requiresFocusSelection` | 是否先选目标 | 积分赛常用 |
|
||||||
|
| `game.sequence.skip.enabled` | 是否允许跳点 | 顺序赛常用 |
|
||||||
|
| `game.sequence.skip.radiusMeters` | 跳点半径 | 顺序赛常用 |
|
||||||
|
| `game.sequence.skip.requiresConfirm` | 跳点是否确认 | 顺序赛常用 |
|
||||||
|
| `game.guidance.showLegs` | 是否显示腿线 | 常用表现项 |
|
||||||
|
| `game.guidance.legAnimation` | 腿线动画 | 常用表现项 |
|
||||||
|
| `game.guidance.allowFocusSelection` | 是否允许地图选点 | 积分赛常用 |
|
||||||
|
| `game.visibility.revealFullPlayfieldAfterStartPunch` | 起点后是否揭示全场 | 常用局流程表现项 |
|
||||||
|
| `game.finish.finishControlAlwaysSelectable` | 终点是否始终可打 | 积分赛常用 |
|
||||||
|
| `game.scoring.defaultControlScore` | 默认点位分值 | 常用计分项 |
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 常用活动项是后台第二层的重点
|
||||||
|
- 这层优先服务“活动策划/运营经常会改的东西”
|
||||||
|
|
||||||
|
### 3.3 高级实验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `playfield.controlOverrides.<key>.template` | 白卡模板 | 内容实验项 |
|
||||||
|
| `playfield.controlOverrides.<key>.title` | 打点后内容标题 | 内容实验项 |
|
||||||
|
| `playfield.controlOverrides.<key>.body` | 打点后内容正文 | 内容实验项 |
|
||||||
|
| `playfield.controlOverrides.<key>.clickTitle` | 点击内容标题 | 显式启用型能力 |
|
||||||
|
| `playfield.controlOverrides.<key>.clickBody` | 点击内容正文 | 显式启用型能力 |
|
||||||
|
| `playfield.controlOverrides.<key>.autoPopup` | 是否自动弹白卡 | 内容实验项 |
|
||||||
|
| `playfield.controlOverrides.<key>.once` | 是否仅一次 | 内容实验项 |
|
||||||
|
| `playfield.controlOverrides.<key>.priority` | 内容优先级 | 内容实验项 |
|
||||||
|
| `playfield.controlOverrides.<key>.contentExperience.*` | 打点后 H5 / 原生体验 | 高级体验项 |
|
||||||
|
| `playfield.controlOverrides.<key>.clickExperience.*` | 点击 H5 / 原生体验 | 高级体验项 |
|
||||||
|
| `playfield.controlOverrides.<key>.pointStyle` | 单点样式覆盖 | 表现调优项 |
|
||||||
|
| `playfield.controlOverrides.<key>.pointColorHex` | 单点颜色覆盖 | 表现调优项 |
|
||||||
|
| `playfield.controlOverrides.<key>.pointSizeScale` | 单点尺寸倍率 | 表现调优项 |
|
||||||
|
| `playfield.controlOverrides.<key>.pointAccentRingScale` | 单点强调环倍率 | 表现调优项 |
|
||||||
|
| `playfield.controlOverrides.<key>.pointGlowStrength` | 单点光晕强度 | 表现调优项 |
|
||||||
|
| `playfield.controlOverrides.<key>.pointLabelScale` | 标签缩放 | 表现调优项 |
|
||||||
|
| `playfield.controlOverrides.<key>.pointLabelColorHex` | 标签颜色覆盖 | 表现调优项 |
|
||||||
|
| `playfield.legOverrides.<key>.*` | 腿线局部覆盖 | 表现调优项 |
|
||||||
|
| `game.presentation.*` | 全局点位/腿线样式 | 当前更适合高级区 |
|
||||||
|
| `game.presentation.track.*` | 轨迹表现细项 | 高级表现区 |
|
||||||
|
| `game.presentation.gpsMarker.*` | GPS 点表现细项 | 高级表现区 |
|
||||||
|
| `game.telemetry.*` | 遥测计算参数 | 高级区或设备联调区 |
|
||||||
|
| `game.audio.*` | 音效细项 | 高级表现区 |
|
||||||
|
| `game.haptics.*` | 震动细项 | 高级表现区 |
|
||||||
|
| `game.uiEffects.*` | UI 动效细项 | 高级表现区 |
|
||||||
|
| `resources.*` | 资源档与主题档 | 高级资源管理项 |
|
||||||
|
| `debug.*` | 调试与模拟字段 | 默认不进正式后台 |
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 这一层先不要全做成常规表单
|
||||||
|
- 后期可以根据真实使用频率,把一部分下放到常用活动项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 当前建议的开放策略
|
||||||
|
|
||||||
|
### 4.1 先开放
|
||||||
|
|
||||||
|
优先开放这些:
|
||||||
|
|
||||||
|
- 核心必需项
|
||||||
|
- 常用活动项中的对局规则、打点规则、完赛规则、分值规则
|
||||||
|
|
||||||
|
### 4.2 后开放
|
||||||
|
|
||||||
|
后续再开放这些:
|
||||||
|
|
||||||
|
- 常用活动项中的部分设置默认值
|
||||||
|
- 少量高频使用的内容卡字段
|
||||||
|
- 少量高频使用的表现字段
|
||||||
|
|
||||||
|
### 4.3 暂不开放
|
||||||
|
|
||||||
|
建议先不开放这些:
|
||||||
|
|
||||||
|
- 大量细粒度动画参数
|
||||||
|
- 大量音效和震动细项
|
||||||
|
- 纯调试字段
|
||||||
|
- 仅研发联调使用的实验字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 后续维护规则
|
||||||
|
|
||||||
|
后续每次新增配置能力时,建议先回答 3 个问题:
|
||||||
|
|
||||||
|
1. 这个字段是不是不配就跑不了?
|
||||||
|
2. 这个字段是不是活动经常会改?
|
||||||
|
3. 这个字段是不是只是研发或实验阶段才会动?
|
||||||
|
|
||||||
|
对应落点:
|
||||||
|
|
||||||
|
- 第 1 类:核心必需项
|
||||||
|
- 第 2 类:常用活动项
|
||||||
|
- 第 3 类:高级实验项
|
||||||
|
|
||||||
|
如果无法明确归类,默认先归入高级实验项,不急着开放到后台常规表单。
|
||||||
110
doc/config/配置发布说明.md
Normal file
110
doc/config/配置发布说明.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 配置发布说明
|
||||||
|
|
||||||
|
本文档说明当前项目如何把 `event/*.json` 配置同步到服务器。
|
||||||
|
|
||||||
|
## 1. 当前发布链路
|
||||||
|
|
||||||
|
当前客户端直接读取 OSS 上的静态配置:
|
||||||
|
|
||||||
|
- `classic-sequential`
|
||||||
|
- 远端对象:`gotomars/event/classic-sequential.json`
|
||||||
|
- 访问地址:`https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json`
|
||||||
|
- `score-o`
|
||||||
|
- 远端对象:`gotomars/event/score-o.json`
|
||||||
|
- 访问地址:`https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json`
|
||||||
|
|
||||||
|
对应加载入口见:
|
||||||
|
|
||||||
|
- [map.ts](D:/dev/cmr-mini/miniprogram/pages/map/map.ts)
|
||||||
|
|
||||||
|
## 2. 仓库内已有上传能力
|
||||||
|
|
||||||
|
项目根目录已有 OSS 上传脚本:
|
||||||
|
|
||||||
|
- [oss-html.ps1](D:/dev/cmr-mini/oss-html.ps1)
|
||||||
|
|
||||||
|
该脚本封装了 `tools/ossutil.exe`,默认 bucket 为:
|
||||||
|
|
||||||
|
- `oss://color-map-html`
|
||||||
|
|
||||||
|
依赖前提:
|
||||||
|
|
||||||
|
- 本机存在 [ossutil.exe](D:/dev/cmr-mini/tools/ossutil.exe)
|
||||||
|
- 本机存在 `~/.ossutilconfig`
|
||||||
|
|
||||||
|
## 3. 推荐发布命令
|
||||||
|
|
||||||
|
项目根目录新增了专门的配置发布脚本:
|
||||||
|
|
||||||
|
- [publish-event-config.ps1](D:/dev/cmr-mini/publish-event-config.ps1)
|
||||||
|
|
||||||
|
它会在上传前执行这些检查:
|
||||||
|
|
||||||
|
- 本地配置文件是否存在
|
||||||
|
- JSON 是否可解析
|
||||||
|
- 是否包含 `schemaVersion`
|
||||||
|
- 是否包含 `game`
|
||||||
|
- 是否包含 `game.mode`
|
||||||
|
|
||||||
|
### 发布全部玩法配置
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\publish-event-config.ps1 all
|
||||||
|
```
|
||||||
|
|
||||||
|
### 只发布顺序打点配置
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\publish-event-config.ps1 classic-sequential
|
||||||
|
```
|
||||||
|
|
||||||
|
### 只发布积分赛配置
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\publish-event-config.ps1 score-o
|
||||||
|
```
|
||||||
|
|
||||||
|
### 仅检查,不上传
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\publish-event-config.ps1 all -DryRun
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. npm 快捷命令
|
||||||
|
|
||||||
|
也可以使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run publish:config
|
||||||
|
npm run publish:config:classic
|
||||||
|
npm run publish:config:score-o
|
||||||
|
npm run publish:config:dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 当前默认映射关系
|
||||||
|
|
||||||
|
| 本地文件 | 远端对象 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `event/classic-sequential.json` | `gotomars/event/classic-sequential.json` | 顺序打点默认配置 |
|
||||||
|
| `event/score-o.json` | `gotomars/event/score-o.json` | 积分赛默认配置 |
|
||||||
|
|
||||||
|
## 6. 维护约定
|
||||||
|
|
||||||
|
后续如果新增新玩法配置发布,建议同步修改以下位置:
|
||||||
|
|
||||||
|
1. [publish-event-config.ps1](D:/dev/cmr-mini/publish-event-config.ps1)
|
||||||
|
2. [package.json](D:/dev/cmr-mini/package.json)
|
||||||
|
3. [配置发布说明.md](D:/dev/cmr-mini/doc/config/配置发布说明.md)
|
||||||
|
4. [配置文档索引.md](D:/dev/cmr-mini/doc/config/配置文档索引.md)
|
||||||
|
5. 对应玩法目录下的样例配置说明
|
||||||
|
|
||||||
|
## 7. 后续演进方向
|
||||||
|
|
||||||
|
当前方案属于“本地校验 + 手动发布到 OSS”。
|
||||||
|
|
||||||
|
后续接入正式后台后,推荐演进为:
|
||||||
|
|
||||||
|
1. 后台装配并校验配置
|
||||||
|
2. 后台生成发布版本
|
||||||
|
3. 后台上传 OSS/CDN
|
||||||
|
4. 客户端仍只读取静态 JSON
|
||||||
@@ -1,220 +1,64 @@
|
|||||||
# 配置文档索引
|
# 配置文档索引
|
||||||
|
|
||||||
本文档用于汇总当前项目所有与**配置设计、配置样例、配置管理**相关的文档,作为统一入口。
|
本文档用于汇总当前项目所有与配置设计、配置样例、配置管理相关的文档,并按“公共配置”和“按游戏分类”两层组织。
|
||||||
|
|
||||||
适用对象:
|
## 1. 公共配置入口
|
||||||
|
|
||||||
- 客户端开发
|
- [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
- 服务端开发
|
字段字典、类型、默认逻辑总入口
|
||||||
- 后台管理设计
|
- [配置分级总表](D:/dev/cmr-mini/doc/config/配置分级总表.md)
|
||||||
- 配置录入与联调
|
配置项的开放分级与后台推荐策略
|
||||||
|
- [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md)
|
||||||
---
|
跨玩法公共配置块
|
||||||
|
- [最小游戏配置模板](D:/dev/cmr-mini/doc/config/最小游戏配置模板.md)
|
||||||
## 1. 配置核心结构
|
最小通用骨架
|
||||||
|
- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
||||||
当前项目的配置主入口已经稳定在:
|
当前共享全量模板
|
||||||
|
- [后台配置管理方案V2](D:/dev/cmr-mini/doc/config/后台配置管理方案V2.md)
|
||||||
```json
|
后台管理与发布方案
|
||||||
{
|
- [配置发布说明](D:/dev/cmr-mini/doc/config/配置发布说明.md)
|
||||||
"schemaVersion": "1",
|
当前 OSS 配置发布命令与默认映射
|
||||||
"version": "2026.03.30",
|
|
||||||
"app": {},
|
## 2. 按游戏分类
|
||||||
"map": {},
|
|
||||||
"playfield": {},
|
### 顺序打点
|
||||||
"game": {},
|
|
||||||
"resources": {},
|
- [游戏说明文档](D:/dev/cmr-mini/doc/games/顺序打点/游戏说明文档.md)
|
||||||
"debug": {}
|
- [规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md)
|
||||||
}
|
- [最小配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最小配置模板.md)
|
||||||
```
|
- [最大配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最大配置模板.md)
|
||||||
|
- [全局配置项](D:/dev/cmr-mini/doc/games/顺序打点/全局配置项.md)
|
||||||
顶层职责建议固定为:
|
- [游戏配置项](D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md)
|
||||||
|
- [样例配置 classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
|
||||||
- `app`
|
|
||||||
活动级基础信息
|
### 积分赛
|
||||||
- `map`
|
|
||||||
地图底图与空间底座
|
- [游戏说明文档](D:/dev/cmr-mini/doc/games/积分赛/游戏说明文档.md)
|
||||||
- `playfield`
|
- [规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md)
|
||||||
当前玩法使用的空间对象定义
|
- [最小配置模板](D:/dev/cmr-mini/doc/games/积分赛/最小配置模板.md)
|
||||||
- `game`
|
- [最大配置模板](D:/dev/cmr-mini/doc/games/积分赛/最大配置模板.md)
|
||||||
当前玩法规则配置
|
- [全局配置项](D:/dev/cmr-mini/doc/games/积分赛/全局配置项.md)
|
||||||
- `resources`
|
- [游戏配置项](D:/dev/cmr-mini/doc/games/积分赛/游戏配置项.md)
|
||||||
资源包与 profile
|
- [样例配置 score-o.json](D:/dev/cmr-mini/event/score-o.json)
|
||||||
- `debug`
|
|
||||||
调试与开发开关
|
## 3. 推荐阅读顺序
|
||||||
|
|
||||||
当前推荐的核心原则:
|
1. [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
|
2. [配置分级总表](D:/dev/cmr-mini/doc/config/配置分级总表.md)
|
||||||
- 配置只描述,不执行逻辑
|
3. [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md)
|
||||||
- `KML` 描述空间事实,配置描述玩法解释
|
4. 对应玩法目录下的 [游戏说明文档](D:/dev/cmr-mini/doc/games/顺序打点/游戏说明文档.md) 或 [游戏说明文档](D:/dev/cmr-mini/doc/games/积分赛/游戏说明文档.md)
|
||||||
- `playfield` 是上位概念,`course` 只是其中一种 `kind`
|
5. 对应玩法目录下的最小配置模板、最大配置模板、全局配置项、游戏配置项
|
||||||
- 当前阶段继续以单文件配置为主,后续再逐步升级成 manifest 组合
|
6. 对应 `event/*.json` 样例
|
||||||
|
|
||||||
如果你需要看旧版长文讨论稿,已经移到归档:
|
## 4. 维护约定
|
||||||
|
|
||||||
- [config-design-proposal.md](/D:/dev/cmr-mini/doc/archive/config/配置设计方案.md)
|
后续每次新增玩法或新增字段时,建议至少同步这几处:
|
||||||
|
|
||||||
---
|
1. [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
|
2. [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md)
|
||||||
## 2. 配置选项字典
|
3. 对应玩法目录下的规则说明文档
|
||||||
|
4. 对应玩法目录下的最小配置模板
|
||||||
### [config-option-dictionary.md](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
5. 对应玩法目录下的最大配置模板
|
||||||
|
6. 对应玩法目录下的全局配置项
|
||||||
作用:
|
7. 对应玩法目录下的游戏配置项
|
||||||
|
8. 对应玩法的 `event/*.json` 样例
|
||||||
- 列出当前客户端已经支持或已预留的配置项
|
|
||||||
- 说明每个字段的类型、含义、默认逻辑
|
|
||||||
- 作为后续新增字段时的持续维护文档
|
|
||||||
|
|
||||||
适合阅读时机:
|
|
||||||
|
|
||||||
- 想知道某个字段是否已实现
|
|
||||||
- 想知道字段应该怎么写
|
|
||||||
- 想确认默认行为时
|
|
||||||
|
|
||||||
### [track-visualization-proposal.md](D:/dev/cmr-mini/doc/rendering/轨迹可视化方案.md)
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 说明 `none / full / tail` 三种轨迹模式
|
|
||||||
- 说明拖尾轨迹的默认策略与推荐参数
|
|
||||||
- 说明当前轨迹样式的配置结构
|
|
||||||
|
|
||||||
### [gps-marker-style-system-proposal.md](D:/dev/cmr-mini/doc/rendering/GPS点样式系统方案.md)
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 说明 GPS 点样式系统的目标分层
|
|
||||||
- 说明默认样式、朝向小三角和品牌 logo 扩展思路
|
|
||||||
- 说明第一阶段最小实现字段和长期演进方向
|
|
||||||
|
|
||||||
### [gps-marker-animation-system-proposal.md](D:/dev/cmr-mini/doc/rendering/GPS点动画系统方案.md)
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 说明 GPS 点动画系统的状态分层
|
|
||||||
- 说明 `idle / moving / fast-moving / warning` 的第一阶段实现思路
|
|
||||||
- 说明动画 profile、运行时内部字段和 `standard / lite` 降级策略
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 当前推荐模板
|
|
||||||
|
|
||||||
### [config-template-minimal-game.md](D:/dev/cmr-mini/doc/config/最小游戏配置模板.md)
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 提供“最小可跑”的游戏配置模板
|
|
||||||
- 去掉绝大部分选配项
|
|
||||||
- 适合快速起步、联调和排查配置链
|
|
||||||
|
|
||||||
### [config-template-minimal-classic-sequential.md](D:/dev/cmr-mini/doc/config/顺序赛最小配置模板.md)
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 提供顺序赛最小可跑模板
|
|
||||||
- 适合快速起顺序赛活动
|
|
||||||
|
|
||||||
### [config-template-minimal-score-o.md](D:/dev/cmr-mini/doc/config/积分赛最小配置模板.md)
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 提供积分赛最小可跑模板
|
|
||||||
- 适合快速起积分赛活动
|
|
||||||
|
|
||||||
### [config-template-full-current.md](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 提供“当前开发状态最全”的配置模板
|
|
||||||
- 汇总目前客户端已实现或已消费的主要字段
|
|
||||||
- 适合后端、后台和联调统一对齐
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 运行中的样例配置
|
|
||||||
|
|
||||||
### [event/classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 当前顺序赛样例配置
|
|
||||||
- 可直接联调
|
|
||||||
- 已包含控制点内容覆盖示例
|
|
||||||
|
|
||||||
### [event/score-o.json](D:/dev/cmr-mini/event/score-o.json)
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 当前积分赛样例配置
|
|
||||||
- 可直接联调
|
|
||||||
- 已包含分值、起终点内容、点击内容示例
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 后台与服务端配置管理方案
|
|
||||||
|
|
||||||
### [backend-config-management-v2.md](D:/dev/cmr-mini/doc/config/后台配置管理方案V2.md)
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 在“配置项变化频繁”前提下重写的后台方案
|
|
||||||
- 更强调:
|
|
||||||
- 稳定骨架
|
|
||||||
- `jsonb`
|
|
||||||
- 版本
|
|
||||||
- 发布
|
|
||||||
- 透传未知字段
|
|
||||||
|
|
||||||
推荐优先看这一份。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 推荐阅读顺序
|
|
||||||
|
|
||||||
如果你是第一次接触这套配置体系,建议按这个顺序看:
|
|
||||||
|
|
||||||
1. 本页“配置核心结构”一节
|
|
||||||
2. [config-option-dictionary.md](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
|
||||||
3. [config-template-minimal-game.md](D:/dev/cmr-mini/doc/config/最小游戏配置模板.md)
|
|
||||||
4. [config-template-minimal-classic-sequential.md](D:/dev/cmr-mini/doc/config/顺序赛最小配置模板.md)
|
|
||||||
5. [config-template-minimal-score-o.md](D:/dev/cmr-mini/doc/config/积分赛最小配置模板.md)
|
|
||||||
6. [config-template-full-current.md](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
|
||||||
7. [event/classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
|
|
||||||
8. [event/score-o.json](D:/dev/cmr-mini/event/score-o.json)
|
|
||||||
9. [backend-config-management-v2.md](D:/dev/cmr-mini/doc/config/后台配置管理方案V2.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 维护约定
|
|
||||||
|
|
||||||
后续每次新增配置能力时,建议至少同步更新这几处:
|
|
||||||
|
|
||||||
1. [config-option-dictionary.md](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
|
||||||
2. [config-template-minimal-game.md](D:/dev/cmr-mini/doc/config/最小游戏配置模板.md)
|
|
||||||
3. [config-template-minimal-classic-sequential.md](D:/dev/cmr-mini/doc/config/顺序赛最小配置模板.md)
|
|
||||||
4. [config-template-minimal-score-o.md](D:/dev/cmr-mini/doc/config/积分赛最小配置模板.md)
|
|
||||||
5. [config-template-full-current.md](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
|
||||||
6. 对应玩法的 `event/*.json` 样例
|
|
||||||
7. 如果涉及顶层结构变化,先更新本页“配置核心结构”一节,再视情况补充归档讨论稿
|
|
||||||
|
|
||||||
这样可以保证:
|
|
||||||
|
|
||||||
- 文档
|
|
||||||
- 样例
|
|
||||||
- 代码
|
|
||||||
- 后台录入
|
|
||||||
|
|
||||||
保持一致。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 已归档文档
|
|
||||||
|
|
||||||
下列文档仍保留在归档目录,但不再作为当前主入口:
|
|
||||||
|
|
||||||
- [config-default-template.md](/D:/dev/cmr-mini/doc/archive/config/默认配置模板.md)
|
|
||||||
- [config-design-proposal.md](/D:/dev/cmr-mini/doc/archive/config/配置设计方案.md)
|
|
||||||
- [config-template-classic-sequential.md](/D:/dev/cmr-mini/doc/archive/config/顺序赛配置模板.md)
|
|
||||||
- [config-template-score-o.md](/D:/dev/cmr-mini/doc/archive/config/积分赛配置模板.md)
|
|
||||||
- [backend-config-management-proposal.md](/D:/dev/cmr-mini/doc/archive/config/后台配置管理方案.md)
|
|
||||||
|
|||||||
@@ -23,8 +23,9 @@
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schemaVersion": "1",
|
"schemaVersion": "1",
|
||||||
"version": "2026.03.27",
|
"version": "2026.03.31",
|
||||||
"app": {},
|
"app": {},
|
||||||
|
"settings": {},
|
||||||
"map": {},
|
"map": {},
|
||||||
"playfield": {},
|
"playfield": {},
|
||||||
"game": {},
|
"game": {},
|
||||||
@@ -54,6 +55,12 @@
|
|||||||
- 类型:`object`
|
- 类型:`object`
|
||||||
- 说明:活动级基础信息
|
- 说明:活动级基础信息
|
||||||
|
|
||||||
|
### `settings`
|
||||||
|
|
||||||
|
- 类型:`object`
|
||||||
|
- 说明:系统设置页默认值与锁态配置
|
||||||
|
- 备注:只控制设置页公共项,不属于具体玩法规则本体
|
||||||
|
|
||||||
### `map`
|
### `map`
|
||||||
|
|
||||||
- 类型:`object`
|
- 类型:`object`
|
||||||
@@ -81,6 +88,40 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `settings` 字段补充
|
||||||
|
|
||||||
|
推荐结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"autoRotateEnabled": {
|
||||||
|
"value": true,
|
||||||
|
"isLocked": false
|
||||||
|
},
|
||||||
|
"trackDisplayMode": {
|
||||||
|
"value": "tail",
|
||||||
|
"isLocked": true
|
||||||
|
},
|
||||||
|
"gpsMarkerStyle": {
|
||||||
|
"value": "beacon",
|
||||||
|
"isLocked": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `value` 表示该设置项的活动默认值
|
||||||
|
- `isLocked` 表示该设置项是否允许玩家修改
|
||||||
|
- `value` 会和玩家本地持久化值一起参与解析
|
||||||
|
- `isLocked` 不持久化,只受系统默认值和活动配置控制
|
||||||
|
- 玩家不能在页面里修改锁态
|
||||||
|
- `isLocked` 只在当前游戏配置运行生命周期内生效;本局结束或主动退出后失效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 3. `app` 字段
|
## 3. `app` 字段
|
||||||
|
|
||||||
### `app.id`
|
### `app.id`
|
||||||
@@ -172,23 +213,63 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. `playfield.controlOverrides`
|
## 6. `playfield.controlDefaults` / `playfield.controlOverrides`
|
||||||
|
|
||||||
`playfield.controlOverrides` 用于对起点、检查点、终点做内容或分值覆盖。
|
推荐优先使用:
|
||||||
|
|
||||||
### 6.1 key 命名规则
|
- `playfield.controlDefaults`:活动级默认
|
||||||
|
- `playfield.controlOverrides`:单点重载
|
||||||
|
|
||||||
|
默认覆盖顺序:
|
||||||
|
|
||||||
|
`系统默认值 -> 玩法默认值 -> playfield.controlDefaults -> playfield.controlOverrides`
|
||||||
|
|
||||||
|
### 6.1 `playfield.controlDefaults`
|
||||||
|
|
||||||
|
- 类型:`object`
|
||||||
|
- 说明:普通检查点的活动级默认配置
|
||||||
|
- 适用:普通检查点,不直接作用于起点和终点
|
||||||
|
|
||||||
|
当前支持字段:
|
||||||
|
|
||||||
|
- `score`
|
||||||
|
- `template`
|
||||||
|
- `title`
|
||||||
|
- `body`
|
||||||
|
- `clickTitle`
|
||||||
|
- `clickBody`
|
||||||
|
- `autoPopup`
|
||||||
|
- `once`
|
||||||
|
- `priority`
|
||||||
|
- `ctas`
|
||||||
|
- `contentExperience`
|
||||||
|
- `clickExperience`
|
||||||
|
- `pointStyle`
|
||||||
|
- `pointColorHex`
|
||||||
|
- `pointSizeScale`
|
||||||
|
- `pointAccentRingScale`
|
||||||
|
- `pointGlowStrength`
|
||||||
|
- `pointLabelScale`
|
||||||
|
- `pointLabelColorHex`
|
||||||
|
|
||||||
|
### 6.2 `playfield.controlOverrides`
|
||||||
|
|
||||||
|
`playfield.controlOverrides` 用于对起点、检查点、终点做单点覆盖。
|
||||||
|
|
||||||
|
### 6.3 key 命名规则
|
||||||
|
|
||||||
- 起点:`start-1`
|
- 起点:`start-1`
|
||||||
- 普通检查点:`control-1`、`control-2`、`control-3`
|
- 普通检查点:`control-1`、`control-2`、`control-3`
|
||||||
- 终点:`finish-1`
|
- 终点:`finish-1`
|
||||||
|
|
||||||
### 6.2 当前支持字段
|
### 6.4 当前支持字段
|
||||||
|
|
||||||
#### `score`
|
#### `score`
|
||||||
|
|
||||||
- 类型:`number`
|
- 类型:`number`
|
||||||
- 说明:积分赛控制点分值
|
- 说明:积分赛控制点分值
|
||||||
- 适用:积分赛
|
- 适用:积分赛
|
||||||
|
- 备注:如果同时配置了 `playfield.controlDefaults.score`,则当前点以单点值为准
|
||||||
|
|
||||||
#### `title`
|
#### `title`
|
||||||
|
|
||||||
@@ -216,20 +297,22 @@
|
|||||||
|
|
||||||
- 类型:`string`
|
- 类型:`string`
|
||||||
- 说明:点击控制点时弹出的标题
|
- 说明:点击控制点时弹出的标题
|
||||||
- 默认逻辑:未配置时回退到 `title`
|
- 默认逻辑:最小模板下默认不启用;仅在显式配置点击内容能力时生效
|
||||||
|
|
||||||
#### `clickBody`
|
#### `clickBody`
|
||||||
|
|
||||||
- 类型:`string`
|
- 类型:`string`
|
||||||
- 说明:点击控制点时弹出的正文
|
- 说明:点击控制点时弹出的正文
|
||||||
- 默认逻辑:未配置时回退到 `body`
|
- 默认逻辑:最小模板下默认不启用;仅在显式配置点击内容能力时生效
|
||||||
|
|
||||||
#### `autoPopup`
|
#### `autoPopup`
|
||||||
|
|
||||||
- 类型:`boolean`
|
- 类型:`boolean`
|
||||||
- 说明:完成该点后是否自动弹出内容
|
- 说明:完成该点后是否自动弹出内容卡
|
||||||
- 建议默认值:`true`
|
- 建议默认值:最小模板下 `false`
|
||||||
- 特殊逻辑:如果当前玩法是自动打点,即 `game.punch.policy = "enter"`,则无论这里如何配置,**都不自动弹出**
|
- 特殊逻辑:如果当前玩法是自动打点,即 `game.punch.policy = "enter"`,则无论这里如何配置,**都不自动弹出**
|
||||||
|
- 补充说明:该字段只控制内容卡弹出
|
||||||
|
- 补充说明:系统默认白卡已改为“显式配置启用”,未开启 `autoPopup` 时,起点和普通点完成后不弹白卡
|
||||||
|
|
||||||
#### `once`
|
#### `once`
|
||||||
|
|
||||||
@@ -415,7 +498,7 @@
|
|||||||
"template": "focus",
|
"template": "focus",
|
||||||
"title": "比赛结束",
|
"title": "比赛结束",
|
||||||
"body": "恭喜完成本次路线。",
|
"body": "恭喜完成本次路线。",
|
||||||
"autoPopup": true,
|
"autoPopup": false,
|
||||||
"once": true,
|
"once": true,
|
||||||
"priority": 2,
|
"priority": 2,
|
||||||
"clickTitle": "终点说明",
|
"clickTitle": "终点说明",
|
||||||
@@ -450,7 +533,11 @@
|
|||||||
|
|
||||||
- 类型:`boolean`
|
- 类型:`boolean`
|
||||||
- 说明:是否需要手动点击开始
|
- 说明:是否需要手动点击开始
|
||||||
- 建议默认值:`true`
|
- 建议默认值:
|
||||||
|
- 顺序赛:`false`
|
||||||
|
- 积分赛:`false`
|
||||||
|
- 备注:
|
||||||
|
- 进入页面后先进入待起跑态,通过开始点打卡正式开赛
|
||||||
|
|
||||||
### `game.session.requiresStartPunch`
|
### `game.session.requiresStartPunch`
|
||||||
|
|
||||||
@@ -474,6 +561,14 @@
|
|||||||
- 说明:是否打完最后控制点自动结束
|
- 说明:是否打完最后控制点自动结束
|
||||||
- 建议默认值:`false`
|
- 建议默认值:`false`
|
||||||
|
|
||||||
|
### `game.session.minCompletedControlsBeforeFinish`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 说明:终点生效前至少需要完成的普通检查点数量
|
||||||
|
- 建议默认值:
|
||||||
|
- 顺序赛:`0`
|
||||||
|
- 积分赛:`1`
|
||||||
|
|
||||||
### `game.session.maxDurationSec`
|
### `game.session.maxDurationSec`
|
||||||
|
|
||||||
- 类型:`number`
|
- 类型:`number`
|
||||||
@@ -517,19 +612,22 @@
|
|||||||
|
|
||||||
- 类型:`boolean`
|
- 类型:`boolean`
|
||||||
- 说明:是否允许跳点
|
- 说明:是否允许跳点
|
||||||
- 建议默认值:`false`
|
- 建议默认值:
|
||||||
|
- 顺序赛:`true`
|
||||||
|
- 积分赛:`false`
|
||||||
|
|
||||||
### `game.sequence.skip.radiusMeters`
|
### `game.sequence.skip.radiusMeters`
|
||||||
|
|
||||||
- 类型:`number`
|
- 类型:`number`
|
||||||
- 说明:跳点半径
|
- 说明:跳点半径
|
||||||
- 建议默认值:`30`
|
- 建议默认值:
|
||||||
|
- 顺序赛:`game.punch.radiusMeters * 2`
|
||||||
|
|
||||||
### `game.sequence.skip.requiresConfirm`
|
### `game.sequence.skip.requiresConfirm`
|
||||||
|
|
||||||
- 类型:`boolean`
|
- 类型:`boolean`
|
||||||
- 说明:跳点是否需要确认
|
- 说明:跳点是否需要确认
|
||||||
- 建议默认值:`true`
|
- 建议默认值:`false`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -544,8 +642,11 @@
|
|||||||
### `game.scoring.defaultControlScore`
|
### `game.scoring.defaultControlScore`
|
||||||
|
|
||||||
- 类型:`number`
|
- 类型:`number`
|
||||||
- 说明:积分赛默认控制点分值
|
- 说明:普通控制点未单独配置时的默认基础分
|
||||||
- 建议默认值:`10`
|
- 建议默认值:
|
||||||
|
- 顺序赛:`1`
|
||||||
|
- 积分赛:`10`
|
||||||
|
- 适用:顺序赛、积分赛
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -596,6 +697,9 @@
|
|||||||
- 建议默认值:
|
- 建议默认值:
|
||||||
- 顺序赛:`false`
|
- 顺序赛:`false`
|
||||||
- 积分赛:`true`
|
- 积分赛:`true`
|
||||||
|
- 备注:
|
||||||
|
- 顺序赛默认要求所有中间点都已被标记为“成功”或“跳过”后,终点才可生效
|
||||||
|
- 积分赛默认开赛后终点始终可结束,不需要先设为目标点
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -641,6 +745,50 @@
|
|||||||
- 说明:UI 动效 profile
|
- 说明:UI 动效 profile
|
||||||
- 建议默认值:`default`
|
- 建议默认值:`default`
|
||||||
|
|
||||||
|
### `game.audio.distantDistanceMeters`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 说明:远距离提示音的最大生效距离,超出该距离默认静默
|
||||||
|
- 建议默认值:`80`
|
||||||
|
|
||||||
|
### `game.audio.approachDistanceMeters`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 说明:接近提示音的最大生效距离
|
||||||
|
- 建议默认值:`20`
|
||||||
|
|
||||||
|
### `game.audio.readyDistanceMeters`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 说明:可打点提示音的最大生效距离
|
||||||
|
- 建议默认值:`5`
|
||||||
|
- 备注:
|
||||||
|
- 运行时不会小于 `game.punch.radiusMeters`
|
||||||
|
|
||||||
|
### `game.audio.cues["guidance:distant"].loopGapMs`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 说明:远距离提示音循环间隔,单位毫秒
|
||||||
|
- 建议默认值:`4800`
|
||||||
|
|
||||||
|
### `game.audio.cues["guidance:approaching"].loopGapMs`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 说明:接近提示音循环间隔,单位毫秒
|
||||||
|
- 建议默认值:`950`
|
||||||
|
|
||||||
|
### `game.audio.cues["guidance:ready"].loopGapMs`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 说明:可打点提示音循环间隔,单位毫秒
|
||||||
|
- 建议默认值:`650`
|
||||||
|
|
||||||
|
### `game.audio.cues["guidance:distant" | "guidance:approaching" | "guidance:ready"].volume`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 说明:三档距离提示音各自音量
|
||||||
|
- 建议范围:`0 ~ 1`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 17. `resources`
|
## 17. `resources`
|
||||||
@@ -710,6 +858,8 @@
|
|||||||
|
|
||||||
- 类型:`object`
|
- 类型:`object`
|
||||||
- 说明:顺序赛已跳过点样式
|
- 说明:顺序赛已跳过点样式
|
||||||
|
- 备注:
|
||||||
|
- 默认建议使用偏橙色系,与已完成灰色态区分
|
||||||
|
|
||||||
### `game.presentation.sequential.controls.start`
|
### `game.presentation.sequential.controls.start`
|
||||||
|
|
||||||
@@ -730,6 +880,9 @@
|
|||||||
- `colorHex`:十六进制颜色
|
- `colorHex`:十六进制颜色
|
||||||
- `widthScale`:路线腿宽度倍率
|
- `widthScale`:路线腿宽度倍率
|
||||||
- `glowStrength`:路线腿光晕强度
|
- `glowStrength`:路线腿光晕强度
|
||||||
|
- 备注:
|
||||||
|
- 默认建议使用传统定向运动紫红色系
|
||||||
|
- 默认配合电流动效使用
|
||||||
|
|
||||||
### `game.presentation.sequential.legs.completed`
|
### `game.presentation.sequential.legs.completed`
|
||||||
|
|
||||||
@@ -740,6 +893,17 @@
|
|||||||
|
|
||||||
- 类型:`object`
|
- 类型:`object`
|
||||||
- 说明:对指定路线腿做局部样式覆盖
|
- 说明:对指定路线腿做局部样式覆盖
|
||||||
|
- 建议:优先使用 `playfield.legDefaults` 写整场腿线默认,再用 `legOverrides` 写单腿例外
|
||||||
|
|
||||||
|
### `playfield.legDefaults`
|
||||||
|
|
||||||
|
- 类型:`object`
|
||||||
|
- 说明:腿线的活动级默认样式
|
||||||
|
- 当前支持字段:
|
||||||
|
- `style`
|
||||||
|
- `colorHex`
|
||||||
|
- `widthScale`
|
||||||
|
- `glowStrength`
|
||||||
- 键名建议:
|
- 键名建议:
|
||||||
- `leg-1`
|
- `leg-1`
|
||||||
- `leg-2`
|
- `leg-2`
|
||||||
@@ -766,6 +930,7 @@
|
|||||||
|
|
||||||
- 类型:`object`
|
- 类型:`object`
|
||||||
- 说明:积分赛默认点位样式
|
- 说明:积分赛默认点位样式
|
||||||
|
- 当前默认建议使用传统圆圈样式,编号绘制在圆圈内
|
||||||
|
|
||||||
### `game.presentation.scoreO.controls.focused`
|
### `game.presentation.scoreO.controls.focused`
|
||||||
|
|
||||||
@@ -810,8 +975,8 @@
|
|||||||
"controls": {
|
"controls": {
|
||||||
"scoreBands": [
|
"scoreBands": [
|
||||||
{ "min": 0, "max": 19, "style": "classic-ring", "colorHex": "#56ccf2" },
|
{ "min": 0, "max": 19, "style": "classic-ring", "colorHex": "#56ccf2" },
|
||||||
{ "min": 20, "max": 49, "style": "double-ring", "colorHex": "#f2c94c" },
|
{ "min": 20, "max": 49, "style": "classic-ring", "colorHex": "#f2c94c" },
|
||||||
{ "min": 50, "max": 999999, "style": "badge", "colorHex": "#eb5757" }
|
{ "min": 50, "max": 999999, "style": "double-ring", "colorHex": "#eb5757" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1049,7 +1214,7 @@
|
|||||||
|
|
||||||
- 能有默认值的尽量给默认值
|
- 能有默认值的尽量给默认值
|
||||||
- 控制点内容类字段缺失时走默认文案
|
- 控制点内容类字段缺失时走默认文案
|
||||||
- `clickTitle/clickBody` 缺失时回退到 `title/body`
|
- `clickTitle/clickBody` 在最小模板下默认关闭,不再回退到 `title/body`
|
||||||
- 自动打点模式下不自动弹内容
|
- 自动打点模式下不自动弹内容
|
||||||
- 内容优先级未配置时使用普通点 `1`、终点 `2`
|
- 内容优先级未配置时使用普通点 `1`、终点 `2`
|
||||||
|
|
||||||
@@ -1057,6 +1222,56 @@
|
|||||||
|
|
||||||
**大部分配置项都不是强制必填,先保证主骨架完整即可。**
|
**大部分配置项都不是强制必填,先保证主骨架完整即可。**
|
||||||
|
|
||||||
|
### 22.1 顺序赛最小模板默认流程
|
||||||
|
|
||||||
|
如果只提供顺序赛最小模板,系统默认按以下流程处理:
|
||||||
|
|
||||||
|
- 进入游戏后只显示开始点,提示玩家先打开始点
|
||||||
|
- 成功打开始点后,显示全部普通控制点、终点和腿线,并正式开始计时
|
||||||
|
- 开始点和结束点默认不弹题,只弹提示信息
|
||||||
|
- 普通控制点默认允许跳点
|
||||||
|
- 默认跳点半径为打点半径的 `2` 倍
|
||||||
|
- 普通控制点成功打点后立即获得 `1` 分基础分
|
||||||
|
- 最小模板下默认不弹题
|
||||||
|
- 如需答题,需显式为对应点位配置 `quiz` CTA
|
||||||
|
- 跳过点不弹题、不得分
|
||||||
|
- 成功打结束点后停止计时,弹出结束提示,随后进入默认结算页
|
||||||
|
|
||||||
|
### 22.2 顺序赛最小模板默认表现
|
||||||
|
|
||||||
|
- 起跑前只显示开始点
|
||||||
|
- 打完开始点后显示完整路线
|
||||||
|
- 默认路线主色参考传统定向运动紫红色
|
||||||
|
- 默认腿线带电流动效
|
||||||
|
- 开始点、结束点、当前目标点都应有动效强调
|
||||||
|
- 已完成点默认变灰
|
||||||
|
- 已跳过点默认使用另一套区分色
|
||||||
|
|
||||||
|
### 22.3 积分赛最小模板默认流程
|
||||||
|
|
||||||
|
如果只提供积分赛最小模板,系统默认按以下流程处理:
|
||||||
|
|
||||||
|
- 进入游戏后只显示开始点,提示玩家先打开始点
|
||||||
|
- 成功打开始点后,显示全部积分点和结束点,并正式开始计时
|
||||||
|
- 开始点和结束点默认不弹题,只弹提示信息
|
||||||
|
- 玩家默认不需要先点击积分点
|
||||||
|
- 底部 HUD 信息面板默认显示当前目标摘要、目标距离和总分 / 收集进度摘要
|
||||||
|
- 任意未收集积分点进入范围时都可生效
|
||||||
|
- 成功打点后默认立即获得该点基础分
|
||||||
|
- 最小模板下默认不弹题
|
||||||
|
- 如需答题,需显式为对应点位配置 `quiz` CTA
|
||||||
|
- 默认至少完成 `1` 个普通积分点后,结束点才解锁,且不需要先设为目标点
|
||||||
|
- 成功打结束点后停止计时,弹出结束提示,随后进入默认结算页
|
||||||
|
|
||||||
|
### 22.4 积分赛最小模板默认表现
|
||||||
|
|
||||||
|
- 起跑前只显示开始点
|
||||||
|
- 打完开始点后显示全部积分点和结束点
|
||||||
|
- 当前目标点默认要有更强高亮和动效
|
||||||
|
- 默认所有积分点显示分值标签
|
||||||
|
- 已收集点默认变灰
|
||||||
|
- 默认不显示腿线
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 23. 维护约定
|
## 23. 维护约定
|
||||||
@@ -1072,4 +1287,3 @@
|
|||||||
- 服务端可对照
|
- 服务端可对照
|
||||||
- 后台可录入
|
- 后台可录入
|
||||||
- 客户端联调时有统一参考
|
- 客户端联调时有统一参考
|
||||||
|
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
# 顺序赛最小配置模板
|
|
||||||
|
|
||||||
本文档提供一份 **顺序赛(`classic-sequential`)最小可跑配置模板**。
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
- 只保留顺序赛跑通所需的最少字段
|
|
||||||
- 适合快速起活动、联调、排查配置链
|
|
||||||
- 每个字段都带简要说明
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 最小模板
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schemaVersion": "1",
|
|
||||||
"version": "2026.03.30",
|
|
||||||
"app": {
|
|
||||||
"id": "sample-classic-minimal-001",
|
|
||||||
"title": "顺序赛最小示例"
|
|
||||||
},
|
|
||||||
"map": {
|
|
||||||
"tiles": "../map/lxcb-001/tiles/",
|
|
||||||
"mapmeta": "../map/lxcb-001/tiles/meta.json"
|
|
||||||
},
|
|
||||||
"playfield": {
|
|
||||||
"kind": "course",
|
|
||||||
"source": {
|
|
||||||
"type": "kml",
|
|
||||||
"url": "../kml/lxcb-001/10/c01.kml"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"game": {
|
|
||||||
"mode": "classic-sequential",
|
|
||||||
"punch": {
|
|
||||||
"policy": "enter-confirm",
|
|
||||||
"radiusMeters": 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 字段说明
|
|
||||||
|
|
||||||
### `schemaVersion`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:配置结构版本
|
|
||||||
- 当前建议值:`"1"`
|
|
||||||
|
|
||||||
### `version`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:配置版本号
|
|
||||||
|
|
||||||
### `app.id`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:活动配置实例 ID
|
|
||||||
|
|
||||||
### `app.title`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:活动标题 / 比赛名称
|
|
||||||
|
|
||||||
### `map.tiles`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:地图瓦片根路径
|
|
||||||
|
|
||||||
### `map.mapmeta`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:地图 meta 文件路径
|
|
||||||
|
|
||||||
### `playfield.kind`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:空间对象类型
|
|
||||||
- 顺序赛固定使用:`course`
|
|
||||||
|
|
||||||
### `playfield.source.type`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:空间底稿来源类型
|
|
||||||
- 当前推荐值:`kml`
|
|
||||||
|
|
||||||
### `playfield.source.url`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:KML 文件路径
|
|
||||||
|
|
||||||
### `game.mode`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:玩法模式
|
|
||||||
- 顺序赛固定值:`classic-sequential`
|
|
||||||
|
|
||||||
### `game.punch.policy`
|
|
||||||
|
|
||||||
- 类型:`string`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:打点策略
|
|
||||||
- 当前常用值:
|
|
||||||
- `enter-confirm`:进入范围后用户再点击确认打点
|
|
||||||
- `enter`:进入范围自动打点
|
|
||||||
|
|
||||||
### `game.punch.radiusMeters`
|
|
||||||
|
|
||||||
- 类型:`number`
|
|
||||||
- 必填:是
|
|
||||||
- 说明:打点判定半径,单位米
|
|
||||||
- 建议默认值:`5`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 当前默认逻辑
|
|
||||||
|
|
||||||
如果你不写下面这些字段,顺序赛会按当前客户端默认逻辑运行:
|
|
||||||
|
|
||||||
- `map.declination`
|
|
||||||
- 默认按 `0` 处理
|
|
||||||
- `map.initialView.zoom`
|
|
||||||
- 默认由客户端初始视口逻辑接管
|
|
||||||
- `playfield.CPRadius`
|
|
||||||
- 默认按客户端内置值处理
|
|
||||||
- `game.session.*`
|
|
||||||
- 默认手动开始、要求起点打卡、终点打卡
|
|
||||||
- `game.sequence.skip.*`
|
|
||||||
- 默认不启用跳点
|
|
||||||
- `game.guidance.*`
|
|
||||||
- 默认使用当前引导逻辑
|
|
||||||
- `resources.*`
|
|
||||||
- 默认 profile
|
|
||||||
- `debug.*`
|
|
||||||
- 默认关闭
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 适用场景
|
|
||||||
|
|
||||||
这份模板适合:
|
|
||||||
|
|
||||||
- 快速验证顺序赛主流程
|
|
||||||
- 联调地图和 KML
|
|
||||||
- 后台先跑通最小顺序赛配置
|
|
||||||
|
|
||||||
如果要看更完整版本,请继续参考:
|
|
||||||
|
|
||||||
- [D:\dev\cmr-mini\doc\config-template-full-current.md](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
|
||||||
- [D:\dev\cmr-mini\event\classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
|
|
||||||
|
|
||||||
239
doc/gameplay/故障恢复机制.md
Normal file
239
doc/gameplay/故障恢复机制.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# 故障恢复机制
|
||||||
|
|
||||||
|
本文档用于说明当前客户端在“游戏进行中非正常退出”场景下的恢复机制。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 明确当前恢复能力边界
|
||||||
|
- 说明恢复快照保存什么、不保存什么
|
||||||
|
- 说明恢复流程、清理流程和测试方法
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 设计原则
|
||||||
|
|
||||||
|
当前恢复机制采用:
|
||||||
|
|
||||||
|
`轻量快照恢复,而不是页面状态回放`
|
||||||
|
|
||||||
|
原则如下:
|
||||||
|
|
||||||
|
- 只恢复核心运行态
|
||||||
|
- 不恢复瞬时 UI
|
||||||
|
- 恢复后由规则层和展示层重新计算派生状态
|
||||||
|
- 恢复失败时允许直接降级回正常开局
|
||||||
|
- 不为了恢复体验牺牲主流程性能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 当前恢复范围
|
||||||
|
|
||||||
|
### 2.1 会恢复的内容
|
||||||
|
|
||||||
|
- 当前配置入口
|
||||||
|
- 当前对局核心状态
|
||||||
|
- 已完成点 / 已跳过点
|
||||||
|
- 当前分数
|
||||||
|
- 开赛时间 / 结束时间 / 结束原因
|
||||||
|
- 模式状态 `modeState`
|
||||||
|
- 遥测累计值
|
||||||
|
- 最后 GPS 点与精度
|
||||||
|
- 地图基础视口
|
||||||
|
- GPS 锁定状态
|
||||||
|
|
||||||
|
### 2.2 不恢复的内容
|
||||||
|
|
||||||
|
- 白色内容卡
|
||||||
|
- 答题卡中间态
|
||||||
|
- 黑底引导提示条
|
||||||
|
- 彩色短反馈条
|
||||||
|
- 待查看内容入口
|
||||||
|
- 临时动画和过渡效果
|
||||||
|
- 各类计时器、定时器、提示队列
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
这些内容都属于派生 UI 或瞬时体验,恢复后会重新按当前运行态计算,不直接持久化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 快照结构
|
||||||
|
|
||||||
|
当前恢复快照定义在:
|
||||||
|
|
||||||
|
- [sessionRecovery.ts](D:/dev/cmr-mini/miniprogram/game/core/sessionRecovery.ts)
|
||||||
|
|
||||||
|
结构分为 4 块:
|
||||||
|
|
||||||
|
1. 快照元信息
|
||||||
|
- `schemaVersion`
|
||||||
|
- `savedAt`
|
||||||
|
|
||||||
|
2. 配置身份
|
||||||
|
- `launchEnvelope`
|
||||||
|
- `configAppId`
|
||||||
|
- `configVersion`
|
||||||
|
|
||||||
|
3. 对局核心状态
|
||||||
|
- `gameState`
|
||||||
|
|
||||||
|
4. 运行态附属状态
|
||||||
|
- `telemetry`
|
||||||
|
- `viewport`
|
||||||
|
- `currentGpsPoint`
|
||||||
|
- `currentGpsAccuracyMeters`
|
||||||
|
- `currentGpsInsideMap`
|
||||||
|
- `bonusScore`
|
||||||
|
- `quizCorrectCount`
|
||||||
|
- `quizWrongCount`
|
||||||
|
- `quizTimeoutCount`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 保存时机
|
||||||
|
|
||||||
|
当前 V1 保存时机如下:
|
||||||
|
|
||||||
|
- 对局进入 `running` 时先保存一次
|
||||||
|
- 对局进行中按低频节流定时保存
|
||||||
|
- 页面 `onHide` 时保存一次
|
||||||
|
- 页面 `onUnload` 时保存一次
|
||||||
|
|
||||||
|
当前默认节流周期:
|
||||||
|
|
||||||
|
- `5` 秒
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 不做每帧保存
|
||||||
|
- 不在所有 GPS 更新时保存
|
||||||
|
- 只做低频检查点式持久化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 恢复流程
|
||||||
|
|
||||||
|
当前恢复流程如下:
|
||||||
|
|
||||||
|
1. 页面进入时先解析启动入口
|
||||||
|
2. 如果没有显式新启动参数,则检查是否存在恢复快照
|
||||||
|
3. 若存在恢复快照,则优先使用快照中的 `launchEnvelope`
|
||||||
|
4. 配置加载完成后校验快照身份
|
||||||
|
5. 若快照属于当前配置,则弹出“继续恢复 / 放弃”确认
|
||||||
|
6. 用户确认恢复后:
|
||||||
|
- 先恢复系统设置运行态
|
||||||
|
- 再恢复 `GameRuntime` 核心状态
|
||||||
|
- 再恢复 `TelemetryRuntime`
|
||||||
|
- 再恢复地图视口与 GPS 锁定态
|
||||||
|
- 最后重新计算 HUD、按钮和提示
|
||||||
|
|
||||||
|
恢复相关落点:
|
||||||
|
|
||||||
|
- [map.ts](D:/dev/cmr-mini/miniprogram/pages/map/map.ts)
|
||||||
|
- [mapEngine.ts](D:/dev/cmr-mini/miniprogram/engine/map/mapEngine.ts)
|
||||||
|
- [gameRuntime.ts](D:/dev/cmr-mini/miniprogram/game/core/gameRuntime.ts)
|
||||||
|
- [telemetryRuntime.ts](D:/dev/cmr-mini/miniprogram/game/telemetry/telemetryRuntime.ts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 清理规则
|
||||||
|
|
||||||
|
以下情况会清除恢复快照:
|
||||||
|
|
||||||
|
- 正常完赛
|
||||||
|
- 超时结束
|
||||||
|
- 主动退出
|
||||||
|
- 用户在恢复确认框中选择“放弃”
|
||||||
|
- 快照配置身份与当前配置不匹配
|
||||||
|
- 恢复失败
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
恢复快照只服务“未正常结束的进行中对局”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 当前实现边界
|
||||||
|
|
||||||
|
当前 V1 的明确边界是:
|
||||||
|
|
||||||
|
- 支持续局
|
||||||
|
- 不支持恢复答题进行中
|
||||||
|
- 不支持恢复内容卡浏览进行中
|
||||||
|
- 不支持恢复弹层堆栈
|
||||||
|
- 不支持恢复临时 FX 状态
|
||||||
|
|
||||||
|
这不是缺陷,而是当前版本的刻意收敛。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 性能策略
|
||||||
|
|
||||||
|
当前恢复机制的性能策略如下:
|
||||||
|
|
||||||
|
- 快照只存小而必要的结构
|
||||||
|
- 不重复存整份远端配置 JSON
|
||||||
|
- 不存派生展示状态
|
||||||
|
- 不做高频写入
|
||||||
|
- 恢复后尽量走现有规则重算链
|
||||||
|
|
||||||
|
所以这套恢复机制更接近:
|
||||||
|
|
||||||
|
`保存运行核心状态 + 重新生成界面`
|
||||||
|
|
||||||
|
而不是:
|
||||||
|
|
||||||
|
`保存整个页面现场`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 测试建议
|
||||||
|
|
||||||
|
### 9.1 基础恢复
|
||||||
|
|
||||||
|
1. 开一局并至少完成 1 个点
|
||||||
|
2. 不正常结束,直接退后台并杀掉程序
|
||||||
|
3. 再次进入地图页
|
||||||
|
4. 确认出现恢复提示
|
||||||
|
5. 点击“继续恢复”
|
||||||
|
6. 确认分数、已完成点、计时和地图状态都能续上
|
||||||
|
|
||||||
|
### 9.2 放弃恢复
|
||||||
|
|
||||||
|
1. 重复上面步骤进入恢复提示
|
||||||
|
2. 点击“放弃”
|
||||||
|
3. 确认回到正常初始状态
|
||||||
|
4. 再次进入时不应重复提示
|
||||||
|
|
||||||
|
### 9.3 正常结束清理
|
||||||
|
|
||||||
|
分别验证:
|
||||||
|
|
||||||
|
- 正常打终点结束
|
||||||
|
- 主动退出
|
||||||
|
- 超时结束
|
||||||
|
|
||||||
|
结果都应为:
|
||||||
|
|
||||||
|
- 不再保留恢复快照
|
||||||
|
- 再次进入不再提示恢复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 后续演进方向
|
||||||
|
|
||||||
|
后续如果要继续增强,建议顺序如下:
|
||||||
|
|
||||||
|
1. 先补恢复链的 smoke tests
|
||||||
|
2. 再考虑恢复更多遥测累计细节
|
||||||
|
3. 最后才考虑是否恢复答题态或内容态
|
||||||
|
|
||||||
|
不建议一开始就追求全量 UI 恢复。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 一句话结论
|
||||||
|
|
||||||
|
当前故障恢复机制的定位是:
|
||||||
|
|
||||||
|
**保证玩家在异常退出后可以继续当前对局,但不承担恢复所有临时界面状态。**
|
||||||
380
doc/gameplay/游戏规则架构.md
Normal file
380
doc/gameplay/游戏规则架构.md
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
# 游戏规则架构
|
||||||
|
|
||||||
|
本文档用于说明当前项目中“游戏规则”在文档、配置文件、样例 JSON、解析代码和运行时规则引擎之间的实际组织方式。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 说明规则设计从哪里开始
|
||||||
|
- 说明公共配置和玩法配置如何分层
|
||||||
|
- 说明样例配置、代码解析、运行时规则如何对应
|
||||||
|
- 作为后续后台配置管理的架构基线
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总体原则
|
||||||
|
|
||||||
|
当前项目采用的是“文档定义规则,配置承载规则,代码解析配置,规则引擎执行配置”的结构。
|
||||||
|
|
||||||
|
可以概括为四句话:
|
||||||
|
|
||||||
|
1. 玩法规则先在文档里定义
|
||||||
|
2. 规则通过 JSON 配置文件表达
|
||||||
|
3. 客户端解析配置生成运行态定义
|
||||||
|
4. 规则引擎按运行态定义驱动游戏流程
|
||||||
|
|
||||||
|
也就是说:
|
||||||
|
|
||||||
|
**文档是设计源头,配置是规则载体,代码是执行层。**
|
||||||
|
|
||||||
|
如果换一种更偏运行时的说法:
|
||||||
|
|
||||||
|
- `MapEngine` 和页面壳子是骨架
|
||||||
|
- 默认值层和规则层决定骨架怎么动
|
||||||
|
- 配置像神经系统,把活动差异传进运行时
|
||||||
|
- 遥测和反馈层负责把玩家状态再回流到界面
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 当前分层结构
|
||||||
|
|
||||||
|
当前规则体系分成 6 层。
|
||||||
|
|
||||||
|
### 2.1 公共规则层
|
||||||
|
|
||||||
|
位置:
|
||||||
|
|
||||||
|
- [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md)
|
||||||
|
- [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
|
- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 定义系统共用能力有哪些
|
||||||
|
- 定义公共字段、可选项和默认值
|
||||||
|
- 作为所有玩法的公共配置全集
|
||||||
|
|
||||||
|
这一层回答的是:
|
||||||
|
|
||||||
|
- 系统支持哪些规则块
|
||||||
|
- 系统有哪些字段
|
||||||
|
- 这些字段默认怎么工作
|
||||||
|
|
||||||
|
### 2.2 玩法设计层
|
||||||
|
|
||||||
|
位置:
|
||||||
|
|
||||||
|
- [程序默认规则基线](D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md)
|
||||||
|
- [运行时编译层总表](D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md)
|
||||||
|
- [玩法设计文档模板](D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md)
|
||||||
|
- [玩法构想方案](D:/dev/cmr-mini/doc/gameplay/玩法构想方案.md)
|
||||||
|
- `doc/games/<游戏名称>/规则说明文档.md`
|
||||||
|
- `doc/games/<游戏名称>/游戏说明文档.md`
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 定义客户端自身应该内建的默认行为
|
||||||
|
- 定义某个玩法怎么玩
|
||||||
|
- 定义局流程、状态机、计分、胜负、表现
|
||||||
|
- 明确该玩法选用了哪些公共规则块
|
||||||
|
|
||||||
|
这一层回答的是:
|
||||||
|
|
||||||
|
- 这个玩法的目标是什么
|
||||||
|
- 这个玩法如何开始、推进、结束
|
||||||
|
- 这个玩法和系统公共能力的关系是什么
|
||||||
|
|
||||||
|
### 2.3 玩法配置层
|
||||||
|
|
||||||
|
位置:
|
||||||
|
|
||||||
|
- `doc/games/<游戏名称>/最小配置模板.md`
|
||||||
|
- `doc/games/<游戏名称>/最大配置模板.md`
|
||||||
|
- `doc/games/<游戏名称>/全局配置项.md`
|
||||||
|
- `doc/games/<游戏名称>/游戏配置项.md`
|
||||||
|
- `event/*.json`
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 把玩法规则翻译为可执行配置
|
||||||
|
- 明确这个玩法实际使用的公共字段子集
|
||||||
|
- 给出最小可跑样例和较完整样例
|
||||||
|
|
||||||
|
这一层回答的是:
|
||||||
|
|
||||||
|
- 规则具体落到哪些字段
|
||||||
|
- 哪些字段是系统默认
|
||||||
|
- 哪些字段由玩法覆盖
|
||||||
|
- 当前联调跑的是哪份样例配置
|
||||||
|
|
||||||
|
### 2.4 配置解析层
|
||||||
|
|
||||||
|
位置:
|
||||||
|
|
||||||
|
- [remoteMapConfig.ts](D:/dev/cmr-mini/miniprogram/utils/remoteMapConfig.ts)
|
||||||
|
- [courseToGameDefinition.ts](D:/dev/cmr-mini/miniprogram/game/content/courseToGameDefinition.ts)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 读取远端或本地 JSON
|
||||||
|
- 补齐模式默认值
|
||||||
|
- 归一化为运行时使用的定义对象
|
||||||
|
|
||||||
|
这一层回答的是:
|
||||||
|
|
||||||
|
- 配置进入程序后如何被解释
|
||||||
|
- 系统默认值何时注入
|
||||||
|
- 不同玩法模式如何做差异化默认处理
|
||||||
|
|
||||||
|
### 2.5 运行时默认与设置层
|
||||||
|
|
||||||
|
位置:
|
||||||
|
|
||||||
|
- [gameModeDefaults.ts](D:/dev/cmr-mini/miniprogram/game/core/gameModeDefaults.ts)
|
||||||
|
- [systemSettingsState.ts](D:/dev/cmr-mini/miniprogram/game/core/systemSettingsState.ts)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 统一维护玩法默认值
|
||||||
|
- 统一维护设置页默认值
|
||||||
|
- 明确哪些值持久化,哪些锁态不持久化
|
||||||
|
- 为页面层和规则层提供同一套默认基线
|
||||||
|
|
||||||
|
补充约束:
|
||||||
|
|
||||||
|
- 设置值允许玩家修改并持久化
|
||||||
|
- 设置锁态只由系统默认值和活动配置决定
|
||||||
|
- 锁态不持久化,也不允许玩家在页面里切换
|
||||||
|
- 锁态只在当前游戏配置运行生命周期内生效;本局结束或主动退出后失效
|
||||||
|
|
||||||
|
### 2.6 运行时编译层
|
||||||
|
|
||||||
|
位置:
|
||||||
|
|
||||||
|
- [runtimeProfileCompiler.ts](D:/dev/cmr-mini/miniprogram/game/core/runtimeProfileCompiler.ts)
|
||||||
|
- [运行时编译层总表](D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md)
|
||||||
|
- [故障恢复机制](D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 统一合并系统默认值、玩法默认值、活动配置和玩家设置
|
||||||
|
- 产出页面、引擎和规则层可直接消费的运行时 profile
|
||||||
|
- 逐步替代页面和引擎中分散的默认值判断
|
||||||
|
|
||||||
|
当前进度:
|
||||||
|
|
||||||
|
- `settings / telemetry / feedback` 已开始走编译层入口
|
||||||
|
- 设置页大部分引擎型设置项已改成统一更新玩家设置,再由编译层回灌引擎
|
||||||
|
- `settings` 当前已通过 `MapEngine.applyCompiledSettingsProfile(...)` 统一落地,不再由页面层散着逐项推送
|
||||||
|
- `presentation` 和一部分 `game` 字段也已开始接入
|
||||||
|
- `map` 也已开始接入地图底座和配置状态这组基础字段
|
||||||
|
- `MapEngine.applyRemoteMapConfig(...)` 已开始退回为原始场地与资源入口
|
||||||
|
|
||||||
|
这一层回答的是:
|
||||||
|
|
||||||
|
- 程序默认值放在哪里
|
||||||
|
- 玩家本地设置如何和系统默认值合并
|
||||||
|
- 锁态为什么不能从历史缓存恢复
|
||||||
|
- 为什么页面、引擎、规则不应再直接各自读原始配置
|
||||||
|
|
||||||
|
### 2.7 故障恢复层
|
||||||
|
|
||||||
|
位置:
|
||||||
|
|
||||||
|
- [sessionRecovery.ts](D:/dev/cmr-mini/miniprogram/game/core/sessionRecovery.ts)
|
||||||
|
- [map.ts](D:/dev/cmr-mini/miniprogram/pages/map/map.ts)
|
||||||
|
- [mapEngine.ts](D:/dev/cmr-mini/miniprogram/engine/map/mapEngine.ts)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 在对局进行中低频持久化轻量恢复快照
|
||||||
|
- 在异常退出后提供“继续上一局 / 放弃上一局”入口
|
||||||
|
- 只恢复核心运行态,不恢复瞬时 UI
|
||||||
|
|
||||||
|
这一层回答的是:
|
||||||
|
|
||||||
|
- 非正常退出后如何继续比赛
|
||||||
|
- 为什么恢复只保留核心赛局状态
|
||||||
|
- 为什么不尝试恢复白卡、答题卡和动效中间态
|
||||||
|
|
||||||
|
### 2.8 运行时规则层
|
||||||
|
|
||||||
|
位置:
|
||||||
|
|
||||||
|
- [classicSequentialRule.ts](D:/dev/cmr-mini/miniprogram/game/rules/classicSequentialRule.ts)
|
||||||
|
- [scoreORule.ts](D:/dev/cmr-mini/miniprogram/game/rules/scoreORule.ts)
|
||||||
|
- [mapEngine.ts](D:/dev/cmr-mini/miniprogram/engine/map/mapEngine.ts)
|
||||||
|
- [map.ts](D:/dev/cmr-mini/miniprogram/pages/map/map.ts)
|
||||||
|
- [resultSummary.ts](D:/dev/cmr-mini/miniprogram/game/result/resultSummary.ts)
|
||||||
|
- [telemetryRuntime.ts](D:/dev/cmr-mini/miniprogram/game/telemetry/telemetryRuntime.ts)
|
||||||
|
- [playerTelemetryProfile.ts](D:/dev/cmr-mini/miniprogram/game/telemetry/playerTelemetryProfile.ts)
|
||||||
|
|
||||||
|
作用:
|
||||||
|
|
||||||
|
- 根据运行态定义执行状态流转
|
||||||
|
- 响应 GPS、点击、打点、答题、结束等事件
|
||||||
|
- 产出 HUD、反馈、结果页等最终体验
|
||||||
|
- 明确区分可打目标、引导目标、HUD 目标和展示高亮目标
|
||||||
|
|
||||||
|
这一层回答的是:
|
||||||
|
|
||||||
|
- 玩家在游戏中的每一步如何被判定
|
||||||
|
- 积分、答题、结束如何结算
|
||||||
|
- 页面上显示什么、什么时候显示
|
||||||
|
- 活动遥测默认值和玩家身体数据如何合并
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 规则从设计到运行的链路
|
||||||
|
|
||||||
|
当前实际链路如下:
|
||||||
|
|
||||||
|
1. 在玩法文档里定义默认规则
|
||||||
|
2. 在公共配置文档里定义字段与默认值
|
||||||
|
3. 在玩法目录里确定该玩法使用哪些字段
|
||||||
|
4. 在 `event/*.json` 中写出样例配置
|
||||||
|
5. 由配置解析层读取 JSON 并注入模式默认值
|
||||||
|
6. 由默认值与设置层补齐系统默认行为和玩家本地值
|
||||||
|
7. 由规则引擎和 `MapEngine` 执行游戏逻辑
|
||||||
|
8. 由结果页和 HUD 层展示最终状态
|
||||||
|
|
||||||
|
其中遥测相关链路补充为:
|
||||||
|
|
||||||
|
`系统默认值 -> 活动 telemetry 配置 -> 玩家线上身体数据 -> TelemetryRuntime -> HUD 第 2 页`
|
||||||
|
|
||||||
|
设置相关链路补充为:
|
||||||
|
|
||||||
|
`系统设置默认值 -> 玩家本地持久化值 -> 当前运行时锁态 -> map.ts / MapEngine`
|
||||||
|
|
||||||
|
也可以简化成:
|
||||||
|
|
||||||
|
`规则文档 -> 配置文档 -> 样例 JSON -> 配置解析 -> 规则引擎 -> 运行结果`
|
||||||
|
|
||||||
|
当前已补一层轻量 smoke tests,用于卡住高风险回归:
|
||||||
|
|
||||||
|
- 配置继承
|
||||||
|
- 起点 / 终点规则
|
||||||
|
- 积分赛自由打点
|
||||||
|
- 锁态生命周期
|
||||||
|
- 超时结束
|
||||||
|
|
||||||
|
命令:
|
||||||
|
|
||||||
|
- `npm run test:runtime-smoke`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 当前目录分工
|
||||||
|
|
||||||
|
### 4.1 `doc/config`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 公共配置全集
|
||||||
|
- 公共字段字典
|
||||||
|
- 公共模板
|
||||||
|
- 发布和后台管理方案
|
||||||
|
|
||||||
|
这一层不应该存放某个玩法自己的专属规则文档。
|
||||||
|
|
||||||
|
### 4.2 `doc/gameplay`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 通用玩法方法论
|
||||||
|
- 玩法设计模板
|
||||||
|
- 玩法构想和规则架构说明
|
||||||
|
|
||||||
|
这一层不应该长期存放具体玩法的正式规则文档。
|
||||||
|
|
||||||
|
### 4.3 `doc/games/<游戏名称>`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 某个具体玩法的完整文档集合
|
||||||
|
- 玩法说明
|
||||||
|
- 规则说明
|
||||||
|
- 最小 / 最大模板
|
||||||
|
- 全局配置项子集
|
||||||
|
- 游戏配置项子集
|
||||||
|
|
||||||
|
这一层是具体玩法的唯一正式维护入口。
|
||||||
|
|
||||||
|
### 4.4 `event/*.json`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 当前可运行样例配置
|
||||||
|
- 联调、验证、远端发布的直接输入
|
||||||
|
|
||||||
|
这一层必须和玩法目录中的文档保持一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 当前已落地的玩法实例
|
||||||
|
|
||||||
|
### 5.1 顺序打点
|
||||||
|
|
||||||
|
正式文档入口:
|
||||||
|
|
||||||
|
- [顺序打点/游戏说明文档](D:/dev/cmr-mini/doc/games/顺序打点/游戏说明文档.md)
|
||||||
|
- [顺序打点/规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md)
|
||||||
|
- [顺序打点/最小配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最小配置模板.md)
|
||||||
|
- [顺序打点/最大配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最大配置模板.md)
|
||||||
|
- [顺序打点/全局配置项](D:/dev/cmr-mini/doc/games/顺序打点/全局配置项.md)
|
||||||
|
- [顺序打点/游戏配置项](D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md)
|
||||||
|
- [classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
|
||||||
|
|
||||||
|
### 5.2 积分赛
|
||||||
|
|
||||||
|
正式文档入口:
|
||||||
|
|
||||||
|
- [积分赛/游戏说明文档](D:/dev/cmr-mini/doc/games/积分赛/游戏说明文档.md)
|
||||||
|
- [积分赛/规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md)
|
||||||
|
- [积分赛/最小配置模板](D:/dev/cmr-mini/doc/games/积分赛/最小配置模板.md)
|
||||||
|
- [积分赛/最大配置模板](D:/dev/cmr-mini/doc/games/积分赛/最大配置模板.md)
|
||||||
|
- [积分赛/全局配置项](D:/dev/cmr-mini/doc/games/积分赛/全局配置项.md)
|
||||||
|
- [积分赛/游戏配置项](D:/dev/cmr-mini/doc/games/积分赛/游戏配置项.md)
|
||||||
|
- [score-o.json](D:/dev/cmr-mini/event/score-o.json)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 文档与代码同步约定
|
||||||
|
|
||||||
|
后续每次规则变化,建议至少检查 4 层是否同步:
|
||||||
|
|
||||||
|
1. 公共配置文档是否要补字段或默认值
|
||||||
|
2. 对应玩法目录下的规则文档是否要改
|
||||||
|
3. 对应 `event/*.json` 样例是否要改
|
||||||
|
4. 配置解析和规则引擎是否要改
|
||||||
|
|
||||||
|
如果只改了其中一层,就容易出现文档、配置、代码不一致。
|
||||||
|
|
||||||
|
建议统一按下面顺序维护:
|
||||||
|
|
||||||
|
1. 先改规则文档
|
||||||
|
2. 再改配置文档和样例 JSON
|
||||||
|
3. 再改解析代码和规则代码
|
||||||
|
4. 最后回头核对结果页、HUD 和提示是否与文档一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 和后续后台管理的关系
|
||||||
|
|
||||||
|
当前本地项目里,规则主要由文档和样例 JSON 驱动。
|
||||||
|
|
||||||
|
后续接后台后,分工仍然建议保持不变:
|
||||||
|
|
||||||
|
- 玩法文档负责定义规则
|
||||||
|
- 公共配置文档负责定义字段
|
||||||
|
- 后台负责编辑、版本、校验、装配、发布
|
||||||
|
- 客户端继续消费静态 JSON
|
||||||
|
|
||||||
|
也就是说,后台是管理工具,不是规则源头。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 一句话总结
|
||||||
|
|
||||||
|
当前这套实际架构可以概括为:
|
||||||
|
|
||||||
|
**`doc/config` 管公共规则全集,`doc/games/<游戏名称>` 管玩法规则与配置子集,`event/*.json` 管可运行样例,客户端解析配置后交给规则引擎执行,并由轻量恢复层处理异常退出后的续局。**
|
||||||
411
doc/gameplay/玩法设计文档模板.md
Normal file
411
doc/gameplay/玩法设计文档模板.md
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
# 玩法设计文档模板
|
||||||
|
|
||||||
|
本文档用于定义后续所有玩法设计文档的**统一写法**,保证玩法规则、全局规则块、配置落点和最小样例能够一起沉淀,为后续 JSON 配置管理和后台装配提供稳定输入。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 统一玩法设计文档结构
|
||||||
|
- 避免只写“玩法创意”,不写“配置落点”
|
||||||
|
- 让后续玩法都能自然接到配置文件和后台管理方案
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 本文档是模板,不是具体玩法规则
|
||||||
|
- 后期 JSON 配置管理以专门后台方案承接
|
||||||
|
- 本文档负责沉淀“设计输入”
|
||||||
|
- 后台方案负责沉淀“编辑、版本、发布、装配”
|
||||||
|
|
||||||
|
建议配合阅读:
|
||||||
|
|
||||||
|
- [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md)
|
||||||
|
- [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
|
- [后台配置管理方案V2](D:/dev/cmr-mini/doc/config/后台配置管理方案V2.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 使用方式
|
||||||
|
|
||||||
|
后续每新增一个玩法,都建议新建一份玩法文档,并按本模板完整填写。
|
||||||
|
|
||||||
|
推荐存放方式:
|
||||||
|
|
||||||
|
- `doc/games/<游戏名称>/游戏说明文档.md`
|
||||||
|
- `doc/games/<游戏名称>/规则说明文档.md`
|
||||||
|
- `doc/games/<游戏名称>/最小配置模板.md`
|
||||||
|
- `doc/games/<游戏名称>/最大配置模板.md`
|
||||||
|
- `doc/games/<游戏名称>/全局配置项.md`
|
||||||
|
- `doc/games/<游戏名称>/游戏配置项.md`
|
||||||
|
|
||||||
|
如果玩法还处于发散阶段,可以先写“构想方案”;
|
||||||
|
一旦进入可开发阶段,就应该补齐本模板里的正式章节。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 标准章节清单
|
||||||
|
|
||||||
|
后续每个玩法设计文档,建议至少包含以下章节:
|
||||||
|
|
||||||
|
1. 文档定位
|
||||||
|
2. 一句话规则
|
||||||
|
3. 设计目标
|
||||||
|
4. 适用范围
|
||||||
|
5. 局流程
|
||||||
|
6. 核心对象模型
|
||||||
|
7. 判定与状态机
|
||||||
|
8. 计分与胜负规则
|
||||||
|
9. 全局规则块选型
|
||||||
|
10. 配置落点
|
||||||
|
11. 最小可跑配置样例
|
||||||
|
12. 验收清单
|
||||||
|
13. 后续扩展点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 模板正文
|
||||||
|
|
||||||
|
以下为推荐模板正文,可直接复制新建玩法文档后填写。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# `玩法名称`
|
||||||
|
|
||||||
|
## 1. 文档定位
|
||||||
|
|
||||||
|
本文档用于定义 `玩法模式标识` 的正式规则、默认行为、配置落点和最小可跑样例。
|
||||||
|
|
||||||
|
当前阶段目标:
|
||||||
|
|
||||||
|
- 说明玩法怎么玩
|
||||||
|
- 说明系统如何判定
|
||||||
|
- 说明配置文件如何承载
|
||||||
|
- 说明哪些沿用系统默认,哪些需要玩法覆盖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 一句话规则
|
||||||
|
|
||||||
|
请用一句话写清:
|
||||||
|
|
||||||
|
- 玩家要做什么
|
||||||
|
- 怎样推进
|
||||||
|
- 怎样结束
|
||||||
|
- 怎样赢 / 怎样结算
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
> 玩家需要按顺序完成控制点打卡,允许跳点;普通点打卡后回答限时数学题获取奖励分,打完终点后结算成绩。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 设计目标
|
||||||
|
|
||||||
|
建议至少说明:
|
||||||
|
|
||||||
|
- 这个玩法的核心乐趣是什么
|
||||||
|
- 它和已有玩法的主要差异是什么
|
||||||
|
- 它优先验证哪种系统能力
|
||||||
|
|
||||||
|
建议回答:
|
||||||
|
|
||||||
|
- 是否偏竞技
|
||||||
|
- 是否偏探索
|
||||||
|
- 是否偏轻量复玩
|
||||||
|
- 是否偏实时压力
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 适用范围
|
||||||
|
|
||||||
|
建议至少说明:
|
||||||
|
|
||||||
|
- 适用于单人还是多人
|
||||||
|
- 是否依赖真实 GPS
|
||||||
|
- 是否依赖模拟器
|
||||||
|
- 是否依赖心率等遥测能力
|
||||||
|
- 是否依赖路线型场地还是对象集型场地
|
||||||
|
|
||||||
|
建议落成明确语句:
|
||||||
|
|
||||||
|
- 支持:`单人 / 多人`
|
||||||
|
- 输入依赖:`GPS / 心率 / 模拟输入`
|
||||||
|
- 场地类型:`course / control-set / region-set / object-set`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 局流程
|
||||||
|
|
||||||
|
建议按时间顺序写完整流程:
|
||||||
|
|
||||||
|
### 5.1 进入游戏
|
||||||
|
|
||||||
|
- 初始看到什么
|
||||||
|
- 哪些对象显示,哪些隐藏
|
||||||
|
- 是否立即开始计时
|
||||||
|
- 是否先弹提示
|
||||||
|
|
||||||
|
### 5.2 正式开始
|
||||||
|
|
||||||
|
- 什么事件触发正式开局
|
||||||
|
- 是否初始化数据
|
||||||
|
- 是否显示全图 / 全路线
|
||||||
|
- 是否弹开始提示
|
||||||
|
|
||||||
|
### 5.3 进行中推进
|
||||||
|
|
||||||
|
- 玩家如何推进
|
||||||
|
- 当前目标如何变化
|
||||||
|
- 是否允许跳点 / 切目标 / 自由选点
|
||||||
|
- 进行中有哪些关键反馈
|
||||||
|
|
||||||
|
### 5.4 结束
|
||||||
|
|
||||||
|
- 什么条件触发结束
|
||||||
|
- 是否立即停止计时
|
||||||
|
- 是否弹结束提示
|
||||||
|
- 是否进入结算页
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 核心对象模型
|
||||||
|
|
||||||
|
建议列清玩法运行依赖的对象类型。
|
||||||
|
|
||||||
|
示例格式:
|
||||||
|
|
||||||
|
| 对象类型 | 作用 | 是否必需 | 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `start` | 起始触发点 | 是 | 用于正式开赛 |
|
||||||
|
| `control` | 普通推进目标 | 是 | 可按玩法扩展属性 |
|
||||||
|
| `finish` | 结束点 | 视玩法而定 | 用于结束比赛 |
|
||||||
|
| `bonus` | 奖励对象 | 否 | 可选 |
|
||||||
|
| `danger-zone` | 危险区域 | 否 | 可选 |
|
||||||
|
|
||||||
|
还建议说明:
|
||||||
|
|
||||||
|
- 对象来源于 `KML` 还是运行时生成
|
||||||
|
- 对象是静态的还是动态变化的
|
||||||
|
- 对象是否有状态切换
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 判定与状态机
|
||||||
|
|
||||||
|
### 7.1 局状态
|
||||||
|
|
||||||
|
建议明确列出局状态,例如:
|
||||||
|
|
||||||
|
1. `ready`
|
||||||
|
2. `running`
|
||||||
|
3. `paused`
|
||||||
|
4. `finished`
|
||||||
|
5. `settled`
|
||||||
|
|
||||||
|
### 7.2 关键判定
|
||||||
|
|
||||||
|
建议逐条写清:
|
||||||
|
|
||||||
|
- 打点成功判定
|
||||||
|
- 跳点判定
|
||||||
|
- 得分判定
|
||||||
|
- 失败判定
|
||||||
|
- 完赛判定
|
||||||
|
|
||||||
|
### 7.3 状态切换条件
|
||||||
|
|
||||||
|
建议写成表:
|
||||||
|
|
||||||
|
| 当前状态 | 触发事件 | 下一状态 | 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `ready` | 起点打卡成功 | `running` | 正式开始计时 |
|
||||||
|
| `running` | 结束条件满足且终点打卡 | `finished` | 停止计时 |
|
||||||
|
| `finished` | 关闭结束提示 | `settled` | 进入结算页 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 计分与胜负规则
|
||||||
|
|
||||||
|
建议明确:
|
||||||
|
|
||||||
|
- 基础分怎么来
|
||||||
|
- 奖励分怎么来
|
||||||
|
- 扣分怎么来
|
||||||
|
- 跳过点如何处理
|
||||||
|
- 最终成绩显示哪些指标
|
||||||
|
|
||||||
|
推荐格式:
|
||||||
|
|
||||||
|
| 规则项 | 说明 | 默认值 | 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 基础分 | 成功打点获得 | `1` | 示例 |
|
||||||
|
| 奖励分 | 答题答对获得 | `1` | 示例 |
|
||||||
|
| 跳过点得分 | 是否得分 | `0` | 示例 |
|
||||||
|
| 结算指标 | 总用时 / 总分 / 成功点数 | - | 示例 |
|
||||||
|
|
||||||
|
如果玩法没有积分,也要写清:
|
||||||
|
|
||||||
|
- 胜负依据是什么
|
||||||
|
- 排名依据是什么
|
||||||
|
- 是否只有完赛状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 全局规则块选型
|
||||||
|
|
||||||
|
本节必须回答“这个玩法用了哪些全局能力块,以及默认选型是什么”。
|
||||||
|
|
||||||
|
建议按下表填写:
|
||||||
|
|
||||||
|
| 规则块 | 是否使用 | 选型 / 配置方向 | 默认值策略 | 备注 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 地图底座 | 是 | 自定义地图 + KML | 沿用系统默认 | |
|
||||||
|
| 点位表现 | 是 | 传统定向紫红系 | 可局部覆盖 | |
|
||||||
|
| 腿线表现 | 是 | `classic-leg` + 动效 | 顺序类沿用默认 | |
|
||||||
|
| 轨迹表现 | 是 | `full` / `tail` / `none` | 玩法指定 | |
|
||||||
|
| 定位点表现 | 是 | `beacon` / `dot` 等 | 沿用系统默认或玩法覆盖 | |
|
||||||
|
| 引导显示 | 是 | 是否显示腿线、是否允许选点 | 玩法指定 | |
|
||||||
|
| 可见性策略 | 是 | 开局隐藏 / 起点后全显 | 玩法指定 | |
|
||||||
|
| 内容体验 | 否 / 是 | 原生 / H5 / 不启用 | 玩法指定 | |
|
||||||
|
| 反馈系统 | 是 | 音效 / 震动 / UI 档位 | 玩法指定 | |
|
||||||
|
| 遥测参数 | 否 / 是 | 是否用心率 | 沿用默认或玩法覆盖 | |
|
||||||
|
| 调试能力 | 是 | 是否开放模拟器 | 开发期指定 | |
|
||||||
|
|
||||||
|
本节的目的不是重复字段字典,而是明确:
|
||||||
|
|
||||||
|
- 这个玩法到底用了哪些公共块
|
||||||
|
- 哪些沿用默认
|
||||||
|
- 哪些要做玩法覆盖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 配置落点
|
||||||
|
|
||||||
|
本节用于把规则翻译成配置结构。
|
||||||
|
|
||||||
|
建议写成表:
|
||||||
|
|
||||||
|
| 设计项 | 配置落点 | 是否已有字段 | 默认值 | 是否建议后台表单化 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 起点后显示全路线 | `game.visibility.revealFullPlayfieldAfterStartPunch` | 是 | `true` | 是 |
|
||||||
|
| 跳点开关 | `game.sequence.skip.enabled` | 是 | `true` | 是 |
|
||||||
|
| 答题倒计时 | `待补字段` | 否 | `10` | 先走 JSON 区 |
|
||||||
|
| 目标点样式 | `game.presentation.sequential.controls.current` | 是 | 玩法指定 | 可后续表单化 |
|
||||||
|
|
||||||
|
这里建议把字段分成两类:
|
||||||
|
|
||||||
|
### 10.1 稳定字段
|
||||||
|
|
||||||
|
适合后续做后台正式表单:
|
||||||
|
|
||||||
|
- 模式
|
||||||
|
- 半径
|
||||||
|
- 是否必须起点 / 终点
|
||||||
|
- 是否显示腿线
|
||||||
|
- 是否显示轨迹
|
||||||
|
- 是否允许跳点
|
||||||
|
- 是否启用内容体验
|
||||||
|
|
||||||
|
### 10.2 易变字段
|
||||||
|
|
||||||
|
更适合先放 JSON 编辑区:
|
||||||
|
|
||||||
|
- 实验性计分细则
|
||||||
|
- 特殊答题规则
|
||||||
|
- 视觉实验参数
|
||||||
|
- 单点特殊表现
|
||||||
|
- 复杂状态映射
|
||||||
|
|
||||||
|
本节要和后续后台管理方案衔接,避免字段落点混乱。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 最小可跑配置样例
|
||||||
|
|
||||||
|
本节建议给出一个最小可跑 JSON 样例。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 只保留当前玩法最关键字段
|
||||||
|
- 能作为联调和测试入口
|
||||||
|
- 和规则说明保持一致
|
||||||
|
|
||||||
|
建议结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": "1",
|
||||||
|
"version": "2026.03.31",
|
||||||
|
"app": {},
|
||||||
|
"map": {},
|
||||||
|
"playfield": {},
|
||||||
|
"game": {},
|
||||||
|
"resources": {},
|
||||||
|
"debug": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果已有运行中的 `event/*.json`,应在本节明确引用对应样例。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 验收清单
|
||||||
|
|
||||||
|
建议至少覆盖:
|
||||||
|
|
||||||
|
| 验收项 | 是否通过 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 开局流程与文档一致 | | |
|
||||||
|
| 打点判定与文档一致 | | |
|
||||||
|
| 计分规则与文档一致 | | |
|
||||||
|
| 点位表现与文档一致 | | |
|
||||||
|
| 轨迹与定位点表现一致 | | |
|
||||||
|
| 结算页指标与文档一致 | | |
|
||||||
|
| 样例配置可跑通 | | |
|
||||||
|
| 文档与配置字段口径一致 | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 后续扩展点
|
||||||
|
|
||||||
|
本节建议专门写:
|
||||||
|
|
||||||
|
- 哪些能力当前先不做
|
||||||
|
- 哪些字段未来可能配置化
|
||||||
|
- 哪些地方后续会交给后台表单化管理
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
- 第一阶段先固定答题倒计时为 `10` 秒,后续再配置化
|
||||||
|
- 第一阶段先固定基础分 / 奖励分,后续再补 `game.scoring` 细项
|
||||||
|
- 第一阶段样式先走 profile,后续再细化到按状态表单配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 文档产出要求
|
||||||
|
|
||||||
|
后续新增一个正式玩法文档时,建议至少同步产出以下内容:
|
||||||
|
|
||||||
|
1. 一份玩法规则文档
|
||||||
|
2. 一份对应最小样例配置
|
||||||
|
3. 如有新增公共能力,更新 [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md)
|
||||||
|
4. 如有新增字段,更新 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 和后台方案的关系
|
||||||
|
|
||||||
|
后期 JSON 配置文档建议通过专门后台管理,但要注意分工:
|
||||||
|
|
||||||
|
- 玩法文档:
|
||||||
|
负责定义规则、默认值、配置落点、最小样例
|
||||||
|
- 配置字典:
|
||||||
|
负责定义字段含义、可选项和默认值
|
||||||
|
- 后台方案:
|
||||||
|
负责对象、版本、校验、装配、发布
|
||||||
|
|
||||||
|
也就是说:
|
||||||
|
|
||||||
|
**玩法文档是“设计源头”,后台系统是“管理和发布工具”。**
|
||||||
|
|
||||||
|
不要让后台方案反过来决定玩法规则结构。
|
||||||
|
|
||||||
|
|
||||||
439
doc/gameplay/程序默认规则基线.md
Normal file
439
doc/gameplay/程序默认规则基线.md
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
# 程序默认规则基线
|
||||||
|
|
||||||
|
本文档用于定义当前客户端在**不依赖活动配置细项**时,程序层应该内建的默认规则。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 先把程序能力层的默认行为定住
|
||||||
|
- 让代码实现优先对齐这一套基线
|
||||||
|
- 等默认行为稳定后,再决定哪些能力值得开放成配置
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 本文档讲的是**程序默认规则**
|
||||||
|
- 它先于活动配置存在
|
||||||
|
- 它不讨论后台录入方式
|
||||||
|
- 它不追求一次把所有参数开放出去
|
||||||
|
- 当前这批玩法默认值已开始集中收口到 [gameModeDefaults.ts](D:/dev/cmr-mini/miniprogram/game/core/gameModeDefaults.ts)
|
||||||
|
- 设置页默认值与锁态已开始集中收口到 [systemSettingsState.ts](D:/dev/cmr-mini/miniprogram/game/core/systemSettingsState.ts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总体原则
|
||||||
|
|
||||||
|
当前规则收敛顺序固定为:
|
||||||
|
|
||||||
|
1. 先定程序默认能力
|
||||||
|
2. 再定玩法默认差异
|
||||||
|
3. 最后才开放活动配置覆盖
|
||||||
|
|
||||||
|
程序层默认规则要满足 3 个要求:
|
||||||
|
|
||||||
|
- 默认可玩:不给额外配置也能顺畅跑完一局
|
||||||
|
- 默认统一:相同类型行为在不同玩法下表达一致
|
||||||
|
- 默认克制:不把调试、测试、历史试验行为带进最小流程
|
||||||
|
|
||||||
|
补充一条设置页原则:
|
||||||
|
|
||||||
|
- 设置值和锁态分离管理,避免运行时状态被历史缓存污染
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 程序默认层次
|
||||||
|
|
||||||
|
前台默认只保留 5 类可见反馈层。
|
||||||
|
|
||||||
|
同时,程序默认值分成两条并行基线:
|
||||||
|
|
||||||
|
- 对局规则默认值
|
||||||
|
- 系统设置默认值
|
||||||
|
|
||||||
|
### 2.1 黑底引导提示条
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 只负责告诉玩家“下一步该做什么”
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 自动消失
|
||||||
|
- 可手动关闭
|
||||||
|
- 文案变化时可有轻动画
|
||||||
|
- 默认只配轻震动
|
||||||
|
- 不承担结果反馈
|
||||||
|
- 不承担内容说明
|
||||||
|
|
||||||
|
### 2.2 彩色短反馈条
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 只负责告诉玩家“刚刚发生了什么”
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 短暂出现
|
||||||
|
- 不带按钮
|
||||||
|
- 不显示长说明
|
||||||
|
- 不承担下一步引导
|
||||||
|
|
||||||
|
### 2.3 白色内容卡
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 只承载显式配置的点位内容
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 默认不进入最小流程
|
||||||
|
- 默认关闭
|
||||||
|
- 仅当某个点位明确启用时才参与流程
|
||||||
|
- 浏览型卡片默认短时自动消失
|
||||||
|
- 交互型卡片只在显式开启时出现
|
||||||
|
|
||||||
|
### 2.4 答题卡
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 承载显式启用的答题玩法
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 属于强交互层
|
||||||
|
- 最小模板下默认关闭
|
||||||
|
- 仅当点位显式配置 `quiz` CTA 时才进入
|
||||||
|
- 不依赖白色内容卡作为前置
|
||||||
|
|
||||||
|
### 2.5 成绩总览页
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 承载结算结果
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 终点完成后直接进入
|
||||||
|
- 不再额外叠终点白卡
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 打点默认规则
|
||||||
|
|
||||||
|
### 3.1 开始点
|
||||||
|
|
||||||
|
程序默认行为:
|
||||||
|
|
||||||
|
- 必须先打开始点才能正式开赛
|
||||||
|
- 成功打开始点后开始计时
|
||||||
|
- 起点完成后只给短反馈,并更新引导和 HUD
|
||||||
|
- 默认不弹白色开始卡
|
||||||
|
- 默认不弹答题卡
|
||||||
|
|
||||||
|
### 3.2 普通点
|
||||||
|
|
||||||
|
程序默认行为:
|
||||||
|
|
||||||
|
- 成功打点后先完成基础结算
|
||||||
|
- 最小模板下默认不弹答题卡
|
||||||
|
- 如需答题,必须显式配置点位 CTA
|
||||||
|
- 默认不先弹白色内容卡
|
||||||
|
- 默认不重复得分
|
||||||
|
- 默认不重复出题
|
||||||
|
|
||||||
|
### 3.3 结束点
|
||||||
|
|
||||||
|
程序默认行为:
|
||||||
|
|
||||||
|
- 成功打终点后先给完成短反馈
|
||||||
|
- 随后直接进入结果页
|
||||||
|
- 默认不弹白色终点卡
|
||||||
|
- 默认不弹答题卡
|
||||||
|
|
||||||
|
### 3.4 关门时间
|
||||||
|
|
||||||
|
程序默认行为:
|
||||||
|
|
||||||
|
- 默认关门时间为开赛后 `2` 小时
|
||||||
|
- 距离关门时间小于等于 `10` 分钟时,HUD 第 1 页时间区切换为倒计时显示
|
||||||
|
- 倒计时显示需有区别于正常计时的强调样式
|
||||||
|
- 到达关门时间后,系统自动结束当前对局
|
||||||
|
- 超时结束必须和正常完赛、主动退出区分开
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 玩法默认差异
|
||||||
|
|
||||||
|
程序只内建少量玩法差异,不把所有东西做成配置。
|
||||||
|
|
||||||
|
### 4.1 顺序打点 `classic-sequential`
|
||||||
|
|
||||||
|
默认差异:
|
||||||
|
|
||||||
|
- 按顺序推进
|
||||||
|
- 默认允许跳点
|
||||||
|
- 默认跳点半径 = 打点半径的 2 倍
|
||||||
|
- 默认跳点前弹出确认
|
||||||
|
- 当前目标点由系统自动推进
|
||||||
|
- 终点默认需要在中间点都“成功或跳过”后才生效
|
||||||
|
- 普通点基础分默认 `1`
|
||||||
|
- 普通点默认不附带答题奖励
|
||||||
|
|
||||||
|
### 4.2 积分赛 `score-o`
|
||||||
|
|
||||||
|
默认差异:
|
||||||
|
|
||||||
|
- 自由打点
|
||||||
|
- 默认不存在跳点
|
||||||
|
- 默认不要求先选中目标点
|
||||||
|
- 点击某个积分点时,默认只更新当前目标和 HUD 信息
|
||||||
|
- 未点击选中时,也允许直接进入任意积分点范围完成打点
|
||||||
|
- 默认至少完成 `1` 个普通积分点后,终点才解锁
|
||||||
|
- 普通点基础分默认取该点分值
|
||||||
|
- 普通点默认不附带答题奖励
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. HUD 默认规则
|
||||||
|
|
||||||
|
HUD 属于公共程序能力,不属于某个玩法专属实现。
|
||||||
|
|
||||||
|
### 5.1 固定结构
|
||||||
|
|
||||||
|
- HUD 固定为 2 页
|
||||||
|
- 第 1 页为比赛主信息页
|
||||||
|
- 第 2 页为心率 / 遥测页
|
||||||
|
- 异型壳子布局固定,不因玩法改变结构
|
||||||
|
|
||||||
|
### 5.2 第 1 页默认职责
|
||||||
|
|
||||||
|
- 时间
|
||||||
|
- 里程
|
||||||
|
- 动作标签
|
||||||
|
- 目标摘要
|
||||||
|
- 目标距离
|
||||||
|
- 进度摘要
|
||||||
|
- 速度
|
||||||
|
- 临近关门时间时,时间槽位切换为倒计时
|
||||||
|
|
||||||
|
### 5.3 第 2 页默认职责
|
||||||
|
|
||||||
|
- 心率
|
||||||
|
- 卡路里
|
||||||
|
- 平均速度
|
||||||
|
- 精度或相关遥测值
|
||||||
|
|
||||||
|
### 5.4 遥测身体数据来源
|
||||||
|
|
||||||
|
程序默认口径:
|
||||||
|
|
||||||
|
- HUD 第 2 页使用统一遥测运行时
|
||||||
|
- 活动配置里的 `game.telemetry.*` 只作为活动默认值
|
||||||
|
- 玩家年龄、静息心率、体重等身体数据,后续允许由线上接口覆盖
|
||||||
|
- 线上身体数据一旦到位,应高于活动配置生效
|
||||||
|
|
||||||
|
默认优先级:
|
||||||
|
|
||||||
|
`系统默认值 -> 活动遥测默认值 -> 玩家线上身体数据`
|
||||||
|
|
||||||
|
### 5.5 玩法映射默认口径
|
||||||
|
|
||||||
|
- 顺序打点:
|
||||||
|
- 目标摘要显示当前目标点
|
||||||
|
- 进度摘要显示完成进度和跳点数
|
||||||
|
- 积分赛:
|
||||||
|
- 目标摘要显示当前选中目标点
|
||||||
|
- 进度摘要显示总分和收集进度
|
||||||
|
|
||||||
|
### 5.6 目标角色默认口径
|
||||||
|
|
||||||
|
程序默认把目标拆成 4 类:
|
||||||
|
|
||||||
|
- 可打目标:当前进入范围后可真正完成打点的对象
|
||||||
|
- 引导目标:用于距离音效、接近提示和弱引导的对象
|
||||||
|
- HUD 目标:用于底部信息面板距离与摘要显示的对象
|
||||||
|
- 展示高亮目标:用于地图上重点高亮的对象
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 这 4 类目标不能再混用
|
||||||
|
- 积分赛里“选中目标”默认只影响 HUD 目标
|
||||||
|
- 距离音效默认只跟随引导目标,不跟随选中状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 距离反馈默认规则
|
||||||
|
|
||||||
|
距离反馈和黑底引导提示条分离管理。
|
||||||
|
|
||||||
|
### 6.1 黑底引导提示条
|
||||||
|
|
||||||
|
- 默认只走轻震动
|
||||||
|
- 不绑定提示音
|
||||||
|
|
||||||
|
### 6.2 距离提示
|
||||||
|
|
||||||
|
默认分为 3 档:
|
||||||
|
|
||||||
|
1. `distant`
|
||||||
|
2. `approaching`
|
||||||
|
3. `ready`
|
||||||
|
|
||||||
|
默认口径:
|
||||||
|
|
||||||
|
- `ready`:进入可打点范围
|
||||||
|
- `approaching`:接近目标
|
||||||
|
- `distant`:较远但仍处于有效提醒范围
|
||||||
|
- 更远距离默认静默
|
||||||
|
|
||||||
|
默认阈值:
|
||||||
|
|
||||||
|
- `distantDistanceMeters = 80`
|
||||||
|
- `approachDistanceMeters = 20`
|
||||||
|
- `readyDistanceMeters = 5`
|
||||||
|
|
||||||
|
默认节奏:
|
||||||
|
|
||||||
|
- `distant`:弱提醒,默认间隔 `4800ms`
|
||||||
|
- `approaching`:较明确提醒,默认间隔 `950ms`
|
||||||
|
- `ready`:确认提醒,默认间隔 `650ms`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 系统设置默认规则
|
||||||
|
|
||||||
|
设置页属于程序公共能力,不属于某个玩法专属逻辑。
|
||||||
|
|
||||||
|
### 7.1 设置项结构
|
||||||
|
|
||||||
|
每个设置项默认由两部分组成:
|
||||||
|
|
||||||
|
- `value`:设置值
|
||||||
|
- `isLocked`:是否允许玩家修改
|
||||||
|
|
||||||
|
### 7.2 默认值规则
|
||||||
|
|
||||||
|
程序默认要求:
|
||||||
|
|
||||||
|
- 每个设置项都必须有系统默认值
|
||||||
|
- 玩家未手动修改时,直接使用系统默认值
|
||||||
|
- 默认值应集中维护,不散落在页面逻辑里
|
||||||
|
|
||||||
|
### 7.3 持久化规则
|
||||||
|
|
||||||
|
程序默认要求:
|
||||||
|
|
||||||
|
- `value` 需要持久化
|
||||||
|
- `isLocked` 不持久化
|
||||||
|
- 页面每次进入时,锁态都应重新按当前运行时规则计算
|
||||||
|
- 锁态只受系统默认值与活动配置影响,玩家不能在页面中修改锁态
|
||||||
|
- 设置页中的锁态徽标只做状态展示,不提供点按切换能力
|
||||||
|
- 锁态生存期:从当前游戏配置载入并进入该局开始,到本局正常结束、超时结束或主动退出为止
|
||||||
|
|
||||||
|
默认优先级:
|
||||||
|
|
||||||
|
`系统设置默认值 -> 玩家本地持久化值`
|
||||||
|
|
||||||
|
锁态优先级:
|
||||||
|
|
||||||
|
`系统锁态默认值 -> 当前运行时或活动规则覆盖`
|
||||||
|
|
||||||
|
### 7.4 当前已集中维护的设置基线
|
||||||
|
|
||||||
|
当前已在 [systemSettingsState.ts](D:/dev/cmr-mini/miniprogram/game/core/systemSettingsState.ts) 集中维护:
|
||||||
|
|
||||||
|
- 轨迹显示
|
||||||
|
- GPS 点显示
|
||||||
|
- 侧边按钮习惯
|
||||||
|
- 自动旋转
|
||||||
|
- 指北针调校
|
||||||
|
- 北向参考
|
||||||
|
- 中央标尺显示与锚点
|
||||||
|
- 各设置项的默认锁态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 最小流程基线
|
||||||
|
|
||||||
|
### 8.1 顺序打点最小流程
|
||||||
|
|
||||||
|
1. 进入游戏,只显示开始点
|
||||||
|
2. 打开始点,开赛并显示全场
|
||||||
|
3. 按顺序推进普通点
|
||||||
|
4. 普通点打点后默认只做基础结算
|
||||||
|
5. 可触发跳点
|
||||||
|
6. 打终点后直接进入结果页
|
||||||
|
7. 如果超过 `2` 小时仍未结束,系统按超时结束处理
|
||||||
|
|
||||||
|
### 8.2 积分赛最小流程
|
||||||
|
|
||||||
|
1. 进入游戏,只显示开始点
|
||||||
|
2. 打开始点,开赛并显示全部积分点和终点
|
||||||
|
3. 可直接前往任意积分点,或点击某个点更新当前目标
|
||||||
|
4. 进入积分点范围后成功打点
|
||||||
|
5. 普通点打点后默认只做基础结算
|
||||||
|
6. 默认至少完成 `1` 个普通积分点后可打终点结束
|
||||||
|
7. 如果超过 `2` 小时仍未结束,系统按超时结束处理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 暂不开放为配置的内容
|
||||||
|
|
||||||
|
当前先不优先配置化的内容:
|
||||||
|
|
||||||
|
- 弹层体系层级
|
||||||
|
- 起点是否弹白卡
|
||||||
|
- 普通点是否先白卡后答题
|
||||||
|
- 终点是否白卡后结算
|
||||||
|
- HUD 是否双页
|
||||||
|
- HUD 异型壳子结构
|
||||||
|
- 黑条提示与距离反馈的职责边界
|
||||||
|
|
||||||
|
这些内容应先作为程序默认能力稳定下来。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 故障恢复基线
|
||||||
|
|
||||||
|
当前故障恢复按“轻量快照恢复”处理:
|
||||||
|
|
||||||
|
- 仅恢复进行中的对局
|
||||||
|
- 仅恢复核心赛局状态、遥测累计值和地图基础视口
|
||||||
|
- 不恢复白卡、答题卡、临时动效、短反馈和提示层
|
||||||
|
- 恢复后由规则层和展示层重新计算 HUD、按钮文案、目标提示和音效状态
|
||||||
|
|
||||||
|
当前默认恢复内容:
|
||||||
|
|
||||||
|
- 当前配置入口
|
||||||
|
- `startedAt / completedControlIds / skippedControlIds / currentTargetControlId / score / modeState`
|
||||||
|
- 累计里程、基础心率/卡路里累计、最后 GPS 点
|
||||||
|
- `zoom / centerTile / rotation / gpsLock`
|
||||||
|
|
||||||
|
当前默认不恢复内容:
|
||||||
|
|
||||||
|
- 详情卡
|
||||||
|
- 答题中间态
|
||||||
|
- 待查看内容入口
|
||||||
|
- 所有瞬时动效和提示队列
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 后续开放配置的原则
|
||||||
|
|
||||||
|
后续只有满足以下条件的内容,才建议开放成配置:
|
||||||
|
|
||||||
|
- 运营确实会频繁改
|
||||||
|
- 改动不会破坏主流程一致性
|
||||||
|
- 改完不会引入一组新的层级冲突
|
||||||
|
|
||||||
|
优先可配置的内容应是:
|
||||||
|
|
||||||
|
- 点位分值
|
||||||
|
- 点位内容是否启用
|
||||||
|
- 点位样式
|
||||||
|
- 三档距离提示阈值
|
||||||
|
- 三档距离提示间隔
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 一句话结论
|
||||||
|
|
||||||
|
当前阶段应以这份文档作为**程序默认能力基线**:先把最小流程、弹层职责、HUD 结构和距离反馈定死,再决定哪些内容值得进入配置层。
|
||||||
245
doc/gameplay/运行时编译层总表.md
Normal file
245
doc/gameplay/运行时编译层总表.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# 运行时编译层总表
|
||||||
|
|
||||||
|
本文档用于定义当前项目推荐的“运行时编译层”结构,也就是把系统默认值、玩法默认值、活动配置、玩家设置编译成统一运行时 profile 的中间层。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 避免页面、引擎、规则层到处直接读取原始配置
|
||||||
|
- 明确各种默认值和覆盖值的合并顺序
|
||||||
|
- 为后续继续收口代码提供统一目标
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 本文档讲的是“运行时编译结构”
|
||||||
|
- 它不替代字段字典和玩法规则文档
|
||||||
|
- 当前已先落第一版代码骨架,后续会逐步把更多模块并到这一层
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 为什么需要编译层
|
||||||
|
|
||||||
|
当前系统里至少有 4 类来源:
|
||||||
|
|
||||||
|
1. 系统默认值
|
||||||
|
2. 玩法默认值
|
||||||
|
3. 活动配置
|
||||||
|
4. 玩家设置
|
||||||
|
|
||||||
|
如果页面、引擎、规则层分别自己去判断这些来源,问题会很快出现:
|
||||||
|
|
||||||
|
- 默认值散落
|
||||||
|
- 覆盖顺序不一致
|
||||||
|
- 同一个字段在不同页面里表现不同
|
||||||
|
- 后续后台接入后更难维护
|
||||||
|
|
||||||
|
所以推荐做法是:
|
||||||
|
|
||||||
|
先编译,再运行。
|
||||||
|
|
||||||
|
也就是:
|
||||||
|
|
||||||
|
`默认值 / 配置 / 玩家设置 -> Runtime Profile -> MapEngine / Rule / HUD`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 当前推荐编译顺序
|
||||||
|
|
||||||
|
### 2.1 规则与玩法相关
|
||||||
|
|
||||||
|
推荐顺序:
|
||||||
|
|
||||||
|
`系统默认值 -> 玩法默认值 -> 活动配置`
|
||||||
|
|
||||||
|
适用:
|
||||||
|
|
||||||
|
- 对局流程
|
||||||
|
- 打点规则
|
||||||
|
- 跳点规则
|
||||||
|
- 完赛规则
|
||||||
|
- 分值默认值
|
||||||
|
|
||||||
|
### 2.2 系统设置相关
|
||||||
|
|
||||||
|
推荐顺序:
|
||||||
|
|
||||||
|
`系统设置默认值 -> 活动 settings 默认值 -> 玩家本地设置值`
|
||||||
|
|
||||||
|
锁态单独处理:
|
||||||
|
|
||||||
|
`系统默认锁态 -> 活动 settings 锁态 -> 本局锁态生命周期`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 设置值可以持久化
|
||||||
|
- 锁态不持久化
|
||||||
|
- 锁态只在当前游戏配置运行生命周期内生效
|
||||||
|
|
||||||
|
### 2.3 遥测相关
|
||||||
|
|
||||||
|
推荐顺序:
|
||||||
|
|
||||||
|
`系统遥测默认值 -> 活动 telemetry 默认值 -> 玩家身体数据`
|
||||||
|
|
||||||
|
适用:
|
||||||
|
|
||||||
|
- 年龄
|
||||||
|
- 静息心率
|
||||||
|
- 体重
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 当前推荐 profile 结构
|
||||||
|
|
||||||
|
当前建议至少编译成以下几块:
|
||||||
|
|
||||||
|
### 3.1 `map`
|
||||||
|
|
||||||
|
负责地图底座级运行参数:
|
||||||
|
|
||||||
|
- 标题
|
||||||
|
- 控制点绘制半径
|
||||||
|
- 投影
|
||||||
|
- 磁偏角
|
||||||
|
- 初始缩放
|
||||||
|
|
||||||
|
### 3.2 `game`
|
||||||
|
|
||||||
|
负责玩法与规则层运行参数:
|
||||||
|
|
||||||
|
- 玩法模式
|
||||||
|
- 关门时间
|
||||||
|
- 关门预警时间
|
||||||
|
- 打点方式
|
||||||
|
- 打点半径
|
||||||
|
- 是否先选目标点
|
||||||
|
- 是否允许跳点
|
||||||
|
- 跳点半径
|
||||||
|
- 跳点确认
|
||||||
|
- 是否自动结束
|
||||||
|
- 默认点位分值
|
||||||
|
|
||||||
|
### 3.3 `settings`
|
||||||
|
|
||||||
|
负责系统设置页相关运行参数:
|
||||||
|
|
||||||
|
- 设置值最终结果
|
||||||
|
- 锁态最终结果
|
||||||
|
- 锁态生命周期是否激活
|
||||||
|
|
||||||
|
### 3.4 `telemetry`
|
||||||
|
|
||||||
|
负责遥测层运行参数:
|
||||||
|
|
||||||
|
- 合并后的遥测配置
|
||||||
|
- 当前玩家身体数据快照
|
||||||
|
|
||||||
|
### 3.5 `presentation`
|
||||||
|
|
||||||
|
负责表现层 profile:
|
||||||
|
|
||||||
|
- 控制点样式
|
||||||
|
- 轨迹样式
|
||||||
|
- GPS 点样式
|
||||||
|
|
||||||
|
### 3.6 `feedback`
|
||||||
|
|
||||||
|
负责反馈层 profile:
|
||||||
|
|
||||||
|
- 音效配置
|
||||||
|
- 震动配置
|
||||||
|
- UI 动效配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 当前代码落点
|
||||||
|
|
||||||
|
第一版运行时编译骨架已落在:
|
||||||
|
|
||||||
|
- [runtimeProfileCompiler.ts](D:/dev/cmr-mini/miniprogram/game/core/runtimeProfileCompiler.ts)
|
||||||
|
|
||||||
|
当前已经开始编译的内容:
|
||||||
|
|
||||||
|
- `game`
|
||||||
|
- `settings`
|
||||||
|
- `telemetry`
|
||||||
|
- `presentation`
|
||||||
|
- `feedback`
|
||||||
|
- `map`
|
||||||
|
|
||||||
|
当前已接入页面 / 引擎的部分:
|
||||||
|
|
||||||
|
- 设置页运行时 profile
|
||||||
|
- 遥测层运行时 profile
|
||||||
|
- 反馈层运行时 profile
|
||||||
|
- 表现层 runtime profile
|
||||||
|
- 规则层中一部分 `game profile` 字段
|
||||||
|
|
||||||
|
当前接入说明:
|
||||||
|
|
||||||
|
- `settings / telemetry / feedback` 已开始作为日常运行入口使用
|
||||||
|
- 设置页大部分引擎型设置项已改成“先更新玩家设置,再统一走 `settings profile` 回灌 `MapEngine`”
|
||||||
|
- `settings profile` 现已通过 `MapEngine.applyCompiledSettingsProfile(...)` 统一应用,不再由页面逐项调用各类 `handleSet...`
|
||||||
|
- `presentation` 已开始通过编译层回写 `MapEngine` 表现参数
|
||||||
|
- `game` 当前已先接入模式、关门时间、打点和跳点这组核心字段
|
||||||
|
- `map` 已开始接入地图底座参数和配置状态文本这组基础字段
|
||||||
|
- `MapEngine.applyRemoteMapConfig(...)` 已开始退回为原始场地与资源入口,不再继续双写已编译字段
|
||||||
|
|
||||||
|
后续建议继续并入:
|
||||||
|
|
||||||
|
- `MapEngine.applyRemoteMapConfig(...)` 的配置归一入口
|
||||||
|
- `map profile`
|
||||||
|
- 更多 `game profile` 字段
|
||||||
|
- `courseToGameDefinition`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 当前推荐落地步骤
|
||||||
|
|
||||||
|
建议按以下顺序继续收代码:
|
||||||
|
|
||||||
|
1. 先让剩余设置层完全只吃 `settings profile`
|
||||||
|
2. 再让遥测层只吃 `telemetry profile`
|
||||||
|
3. 再让反馈层只吃 `feedback profile`
|
||||||
|
4. 最后把玩法规则入口和表现入口都改成吃统一 profile
|
||||||
|
|
||||||
|
补充:
|
||||||
|
|
||||||
|
- 规则层中的目标角色也应尽量由统一运行态承载,而不是散落在 HUD 和地图展示字段里反推
|
||||||
|
- 当前已先明确 4 类目标角色:
|
||||||
|
- 可打目标
|
||||||
|
- 引导目标
|
||||||
|
- HUD 目标
|
||||||
|
- 展示高亮目标
|
||||||
|
|
||||||
|
这样风险最小,也最容易逐步验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 这层的边界
|
||||||
|
|
||||||
|
运行时编译层应该只做两件事:
|
||||||
|
|
||||||
|
1. 合并来源
|
||||||
|
2. 产出最终运行态 profile
|
||||||
|
|
||||||
|
不应该做的事:
|
||||||
|
|
||||||
|
- 不直接承载页面状态
|
||||||
|
- 不直接承载本局临时分数和目标点
|
||||||
|
- 不直接写回配置
|
||||||
|
- 不承担玩法执行逻辑本身
|
||||||
|
|
||||||
|
也就是说:
|
||||||
|
|
||||||
|
- 编译层负责“准备好”
|
||||||
|
- 引擎和规则层负责“执行”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 当前结论
|
||||||
|
|
||||||
|
后续推荐统一按这条链走:
|
||||||
|
|
||||||
|
`系统默认值 -> 玩法默认值 -> 活动配置 -> 玩家设置 -> 运行时编译层 -> 引擎 / 页面 / 规则`
|
||||||
|
|
||||||
|
这样配置越多,系统越不容易乱;后续后台做复杂了,也还是有一层中间结构兜住。
|
||||||
21
doc/games/积分赛/全局配置项.md
Normal file
21
doc/games/积分赛/全局配置项.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 积分赛全局配置项
|
||||||
|
|
||||||
|
本文档只列积分赛对公共配置块的默认落点。完整字段定义仍以 [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) 和 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) 为准。
|
||||||
|
|
||||||
|
| 名称 | 字段 | 积分赛默认值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 开赛后显全场 | `game.visibility.revealFullPlayfieldAfterStartPunch` | `true` |
|
||||||
|
| 必须先聚焦目标 | `game.punch.requiresFocusSelection` | `true` |
|
||||||
|
| 最后点自动结束 | `game.session.autoFinishOnLastControl` | `false` |
|
||||||
|
| 跳点启用 | `game.sequence.skip.enabled` | `false` |
|
||||||
|
| 跳点半径 | `game.sequence.skip.radiusMeters` | 不启用时忽略 |
|
||||||
|
| 跳点确认 | `game.sequence.skip.requiresConfirm` | 不启用时忽略 |
|
||||||
|
| 终点始终可选 | `game.finish.finishControlAlwaysSelectable` | `true` |
|
||||||
|
| 默认控制点分值 | `game.scoring.defaultControlScore` | `10` |
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- 开始点和结束点不弹题
|
||||||
|
- 普通点默认自动进入 10 秒题卡
|
||||||
|
- 答题时比赛继续计时
|
||||||
|
- 未选择目标点时,HUD 只提示“请选择目标点”
|
||||||
53
doc/games/积分赛/最大配置模板.md
Normal file
53
doc/games/积分赛/最大配置模板.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# 积分赛最大配置模板
|
||||||
|
|
||||||
|
本文档作为积分赛的完整模板入口。当前项目仍维护一份共享全量模板:
|
||||||
|
|
||||||
|
- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
||||||
|
|
||||||
|
如果要为积分赛新建一份“尽量全”的活动配置,建议至少覆盖这些块:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": {},
|
||||||
|
"map": {},
|
||||||
|
"playfield": {
|
||||||
|
"kind": "control-set",
|
||||||
|
"source": {},
|
||||||
|
"CPRadius": 6,
|
||||||
|
"controlOverrides": {},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
"game": {
|
||||||
|
"mode": "score-o",
|
||||||
|
"session": {
|
||||||
|
"startManually": false,
|
||||||
|
"requiresStartPunch": true,
|
||||||
|
"requiresFinishPunch": false,
|
||||||
|
"autoFinishOnLastControl": false
|
||||||
|
},
|
||||||
|
"punch": {
|
||||||
|
"policy": "enter-confirm",
|
||||||
|
"radiusMeters": 5,
|
||||||
|
"requiresFocusSelection": true
|
||||||
|
},
|
||||||
|
"scoring": {
|
||||||
|
"type": "score",
|
||||||
|
"defaultControlScore": 10
|
||||||
|
},
|
||||||
|
"guidance": {},
|
||||||
|
"presentation": {},
|
||||||
|
"visibility": {},
|
||||||
|
"finish": {},
|
||||||
|
"telemetry": {},
|
||||||
|
"feedback": {}
|
||||||
|
},
|
||||||
|
"resources": {},
|
||||||
|
"debug": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
建议搭配阅读:
|
||||||
|
|
||||||
|
- [规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md)
|
||||||
|
- [游戏配置项](D:/dev/cmr-mini/doc/games/积分赛/游戏配置项.md)
|
||||||
|
- [score-o.json](D:/dev/cmr-mini/event/score-o.json)
|
||||||
246
doc/games/积分赛/最小配置模板.md
Normal file
246
doc/games/积分赛/最小配置模板.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# 积分赛最小配置模板
|
||||||
|
|
||||||
|
本文档提供一份 **积分赛(`score-o`)最小可跑配置模板**。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 只保留积分赛跑通所需的最少字段
|
||||||
|
- 让测试尽量回到程序默认规则本身
|
||||||
|
- 避免历史内容卡、H5 页面、样式覆盖继续干扰联调
|
||||||
|
|
||||||
|
如果你关心“最小模板下系统到底默认怎么跑”,请优先配合阅读:
|
||||||
|
|
||||||
|
- [积分赛规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md)
|
||||||
|
- [程序默认规则基线](D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 最小模板
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": "1",
|
||||||
|
"version": "2026.03.31",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"game": {
|
||||||
|
"mode": "score-o"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 字段说明
|
||||||
|
|
||||||
|
### `schemaVersion`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:配置结构版本
|
||||||
|
- 当前建议值:`"1"`
|
||||||
|
|
||||||
|
### `version`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:配置版本号
|
||||||
|
|
||||||
|
### `app.id`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:活动配置实例 ID
|
||||||
|
|
||||||
|
### `app.title`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:活动标题 / 比赛名称
|
||||||
|
|
||||||
|
### `app.locale`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:语言区域
|
||||||
|
|
||||||
|
### `map.tiles`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:地图瓦片根路径
|
||||||
|
|
||||||
|
### `map.mapmeta`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:地图 meta 文件路径
|
||||||
|
|
||||||
|
### `map.declination`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:磁偏角,未填写时按程序默认值处理
|
||||||
|
|
||||||
|
### `map.initialView.zoom`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:初始缩放级别,未填写时按程序默认视口逻辑处理
|
||||||
|
|
||||||
|
### `playfield.kind`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:空间对象类型
|
||||||
|
- 积分赛固定使用:`control-set`
|
||||||
|
|
||||||
|
### `playfield.source.type`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:空间底稿来源类型
|
||||||
|
- 当前推荐值:`kml`
|
||||||
|
|
||||||
|
### `playfield.source.url`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:KML 文件路径
|
||||||
|
|
||||||
|
### `playfield.CPRadius`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:点位触发半径,未填写时按程序默认值处理
|
||||||
|
|
||||||
|
### `playfield.controlDefaults.score`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:普通积分点统一默认分值
|
||||||
|
- 备注:适合整场大多数积分点共用一个分值
|
||||||
|
|
||||||
|
### `playfield.controlOverrides.*.score`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:积分点分值
|
||||||
|
- 备注:如果写了 `playfield.controlDefaults.score`,单点这里会覆盖默认值
|
||||||
|
|
||||||
|
### `playfield.metadata.title`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:路线名称
|
||||||
|
|
||||||
|
### `playfield.metadata.code`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:路线编码
|
||||||
|
|
||||||
|
### `game.mode`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:玩法模式
|
||||||
|
- 积分赛固定值:`score-o`
|
||||||
|
|
||||||
|
### `game.*`
|
||||||
|
|
||||||
|
- 类型:`object`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:最小样例默认不依赖额外玩法覆盖字段
|
||||||
|
- 备注:如需覆盖程序默认值,再补 `punch`、`scoring`、`guidance`、`finish` 等子块
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 当前默认逻辑
|
||||||
|
|
||||||
|
如果你只写本页最小模板,积分赛会按程序默认规则运行。
|
||||||
|
|
||||||
|
### 3.1 默认局流程
|
||||||
|
|
||||||
|
- 进入游戏后默认只显示开始点
|
||||||
|
- 系统默认提示玩家:需要先打开始点才正式开始比赛
|
||||||
|
- 未打开始点前,积分点和结束点不生效
|
||||||
|
- 首次成功打开始点后:
|
||||||
|
- 正式开始比赛
|
||||||
|
- 初始化本局数据
|
||||||
|
- 开始计时
|
||||||
|
- 显示全部积分点和结束点
|
||||||
|
- 默认只给出“比赛开始”短反馈,并同步更新引导提示和 HUD
|
||||||
|
- 最小模板下默认不弹开始白色内容卡
|
||||||
|
- 比赛进行中默认允许自由点击任意未收集积分点,将其设为当前目标点
|
||||||
|
- 当前目标点选定后,底部 HUD 信息面板默认显示该点距离等信息
|
||||||
|
- 最小模板下,点击积分点默认不弹详情卡
|
||||||
|
- 默认不要求先选中目标点
|
||||||
|
- 任意未收集积分点进入打点半径后都可成功打点
|
||||||
|
- 成功打点后默认:
|
||||||
|
- 先得该点标注分值的基础分
|
||||||
|
- 默认不弹题
|
||||||
|
- 如需答题卡,需显式配置对应点位的 `quiz` CTA
|
||||||
|
- 开始点和结束点默认不弹题,只弹提示信息
|
||||||
|
- 默认至少完成 `1` 个普通积分点后,结束点才解锁
|
||||||
|
- 结束点解锁后不需要先选为目标点
|
||||||
|
- 成功打结束点后:
|
||||||
|
- 停止计时
|
||||||
|
- 给出完成短反馈
|
||||||
|
- 直接进入默认成绩结算页
|
||||||
|
- 默认不再额外叠加终点白色内容卡
|
||||||
|
- 白色内容卡默认改为显式配置启用;只有某个点位明确配置 `autoPopup = true` 时,完成该点后才会先弹白卡
|
||||||
|
|
||||||
|
### 3.2 默认规则参数
|
||||||
|
|
||||||
|
- `map.declination`
|
||||||
|
- 没配时默认按 `0`
|
||||||
|
- `map.initialView.zoom`
|
||||||
|
- 没配时由客户端初始视口逻辑接管
|
||||||
|
- `playfield.CPRadius`
|
||||||
|
- 没配时按客户端内置值处理
|
||||||
|
- `playfield.controlDefaults.*`
|
||||||
|
- 没配时按程序默认值处理
|
||||||
|
- `playfield.controlOverrides.*.score`
|
||||||
|
- 没配时按程序默认统一分值处理
|
||||||
|
- `game.session.*`
|
||||||
|
- 默认非手动开始
|
||||||
|
- 默认要求起点打卡
|
||||||
|
- 默认不要求终点打卡才能算“完赛资格”
|
||||||
|
- 默认不在最后一个积分点自动结束
|
||||||
|
- `game.punch.requiresFocusSelection`
|
||||||
|
- 默认按 `true` 处理
|
||||||
|
- `game.guidance.allowFocusSelection`
|
||||||
|
- 默认按积分赛逻辑允许选点
|
||||||
|
- `game.finish.finishControlAlwaysSelectable`
|
||||||
|
- 默认按积分赛逻辑处理终点可选
|
||||||
|
- `game.visibility.revealFullPlayfieldAfterStartPunch`
|
||||||
|
- 默认按 `true` 处理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 当前样例说明
|
||||||
|
|
||||||
|
当前仓库中的 [score-o.json](D:/dev/cmr-mini/event/score-o.json) 已按这套最小测试口径收敛,除积分点分值外,不再附带历史测试内容卡、H5 页面和样式覆盖。
|
||||||
|
|
||||||
|
如果要查看公共完整字段,请继续参考:
|
||||||
|
|
||||||
|
- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
||||||
33
doc/games/积分赛/游戏说明文档.md
Normal file
33
doc/games/积分赛/游戏说明文档.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 积分赛游戏说明文档
|
||||||
|
|
||||||
|
本文档作为 `score-o` 的目录入口,用来统一说明本玩法文档放在哪里、分别看什么。
|
||||||
|
|
||||||
|
## 1. 玩法定位
|
||||||
|
|
||||||
|
- 玩法名称:积分赛
|
||||||
|
- 模式标识:`score-o`
|
||||||
|
- 核心特征:自由选点,自由收集,终点可随时结束
|
||||||
|
- 当前默认:普通点先得点位分值,再进入题卡,答对再得同分奖励
|
||||||
|
|
||||||
|
## 2. 本目录结构
|
||||||
|
|
||||||
|
- [规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md)
|
||||||
|
规则、流程、选点、计分、结算口径
|
||||||
|
- [最小配置模板](D:/dev/cmr-mini/doc/games/积分赛/最小配置模板.md)
|
||||||
|
最少字段怎么配才能跑起来
|
||||||
|
- [最大配置模板](D:/dev/cmr-mini/doc/games/积分赛/最大配置模板.md)
|
||||||
|
该玩法当前建议的完整配置骨架入口
|
||||||
|
- [全局配置项](D:/dev/cmr-mini/doc/games/积分赛/全局配置项.md)
|
||||||
|
跨玩法公共字段在积分赛下的默认取值
|
||||||
|
- [游戏配置项](D:/dev/cmr-mini/doc/games/积分赛/游戏配置项.md)
|
||||||
|
积分赛独有或强相关字段
|
||||||
|
|
||||||
|
## 3. 运行样例
|
||||||
|
|
||||||
|
- [score-o.json](D:/dev/cmr-mini/event/score-o.json)
|
||||||
|
|
||||||
|
## 4. 关联公共文档
|
||||||
|
|
||||||
|
- [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
|
- [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md)
|
||||||
|
- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
||||||
54
doc/games/积分赛/游戏配置项.md
Normal file
54
doc/games/积分赛/游戏配置项.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# 积分赛游戏配置项
|
||||||
|
|
||||||
|
本文档用于汇总当前系统对 `score-o` 的已支持可配置项,重点只看和积分赛玩法直接相关的字段。
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 跨玩法公共字段请继续参考 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
|
- 本文只补积分赛视角下最常用、最关键的字段
|
||||||
|
|
||||||
|
## 1. 顶层与样例入口
|
||||||
|
|
||||||
|
| 字段 | 作用 | 默认逻辑 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `game.mode` | 指定玩法模式 | 固定为 `score-o` |
|
||||||
|
| `event/score-o.json` | 当前联调样例 | 可直接运行 |
|
||||||
|
|
||||||
|
## 2. 点位与分值
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认逻辑 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `playfield.controlOverrides.<key>.score` | 单个积分点分值 | `number` | 未配置时回退到 `game.scoring.defaultControlScore`,再回退到玩法默认 `10` |
|
||||||
|
| `playfield.controlOverrides.<key>.title` | 打点后内容卡标题 | `string` | 未配置时走系统默认文案 |
|
||||||
|
| `playfield.controlOverrides.<key>.body` | 打点后内容卡正文 | `string` | 未配置时走系统默认文案 |
|
||||||
|
| `playfield.controlOverrides.<key>.autoPopup` | 打点后是否先弹内容卡 | `true` / `false` | 默认 `true`;即使关闭,普通点默认题卡仍会自动进入 |
|
||||||
|
|
||||||
|
## 3. 选点与打点
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认逻辑 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.punch.policy` | 打点方式 | `enter` / `enter-confirm` | 默认 `enter-confirm` |
|
||||||
|
| `game.punch.radiusMeters` | 打点半径 | `number` | 默认 `5` |
|
||||||
|
| `game.punch.requiresFocusSelection` | 是否必须先选目标点 | `true` / `false` | 默认 `true` |
|
||||||
|
| `game.guidance.allowFocusSelection` | 是否允许点击地图切换目标点 | `true` / `false` | 默认 `true` |
|
||||||
|
|
||||||
|
## 4. 结束与结算
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认逻辑 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.finish.finishControlAlwaysSelectable` | 终点是否始终可打 | `true` / `false` | 默认 `true` |
|
||||||
|
| `game.session.autoFinishOnLastControl` | 最后一个积分点后是否自动结束 | `true` / `false` | 默认 `false` |
|
||||||
|
| `game.session.requiresFinishPunch` | 是否要求打终点才结束 | `true` / `false` | 当前默认 `false` |
|
||||||
|
|
||||||
|
## 5. 默认答题逻辑
|
||||||
|
|
||||||
|
当前普通积分点会自动进入默认题卡流程,默认口径如下:
|
||||||
|
|
||||||
|
- 倒计时 `10` 秒
|
||||||
|
- 默认随机数学题
|
||||||
|
- 答对加该点同分奖励
|
||||||
|
- 答错或超时不得奖励分
|
||||||
|
|
||||||
|
详细规则请看:
|
||||||
|
|
||||||
|
- [规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md)
|
||||||
318
doc/games/积分赛/规则说明文档.md
Normal file
318
doc/games/积分赛/规则说明文档.md
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
# 积分赛规则说明文档
|
||||||
|
|
||||||
|
本文档用于定义 `score-o` 在**最小模板**下的系统默认规则,作为后续实现、联调和配置扩展的共同基线。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 明确积分赛在“不额外写规则字段”时应该怎么跑
|
||||||
|
- 把开局、目标点选择、打点、答题、结束和结算流程写清楚
|
||||||
|
- 为后续配置化拆分提供规则依据
|
||||||
|
|
||||||
|
如果后续要继续扩写更多正式玩法文档,建议统一使用:
|
||||||
|
|
||||||
|
- [玩法设计文档模板](D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 适用范围
|
||||||
|
|
||||||
|
本文默认规则适用于:
|
||||||
|
|
||||||
|
- `game.mode = "score-o"`
|
||||||
|
- 使用最小积分赛模板启动
|
||||||
|
- 未显式覆盖开局流程、目标点规则、答题、结算和默认表现规则
|
||||||
|
|
||||||
|
本文定义的是**系统默认行为**,不是所有字段都必须先配置出来。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 一句话规则
|
||||||
|
|
||||||
|
玩家进入游戏后先看到起点,完成起点打卡后正式开赛;玩家可自由前往任意积分点,进入其打点半径后成功打点并获得该点基础分;玩家也可点击任意积分点将其设为当前目标点,用于 HUD 距离引导;最小模板下默认不带答题;默认至少完成 `1` 个普通积分点后,结束点才解锁并可结束比赛。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 默认流程总览
|
||||||
|
|
||||||
|
### 3.1 进入游戏
|
||||||
|
|
||||||
|
- 系统进入“待起跑”状态
|
||||||
|
- 地图默认只显示:
|
||||||
|
- 起点
|
||||||
|
- 玩家当前位置
|
||||||
|
- 基础 HUD
|
||||||
|
- 所有积分点和结束点默认不显示
|
||||||
|
- 页面提示玩家:需要先打开始点,比赛才会正式开始并开始计时
|
||||||
|
|
||||||
|
### 3.2 打开始点
|
||||||
|
|
||||||
|
- 玩家首次成功打开始点后,比赛正式开始
|
||||||
|
- 系统立即初始化本局数据
|
||||||
|
- 系统开始计时
|
||||||
|
- 系统显示全部积分点和结束点
|
||||||
|
- 默认只给出“比赛开始”短反馈,并同步更新引导提示和 HUD
|
||||||
|
- 最小模板下,开始点完成后默认不弹白色内容卡
|
||||||
|
- 默认关门时间为开赛后 `2` 小时
|
||||||
|
- 当距离关门时间小于等于 `10` 分钟时,HUD 时间区默认切换为倒计时强调样式
|
||||||
|
|
||||||
|
### 3.3 进行中自由打点
|
||||||
|
|
||||||
|
- 玩家可自由前往任意未收集积分点完成打点
|
||||||
|
- 玩家也可点击任意未收集积分点,将其设为当前目标点
|
||||||
|
- 当前目标点设定后,底部 HUD 信息面板更新该点相关信息
|
||||||
|
- 最小模板下,点击积分点默认只做目标选择,不弹详情卡
|
||||||
|
- 默认不要求先选中目标点
|
||||||
|
- 任意未收集积分点进入打点半径后都可成功打点
|
||||||
|
- 成功打点后:
|
||||||
|
- 获得该点基础分
|
||||||
|
- 该点记为已收集
|
||||||
|
- 最小模板下默认不弹答题卡
|
||||||
|
- 如显式配置答题 CTA,则在该点完成后进入答题
|
||||||
|
|
||||||
|
### 3.4 打结束点
|
||||||
|
|
||||||
|
- 默认至少完成 `1` 个普通积分点后,结束点才解锁
|
||||||
|
- 结束点不需要先被设为当前目标点
|
||||||
|
- 成功打结束点后:
|
||||||
|
- 系统停止计时
|
||||||
|
- 先给出完成短反馈
|
||||||
|
- 直接进入默认成绩结算页
|
||||||
|
- 默认不再额外叠加终点白色内容卡
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 局状态与数据初始化
|
||||||
|
|
||||||
|
### 4.1 默认局状态
|
||||||
|
|
||||||
|
积分赛默认包含以下状态:
|
||||||
|
|
||||||
|
1. `ready`
|
||||||
|
2. `running`
|
||||||
|
3. `finished`
|
||||||
|
4. `settled`
|
||||||
|
|
||||||
|
### 4.2 起点打卡后的初始化数据
|
||||||
|
|
||||||
|
首次成功打开始点时,系统至少初始化以下运行时数据:
|
||||||
|
|
||||||
|
- `startTime`
|
||||||
|
- `elapsedTime`
|
||||||
|
- `currentTargetId`
|
||||||
|
- `collectedControls`
|
||||||
|
- `availableControls`
|
||||||
|
- `score`
|
||||||
|
- `baseScore`
|
||||||
|
- `quizBonusScore`
|
||||||
|
- `quizCorrectCount`
|
||||||
|
- `quizWrongCount`
|
||||||
|
- `quizTimeoutCount`
|
||||||
|
- `finishTime`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 打点与目标点规则
|
||||||
|
|
||||||
|
### 5.1 开始点
|
||||||
|
|
||||||
|
- 开始点必须打卡
|
||||||
|
- 开始点不弹答题卡
|
||||||
|
- 开始点默认只给短反馈和引导更新
|
||||||
|
- 开始点默认不弹白色内容卡
|
||||||
|
- 未打开始点前,积分点和结束点不触发正式打点逻辑
|
||||||
|
|
||||||
|
### 5.2 积分点
|
||||||
|
|
||||||
|
- 积分点自由选择,不按顺序推进
|
||||||
|
- 玩家默认不需要先点击某个积分点
|
||||||
|
- 点击某个积分点时,会把它设为当前目标点,主要用于 HUD 和距离引导
|
||||||
|
- 最小模板下,点击点位本身不产生额外内容反馈
|
||||||
|
- 任意未收集积分点进入打点半径后都可成功打点
|
||||||
|
- 已成功打过的积分点不可重复得分,不可重复出题
|
||||||
|
- 已成功打过的积分点再次进入范围时,只保留已收集状态展示
|
||||||
|
|
||||||
|
### 5.3 非目标点进入半径
|
||||||
|
|
||||||
|
- 如果玩家进入某个未选中的积分点打点半径:
|
||||||
|
- 默认仍然可以正式打点
|
||||||
|
- 默认获得基础分
|
||||||
|
- 最小模板下默认不进入答题
|
||||||
|
- “当前目标点”只影响 HUD 和距离引导,不影响默认打点资格
|
||||||
|
|
||||||
|
### 5.4 目标点切换
|
||||||
|
|
||||||
|
- 玩家在比赛进行中可随时点击其他未收集积分点,切换当前目标点
|
||||||
|
- 切换后底部 HUD 信息面板应更新为新目标点信息
|
||||||
|
|
||||||
|
### 5.5 结束点
|
||||||
|
|
||||||
|
- 结束点默认在至少完成 `1` 个普通积分点后生效
|
||||||
|
- 结束点不需要先设为目标点
|
||||||
|
- 结束点不弹答题卡
|
||||||
|
- 结束点默认只给完成短反馈并直接进入结果页
|
||||||
|
- 结束点默认不弹白色内容卡
|
||||||
|
- 如果超过关门时间仍未结束,系统按“超时结束”处理
|
||||||
|
- “超时结束”需与正常完赛、主动退出明确区分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 计分与答题规则
|
||||||
|
|
||||||
|
### 6.1 基础分
|
||||||
|
|
||||||
|
- 成功打到某个积分点后,立即获得该点标注分值作为基础分
|
||||||
|
- 如果该点未单独配置分值,则走默认分值逻辑
|
||||||
|
|
||||||
|
### 6.2 默认答题规则
|
||||||
|
|
||||||
|
- 最小模板下默认不启用答题
|
||||||
|
- 如需答题,需显式为对应点位配置 `quiz` CTA
|
||||||
|
- 显式启用后,答题时比赛继续计时,不暂停比赛时间
|
||||||
|
|
||||||
|
### 6.3 奖励分
|
||||||
|
|
||||||
|
- 最小模板下默认不存在答题奖励分
|
||||||
|
- 如显式启用答题,则可按题卡配置再获得奖励分
|
||||||
|
|
||||||
|
### 6.4 不弹题对象
|
||||||
|
|
||||||
|
- 开始点不弹题
|
||||||
|
- 结束点不弹题
|
||||||
|
- 未成功打点的非目标点不弹题
|
||||||
|
|
||||||
|
### 6.5 排名与结算默认口径
|
||||||
|
|
||||||
|
- 第一排序:总分高者优先
|
||||||
|
- 第二排序:同分时总用时短者优先
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. HUD 默认规则
|
||||||
|
|
||||||
|
### 7.1 HUD 基础信息
|
||||||
|
|
||||||
|
比赛期间底部 HUD 信息面板默认可承载以下信息:
|
||||||
|
|
||||||
|
- 当前比赛时间
|
||||||
|
- 当前总分
|
||||||
|
- 当前目标点摘要
|
||||||
|
- 当前目标点分值
|
||||||
|
- 玩家与当前目标点的距离
|
||||||
|
|
||||||
|
### 7.2 未选择目标点
|
||||||
|
|
||||||
|
- 如果当前还未选择目标点:
|
||||||
|
- HUD 显示“请选择目标点”类提示
|
||||||
|
- HUD 目标摘要区域显示“请选择目标点”
|
||||||
|
- 不显示目标距离
|
||||||
|
- 仍显示比赛时间和总分等基础信息
|
||||||
|
|
||||||
|
### 7.3 已选择目标点
|
||||||
|
|
||||||
|
- 选择目标点后,HUD 更新为该点信息
|
||||||
|
- HUD 目标摘要区域显示当前点名称与分值摘要
|
||||||
|
- 玩家切换目标点后,HUD 立即切换为新目标点信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 地图与表现默认规则
|
||||||
|
|
||||||
|
### 8.1 起跑前显示
|
||||||
|
|
||||||
|
- 起跑前仅显示开始点
|
||||||
|
- 所有积分点和结束点隐藏
|
||||||
|
|
||||||
|
### 8.2 起跑后显示
|
||||||
|
|
||||||
|
- 打完开始点后显示全部积分点和结束点
|
||||||
|
- 当前目标点需要有明确高亮态
|
||||||
|
- 所有积分点默认建议显示分值标签
|
||||||
|
|
||||||
|
### 8.3 点位默认表现
|
||||||
|
|
||||||
|
- 未选中的积分点显示基础分值和基础样式
|
||||||
|
- 积分点默认显示分值,而不是序号
|
||||||
|
- 积分点分值默认绘制在圆圈内
|
||||||
|
- 积分点默认按分值分档显示不同颜色
|
||||||
|
- 积分点默认半径保持一致,不因分值高低自动变大
|
||||||
|
- 当前目标点需要有更强的动效和高亮
|
||||||
|
- 已收集积分点默认变灰
|
||||||
|
- 结束点始终可见,但视觉权重默认低于当前目标点
|
||||||
|
|
||||||
|
### 8.4 腿线默认表现
|
||||||
|
|
||||||
|
- 积分赛默认不显示腿线
|
||||||
|
- 也不启用腿线动效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 反馈与结算页默认规则
|
||||||
|
|
||||||
|
### 9.1 开始反馈
|
||||||
|
|
||||||
|
- 开始点成功后默认给出“比赛开始”类短反馈
|
||||||
|
- 同时更新黑色引导提示条和 HUD 目标摘要
|
||||||
|
- 最小模板下默认不弹开始白卡
|
||||||
|
- 如需开始点完成后展示白色内容卡,需显式开启对应点位的 `autoPopup = true`
|
||||||
|
|
||||||
|
### 9.2 结束反馈
|
||||||
|
|
||||||
|
- 结束点成功后默认先给短反馈
|
||||||
|
- 随后直接进入默认成绩结算页
|
||||||
|
- 默认不再额外弹出终点白色内容卡
|
||||||
|
- 如需再次查看终点说明,需显式配置点击内容能力
|
||||||
|
|
||||||
|
### 9.3 默认成绩结算页
|
||||||
|
|
||||||
|
默认结算页至少显示:
|
||||||
|
|
||||||
|
- 总分
|
||||||
|
- 基础积分
|
||||||
|
- 答题奖励积分
|
||||||
|
- 已收集点数
|
||||||
|
- 答题正确数
|
||||||
|
- 答题错误数
|
||||||
|
- 答题超时数
|
||||||
|
- 总用时
|
||||||
|
- 是否打终点完赛
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 边界与容错默认规则
|
||||||
|
|
||||||
|
### 10.1 GPS 防重复触发
|
||||||
|
|
||||||
|
- 同一点成功结算后,系统应有短暂防抖或冷却,避免 GPS 抖动导致重复触发
|
||||||
|
|
||||||
|
### 10.2 重复进入已收集点
|
||||||
|
|
||||||
|
- 已收集点再次进入范围时不重复得分、不重复答题、不重复弹完成提示
|
||||||
|
|
||||||
|
### 10.3 切换目标点
|
||||||
|
|
||||||
|
- 切换目标点只改变当前目标和 HUD 信息,不回滚已有得分和已收集状态
|
||||||
|
|
||||||
|
### 10.4 页面中断恢复
|
||||||
|
|
||||||
|
- 小程序切后台或页面重进后,默认应恢复当前比赛状态
|
||||||
|
- 已开始的比赛默认不应自动重开
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 与最小模板的关系
|
||||||
|
|
||||||
|
积分赛最小模板只需要保证以下骨架存在:
|
||||||
|
|
||||||
|
- `playfield.kind = "control-set"`
|
||||||
|
- `game.mode = "score-o"`
|
||||||
|
- `game.punch.policy`
|
||||||
|
- `game.punch.radiusMeters`
|
||||||
|
|
||||||
|
其余流程默认按本文档执行。
|
||||||
|
|
||||||
|
也就是说,本文档定义的是:
|
||||||
|
|
||||||
|
- 最小模板下的系统默认局流程
|
||||||
|
- 后续配置化扩展前的默认产品行为
|
||||||
|
- 积分赛样例配置和实现验收时的基准口径
|
||||||
|
|
||||||
20
doc/games/顺序打点/全局配置项.md
Normal file
20
doc/games/顺序打点/全局配置项.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 顺序打点全局配置项
|
||||||
|
|
||||||
|
本文档只列顺序打点对公共配置块的默认落点。完整字段定义仍以 [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) 和 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) 为准。
|
||||||
|
|
||||||
|
| 名称 | 字段 | 顺序打点默认值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 开赛后显全场 | `game.visibility.revealFullPlayfieldAfterStartPunch` | `true` |
|
||||||
|
| 必须先聚焦目标 | `game.punch.requiresFocusSelection` | `false` |
|
||||||
|
| 最后点自动结束 | `game.session.autoFinishOnLastControl` | `false` |
|
||||||
|
| 跳点启用 | `game.sequence.skip.enabled` | `true` |
|
||||||
|
| 跳点半径 | `game.sequence.skip.radiusMeters` | `game.punch.radiusMeters * 2` |
|
||||||
|
| 跳点确认 | `game.sequence.skip.requiresConfirm` | `false` |
|
||||||
|
| 终点始终可选 | `game.finish.finishControlAlwaysSelectable` | `false` |
|
||||||
|
| 默认控制点分值 | `game.scoring.defaultControlScore` | `1` |
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- 开始点和结束点不弹题
|
||||||
|
- 普通点默认自动进入 10 秒题卡
|
||||||
|
- 答题时比赛继续计时
|
||||||
57
doc/games/顺序打点/最大配置模板.md
Normal file
57
doc/games/顺序打点/最大配置模板.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# 顺序打点最大配置模板
|
||||||
|
|
||||||
|
本文档作为顺序打点的完整模板入口。当前项目仍维护一份共享全量模板:
|
||||||
|
|
||||||
|
- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
||||||
|
|
||||||
|
如果要为顺序打点新建一份“尽量全”的活动配置,建议至少覆盖这些块:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": {},
|
||||||
|
"map": {},
|
||||||
|
"playfield": {
|
||||||
|
"kind": "course",
|
||||||
|
"source": {},
|
||||||
|
"CPRadius": 6,
|
||||||
|
"controlOverrides": {},
|
||||||
|
"legOverrides": {},
|
||||||
|
"metadata": {}
|
||||||
|
},
|
||||||
|
"game": {
|
||||||
|
"mode": "classic-sequential",
|
||||||
|
"session": {
|
||||||
|
"startManually": false,
|
||||||
|
"requiresStartPunch": true,
|
||||||
|
"requiresFinishPunch": true,
|
||||||
|
"autoFinishOnLastControl": false
|
||||||
|
},
|
||||||
|
"punch": {
|
||||||
|
"policy": "enter-confirm",
|
||||||
|
"radiusMeters": 5,
|
||||||
|
"requiresFocusSelection": false
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"skip": {
|
||||||
|
"enabled": true,
|
||||||
|
"radiusMeters": 10,
|
||||||
|
"requiresConfirm": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guidance": {},
|
||||||
|
"presentation": {},
|
||||||
|
"visibility": {},
|
||||||
|
"finish": {},
|
||||||
|
"telemetry": {},
|
||||||
|
"feedback": {}
|
||||||
|
},
|
||||||
|
"resources": {},
|
||||||
|
"debug": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
建议搭配阅读:
|
||||||
|
|
||||||
|
- [规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md)
|
||||||
|
- [游戏配置项](D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md)
|
||||||
|
- [classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
|
||||||
232
doc/games/顺序打点/最小配置模板.md
Normal file
232
doc/games/顺序打点/最小配置模板.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# 顺序打点最小配置模板
|
||||||
|
|
||||||
|
本文档提供一份 **顺序赛(`classic-sequential`)最小可跑配置模板**。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 只保留顺序赛跑通所需的最少字段
|
||||||
|
- 让测试尽量回到程序默认规则本身
|
||||||
|
- 避免历史内容卡、H5 页面、样式覆盖继续干扰联调
|
||||||
|
|
||||||
|
如果你关心“最小模板下系统到底默认怎么跑”,请优先配合阅读:
|
||||||
|
|
||||||
|
- [顺序打点规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md)
|
||||||
|
- [程序默认规则基线](D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 最小模板
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": "1",
|
||||||
|
"version": "2026.03.31",
|
||||||
|
"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,
|
||||||
|
"metadata": {
|
||||||
|
"title": "顺序赛路线示例",
|
||||||
|
"code": "classic-001"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"game": {
|
||||||
|
"mode": "classic-sequential"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 字段说明
|
||||||
|
|
||||||
|
### `schemaVersion`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:配置结构版本
|
||||||
|
- 当前建议值:`"1"`
|
||||||
|
|
||||||
|
### `version`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:配置版本号
|
||||||
|
|
||||||
|
### `app.id`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:活动配置实例 ID
|
||||||
|
|
||||||
|
### `app.title`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:活动标题 / 比赛名称
|
||||||
|
|
||||||
|
### `app.locale`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:语言区域
|
||||||
|
|
||||||
|
### `map.tiles`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:地图瓦片根路径
|
||||||
|
|
||||||
|
### `map.mapmeta`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:地图 meta 文件路径
|
||||||
|
|
||||||
|
### `map.declination`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:磁偏角,未填写时按程序默认值处理
|
||||||
|
|
||||||
|
### `map.initialView.zoom`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:初始缩放级别,未填写时按程序默认视口逻辑处理
|
||||||
|
|
||||||
|
### `playfield.kind`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:空间对象类型
|
||||||
|
- 顺序赛固定使用:`course`
|
||||||
|
|
||||||
|
### `playfield.source.type`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:空间底稿来源类型
|
||||||
|
- 当前推荐值:`kml`
|
||||||
|
|
||||||
|
### `playfield.source.url`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:KML 文件路径
|
||||||
|
|
||||||
|
### `playfield.CPRadius`
|
||||||
|
|
||||||
|
- 类型:`number`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:点位触发半径,未填写时按程序默认值处理
|
||||||
|
|
||||||
|
### `playfield.metadata.title`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:路线名称
|
||||||
|
|
||||||
|
### `playfield.metadata.code`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:路线编码
|
||||||
|
|
||||||
|
### `game.mode`
|
||||||
|
|
||||||
|
- 类型:`string`
|
||||||
|
- 必填:是
|
||||||
|
- 说明:玩法模式
|
||||||
|
- 顺序赛固定值:`classic-sequential`
|
||||||
|
|
||||||
|
### `game.*`
|
||||||
|
|
||||||
|
- 类型:`object`
|
||||||
|
- 必填:否
|
||||||
|
- 说明:最小样例默认不依赖额外玩法覆盖字段
|
||||||
|
- 备注:如需覆盖程序默认值,再补 `punch`、`sequence`、`guidance`、`finish` 等子块
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 当前默认逻辑
|
||||||
|
|
||||||
|
如果你只写本页最小模板,顺序赛会按程序默认规则运行。
|
||||||
|
|
||||||
|
### 3.1 默认局流程
|
||||||
|
|
||||||
|
- 进入游戏后默认只显示开始点
|
||||||
|
- 系统默认提示玩家:需要先打开始点才正式开始比赛
|
||||||
|
- 未打开始点前,普通控制点和终点不生效
|
||||||
|
- 首次成功打开始点后:
|
||||||
|
- 正式开始比赛
|
||||||
|
- 初始化本局数据
|
||||||
|
- 开始计时
|
||||||
|
- 显示全部普通控制点、终点和腿线
|
||||||
|
- 默认只给出“比赛开始”短反馈,并同步更新引导提示和 HUD
|
||||||
|
- 最小模板下默认不弹开始白色内容卡
|
||||||
|
- 最小模板下,点击检查点默认不弹详情卡
|
||||||
|
- 普通控制点默认允许跳点
|
||||||
|
- 普通控制点成功打点后默认:
|
||||||
|
- 先得 `1` 分基础分
|
||||||
|
- 默认不弹题
|
||||||
|
- 如需答题卡,需显式配置对应点位的 `quiz` CTA
|
||||||
|
- 开始点和结束点默认不弹题,只弹提示信息
|
||||||
|
- 成功打结束点后:
|
||||||
|
- 停止计时
|
||||||
|
- 给出完成短反馈
|
||||||
|
- 直接进入默认成绩结算页
|
||||||
|
- 默认不再额外叠加终点白色内容卡
|
||||||
|
- 白色内容卡默认改为显式配置启用;只有某个点位明确配置 `autoPopup = true` 时,完成该点后才会先弹白卡
|
||||||
|
|
||||||
|
### 3.2 默认规则参数
|
||||||
|
|
||||||
|
- `map.declination`
|
||||||
|
- 没配时默认按 `0`
|
||||||
|
- `map.initialView.zoom`
|
||||||
|
- 没配时由客户端初始视口逻辑接管
|
||||||
|
- `playfield.CPRadius`
|
||||||
|
- 没配时按客户端内置值处理
|
||||||
|
- `game.session.*`
|
||||||
|
- 默认非手动开始
|
||||||
|
- 默认要求起点打卡
|
||||||
|
- 默认要求终点打卡
|
||||||
|
- 默认不在最后一个普通点自动结束
|
||||||
|
- `game.sequence.skip.*`
|
||||||
|
- 默认启用跳点
|
||||||
|
- 默认跳点半径 = 打点半径的 2 倍
|
||||||
|
- 默认不需要二次确认
|
||||||
|
- `game.guidance.*`
|
||||||
|
- 默认显示路线腿线
|
||||||
|
- 默认显示腿线动效
|
||||||
|
- 默认不允许手动聚焦选点
|
||||||
|
- `game.visibility.revealFullPlayfieldAfterStartPunch`
|
||||||
|
- 默认按 `true` 处理
|
||||||
|
- `game.finish.finishControlAlwaysSelectable`
|
||||||
|
- 默认按 `false` 处理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 当前样例说明
|
||||||
|
|
||||||
|
当前仓库中的 [classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) 已按这套最小测试口径收敛,只保留地图、场地和玩法标识,不再附带历史测试内容卡、H5 页面和样式覆盖。
|
||||||
|
|
||||||
|
如果要查看公共完整字段,请继续参考:
|
||||||
|
|
||||||
|
- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
||||||
33
doc/games/顺序打点/游戏说明文档.md
Normal file
33
doc/games/顺序打点/游戏说明文档.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 顺序打点游戏说明文档
|
||||||
|
|
||||||
|
本文档作为 `classic-sequential` 的目录入口,用来统一说明本玩法文档放在哪里、分别看什么。
|
||||||
|
|
||||||
|
## 1. 玩法定位
|
||||||
|
|
||||||
|
- 玩法名称:顺序打点
|
||||||
|
- 模式标识:`classic-sequential`
|
||||||
|
- 核心特征:按顺序推进,允许跳点,起终点必须打卡
|
||||||
|
- 当前默认:普通点基础分 `1`,答对题卡再得 `1` 分
|
||||||
|
|
||||||
|
## 2. 本目录结构
|
||||||
|
|
||||||
|
- [规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md)
|
||||||
|
规则、流程、计分、跳点、结算口径
|
||||||
|
- [最小配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最小配置模板.md)
|
||||||
|
最少字段怎么配才能跑起来
|
||||||
|
- [最大配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最大配置模板.md)
|
||||||
|
该玩法当前建议的完整配置骨架入口
|
||||||
|
- [全局配置项](D:/dev/cmr-mini/doc/games/顺序打点/全局配置项.md)
|
||||||
|
跨玩法公共字段在顺序打点下的默认取值
|
||||||
|
- [游戏配置项](D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md)
|
||||||
|
顺序打点独有或强相关字段
|
||||||
|
|
||||||
|
## 3. 运行样例
|
||||||
|
|
||||||
|
- [classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
|
||||||
|
|
||||||
|
## 4. 关联公共文档
|
||||||
|
|
||||||
|
- [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
|
- [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md)
|
||||||
|
- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
||||||
358
doc/games/顺序打点/游戏配置项.md
Normal file
358
doc/games/顺序打点/游戏配置项.md
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
# 顺序打点游戏配置项
|
||||||
|
|
||||||
|
本文档用于汇总当前系统对 `classic-sequential` 的**已支持可配置项**,便于产品、客户端、服务端、后台录入和联调统一对照。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 列出顺序打点当前可配置的字段名
|
||||||
|
- 说明每个字段的作用
|
||||||
|
- 说明字段可选项或取值范围
|
||||||
|
- 标注推荐默认值或当前默认逻辑
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 本文档只聚焦 `classic-sequential`
|
||||||
|
- 以当前实现和现有文档口径为准
|
||||||
|
- 如果你需要看跨玩法的完整字段字典,请继续参考 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 顶层结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": "1",
|
||||||
|
"version": "2026.03.31",
|
||||||
|
"app": {},
|
||||||
|
"map": {},
|
||||||
|
"playfield": {},
|
||||||
|
"game": {},
|
||||||
|
"resources": {},
|
||||||
|
"debug": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 顶层字段
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `schemaVersion` | 配置结构版本 | 当前建议:`"1"` | 建议固定写 `"1"` |
|
||||||
|
| `version` | 当前配置内容版本号 | 任意版本字符串,例如日期版号 | 建议按发布日期维护 |
|
||||||
|
| `app` | 活动级基础信息 | `object` | 必填 |
|
||||||
|
| `map` | 地图底座信息 | `object` | 必填 |
|
||||||
|
| `playfield` | 场地对象、路线和点位内容覆盖 | `object` | 必填 |
|
||||||
|
| `game` | 顺序打点规则与局流程 | `object` | 必填 |
|
||||||
|
| `resources` | 资源 profile 引用 | `object` | 选填 |
|
||||||
|
| `debug` | 调试开关 | `object` | 选填 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `app`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `app.id` | 活动或配置实例 ID | 任意字符串 | 建议全局唯一 |
|
||||||
|
| `app.title` | 活动标题 / 比赛名称 | 任意字符串 | 建议必填 |
|
||||||
|
| `app.locale` | 语言环境 | 当前常用:`zh-CN` | 默认建议:`zh-CN` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `map`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `map.tiles` | 瓦片根路径 | 路径字符串 | 必填 |
|
||||||
|
| `map.mapmeta` | 地图元数据路径 | 路径字符串 | 必填 |
|
||||||
|
| `map.declination` | 磁偏角 | `number` | 未配置时按 `0` 处理 |
|
||||||
|
| `map.initialView.zoom` | 初始缩放级别 | `number` | 默认由客户端初始视口逻辑接管,建议值 `17` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `playfield`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `playfield.kind` | 场地对象类型 | 顺序打点固定使用:`course` | 顺序打点建议固定 `course` |
|
||||||
|
| `playfield.source.type` | 场地来源类型 | 当前支持:`kml` | 建议固定 `kml` |
|
||||||
|
| `playfield.source.url` | KML 路径 | 路径字符串 | 必填 |
|
||||||
|
| `playfield.CPRadius` | 地图上控制点绘制半径 | `number` | 建议默认值:`6` |
|
||||||
|
| `playfield.metadata.title` | 路线标题 | 任意字符串 | 选填 |
|
||||||
|
| `playfield.metadata.code` | 路线编码 | 任意字符串 | 选填 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `playfield.controlOverrides`
|
||||||
|
|
||||||
|
`playfield.controlOverrides` 用于对指定起点、普通控制点、终点做内容和样式覆盖。
|
||||||
|
|
||||||
|
### 6.1 Key 命名
|
||||||
|
|
||||||
|
| Key 模式 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `start-1` | 起点 |
|
||||||
|
| `control-1`、`control-2`、`control-3` | 普通控制点 |
|
||||||
|
| `finish-1` | 终点 |
|
||||||
|
|
||||||
|
### 6.2 内容与交互字段
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `playfield.controlOverrides.<key>.template` | 原生内容卡模板 | `minimal` `story` `focus` | 起点/终点建议 `focus`,普通点建议 `story` |
|
||||||
|
| `playfield.controlOverrides.<key>.title` | 打点后自动弹出的标题 | 任意字符串 | 未配置时走默认文案 |
|
||||||
|
| `playfield.controlOverrides.<key>.body` | 打点后自动弹出的正文 | 任意字符串 | 未配置时走默认文案 |
|
||||||
|
| `playfield.controlOverrides.<key>.clickTitle` | 点击点位时弹出的标题 | 任意字符串 | 最小模板下默认关闭,需显式配置 |
|
||||||
|
| `playfield.controlOverrides.<key>.clickBody` | 点击点位时弹出的正文 | 任意字符串 | 最小模板下默认关闭,需显式配置 |
|
||||||
|
| `playfield.controlOverrides.<key>.autoPopup` | 打点后是否自动弹内容卡 | `true` / `false` | 默认 `true`;自动打点模式下不自动弹内容卡;普通控制点默认题卡仍按玩法规则自动进入 |
|
||||||
|
| `playfield.controlOverrides.<key>.once` | 自动内容是否本局只展示一次 | `true` / `false` | 默认 `false` |
|
||||||
|
| `playfield.controlOverrides.<key>.priority` | 内容优先级 | `number` | 普通点默认 `1`,终点默认 `2` |
|
||||||
|
|
||||||
|
### 6.3 H5 / 原生体验承载字段
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `playfield.controlOverrides.<key>.contentExperience.type` | 打点后内容承载方式 | `native` `h5` | 当前支持两种 |
|
||||||
|
| `playfield.controlOverrides.<key>.contentExperience.url` | 打点后 H5 地址 | URL 字符串 | 仅 `type = "h5"` 时生效 |
|
||||||
|
| `playfield.controlOverrides.<key>.contentExperience.bridge` | 打点后 H5 bridge 版本 | 当前建议:`content-v1` | 默认建议:`content-v1` |
|
||||||
|
| `playfield.controlOverrides.<key>.contentExperience.presentation` | 打点后 H5 展示形态 | `sheet` `dialog` `fullscreen` | 默认建议:`sheet` |
|
||||||
|
| `playfield.controlOverrides.<key>.clickExperience.type` | 点击内容承载方式 | `native` `h5` | 当前支持两种 |
|
||||||
|
| `playfield.controlOverrides.<key>.clickExperience.url` | 点击 H5 地址 | URL 字符串 | 仅 `type = "h5"` 时生效 |
|
||||||
|
| `playfield.controlOverrides.<key>.clickExperience.bridge` | 点击 H5 bridge 版本 | 当前建议:`content-v1` | 默认建议:`content-v1` |
|
||||||
|
| `playfield.controlOverrides.<key>.clickExperience.presentation` | 点击 H5 展示形态 | `sheet` `dialog` `fullscreen` | 默认建议:`sheet` |
|
||||||
|
|
||||||
|
### 6.4 点位样式覆盖字段
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `playfield.controlOverrides.<key>.pointStyle` | 单点样式覆盖 | `classic-ring` `solid-dot` `double-ring` `badge` `pulse-core` | 未配置时回退到玩法样式 |
|
||||||
|
| `playfield.controlOverrides.<key>.pointColorHex` | 单点颜色覆盖 | 十六进制颜色字符串 | 例如 `#27ae60` |
|
||||||
|
| `playfield.controlOverrides.<key>.pointSizeScale` | 单点尺寸倍率 | 建议 `0.6 ~ 1.4` | 默认 `1` |
|
||||||
|
| `playfield.controlOverrides.<key>.pointAccentRingScale` | 单点强调环倍率 | 建议 `1.0 ~ 1.6` | 未配置时回退到玩法样式 |
|
||||||
|
| `playfield.controlOverrides.<key>.pointGlowStrength` | 单点光晕强度 | 建议 `0 ~ 1` | 默认 `0` |
|
||||||
|
| `playfield.controlOverrides.<key>.pointLabelScale` | 单点标签字号倍率 | 建议 `0.7 ~ 1.3` | 默认 `1` |
|
||||||
|
| `playfield.controlOverrides.<key>.pointLabelColorHex` | 单点标签颜色覆盖 | 十六进制颜色字符串 | 例如 `#ffffff` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `playfield.legOverrides`
|
||||||
|
|
||||||
|
`playfield.legOverrides` 用于对指定腿线做局部样式覆盖。
|
||||||
|
|
||||||
|
### 7.1 Key 命名
|
||||||
|
|
||||||
|
| Key 模式 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `leg-1`、`leg-2`、`leg-3` | 指定路线腿段 |
|
||||||
|
|
||||||
|
### 7.2 字段
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `playfield.legOverrides.<key>.style` | 局部腿线样式 | `classic-leg` `dashed-leg` `glow-leg` `progress-leg` | 未配置时回退到玩法样式 |
|
||||||
|
| `playfield.legOverrides.<key>.colorHex` | 局部腿线颜色 | 十六进制颜色字符串 | 例如 `#27ae60` |
|
||||||
|
| `playfield.legOverrides.<key>.widthScale` | 局部腿线宽度倍率 | `number` | 选填 |
|
||||||
|
| `playfield.legOverrides.<key>.glowStrength` | 局部腿线光晕强度 | 建议 `0 ~ 1` | 选填 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `game`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.mode` | 玩法类型 | 顺序打点固定:`classic-sequential` | 建议固定写死 |
|
||||||
|
| `game.rulesVersion` | 规则版本 | 任意字符串,当前建议 `1` | 建议随规则迭代维护 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `game.session`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.session.startManually` | 是否手动开始 | `true` / `false` | 顺序打点默认 `false`,通过起点打卡正式开赛 |
|
||||||
|
| `game.session.requiresStartPunch` | 是否必须打起点 | `true` / `false` | 顺序打点默认 `true` |
|
||||||
|
| `game.session.requiresFinishPunch` | 是否必须打终点 | `true` / `false` | 顺序打点默认 `true` |
|
||||||
|
| `game.session.autoFinishOnLastControl` | 最后一个普通控制点完成后是否自动结束 | `true` / `false` | 顺序打点默认 `false` |
|
||||||
|
| `game.session.maxDurationSec` | 最大比赛时长 | `number` | 建议默认 `5400` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. `game.punch`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.punch.policy` | 打点策略 | `enter-confirm` `enter` | 默认 `enter-confirm` |
|
||||||
|
| `game.punch.radiusMeters` | 打点判定半径 | `number` | 默认建议 `5` |
|
||||||
|
| `game.punch.requiresFocusSelection` | 是否必须先聚焦目标再打点 | `true` / `false` | 顺序打点默认 `false` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. `game.sequence.skip`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.sequence.skip.enabled` | 是否允许跳点 | `true` / `false` | 顺序打点默认 `true` |
|
||||||
|
| `game.sequence.skip.radiusMeters` | 跳点判定半径 | `number` | 顺序打点默认 `game.punch.radiusMeters * 2` |
|
||||||
|
| `game.sequence.skip.requiresConfirm` | 跳点是否需要确认 | `true` / `false` | 顺序打点默认 `false` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. `game.guidance`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.guidance.showLegs` | 是否显示路线腿线 | `true` / `false` | 顺序打点默认 `true` |
|
||||||
|
| `game.guidance.legAnimation` | 是否开启腿线动画 | `true` / `false` | 顺序打点默认 `true` |
|
||||||
|
| `game.guidance.allowFocusSelection` | 是否允许地图点击选点 | `true` / `false` | 顺序打点默认 `false` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. `game.visibility`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.visibility.revealFullPlayfieldAfterStartPunch` | 起点打卡后是否显示完整路线与控制点 | `true` / `false` | 顺序打点默认 `true` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. `game.finish`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.finish.finishControlAlwaysSelectable` | 终点是否始终可生效 | `true` / `false` | 顺序打点默认 `false`,默认要求中间点都已“成功”或“跳过” |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. `game.telemetry.heartRate`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.telemetry.heartRate.age` | 年龄 | `number` | 建议默认 `30` |
|
||||||
|
| `game.telemetry.heartRate.restingHeartRateBpm` | 静息心率 | `number` | 建议默认 `62` |
|
||||||
|
| `game.telemetry.heartRate.userWeightKg` | 体重(kg) | `number` | 建议默认 `65` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. `game.feedback`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.feedback.audioProfile` | 音效反馈配置档 | `string` | 默认 `default` |
|
||||||
|
| `game.feedback.hapticsProfile` | 震动反馈配置档 | `string` | 默认 `default` |
|
||||||
|
| `game.feedback.uiEffectsProfile` | UI 动效配置档 | `string` | 默认 `default` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. `game.presentation.sequential.controls`
|
||||||
|
|
||||||
|
以下字段分别作用于不同控制点状态:`default`、`current`、`completed`、`skipped`、`start`、`finish`。
|
||||||
|
|
||||||
|
### 17.1 状态节点
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.presentation.sequential.controls.default` | 普通未完成控制点样式 | `object` | 建议使用传统定向紫红色 |
|
||||||
|
| `game.presentation.sequential.controls.current` | 当前目标点样式 | `object` | 建议强化动效和光晕 |
|
||||||
|
| `game.presentation.sequential.controls.completed` | 已完成点样式 | `object` | 默认建议灰色态 |
|
||||||
|
| `game.presentation.sequential.controls.skipped` | 已跳过点样式 | `object` | 默认建议橙色态 |
|
||||||
|
| `game.presentation.sequential.controls.start` | 起点样式 | `object` | 建议带明显开赛强调 |
|
||||||
|
| `game.presentation.sequential.controls.finish` | 终点样式 | `object` | 建议带明显收尾强调 |
|
||||||
|
|
||||||
|
### 17.2 每个状态对象内可配置字段
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `<state>.style` | 点位样式 | `classic-ring` `solid-dot` `double-ring` `badge` `pulse-core` | 按状态选择 |
|
||||||
|
| `<state>.colorHex` | 点位主色 | 十六进制颜色字符串 | 例如 `#cc006b` |
|
||||||
|
| `<state>.sizeScale` | 点位尺寸倍率 | `number` | 选填 |
|
||||||
|
| `<state>.accentRingScale` | 强调环倍率 | `number` | 选填 |
|
||||||
|
| `<state>.glowStrength` | 光晕强度 | 建议 `0 ~ 1` | 选填 |
|
||||||
|
| `<state>.labelScale` | 标签尺寸倍率 | `number` | 选填 |
|
||||||
|
| `<state>.labelColorHex` | 标签颜色 | 十六进制颜色字符串 | 选填 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. `game.presentation.sequential.legs`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.presentation.sequential.legs.default.style` | 默认腿线样式 | `classic-leg` `dashed-leg` `glow-leg` `progress-leg` | 建议顺序打点使用 `classic-leg` |
|
||||||
|
| `game.presentation.sequential.legs.default.colorHex` | 默认腿线主色 | 十六进制颜色字符串 | 建议传统定向紫红色 |
|
||||||
|
| `game.presentation.sequential.legs.default.widthScale` | 默认腿线宽度倍率 | `number` | 选填 |
|
||||||
|
| `game.presentation.sequential.legs.default.glowStrength` | 默认腿线光晕强度 | 建议 `0 ~ 1` | 选填 |
|
||||||
|
| `game.presentation.sequential.legs.completed.style` | 已完成腿线样式 | `classic-leg` `dashed-leg` `glow-leg` `progress-leg` | 建议弱化样式 |
|
||||||
|
| `game.presentation.sequential.legs.completed.colorHex` | 已完成腿线颜色 | 十六进制颜色字符串 | 建议灰色系 |
|
||||||
|
| `game.presentation.sequential.legs.completed.widthScale` | 已完成腿线宽度倍率 | `number` | 选填 |
|
||||||
|
| `game.presentation.sequential.legs.completed.glowStrength` | 已完成腿线光晕强度 | 建议 `0 ~ 1` | 选填 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. `game.presentation.track`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.presentation.track.mode` | 轨迹显示模式 | `none` `tail` `full` | 顺序打点默认建议 `full` |
|
||||||
|
| `game.presentation.track.style` | 轨迹风格 | `classic` `neon` | 当前默认 `neon` |
|
||||||
|
| `game.presentation.track.tailLength` | 拖尾长度档位 | `short` `medium` `long` | 仅 `tail` 模式重点使用 |
|
||||||
|
| `game.presentation.track.colorPreset` | 轨迹预设色盘 | `mint` `cyan` `sky` `blue` `violet` `pink` `orange` `yellow` | 未显式配置颜色时可走预设 |
|
||||||
|
| `game.presentation.track.tailMeters` | 拖尾长度(米) | `number` | 可覆盖 `tailLength` |
|
||||||
|
| `game.presentation.track.tailMaxSeconds` | 拖尾时间窗口(秒) | `number` | 选填 |
|
||||||
|
| `game.presentation.track.fadeOutWhenStill` | 静止后是否淡出 | `true` / `false` | 选填 |
|
||||||
|
| `game.presentation.track.stillSpeedKmh` | 静止判定速度阈值 | `number` | 选填 |
|
||||||
|
| `game.presentation.track.fadeOutDurationMs` | 静止淡出时长 | `number` | 选填 |
|
||||||
|
| `game.presentation.track.colorHex` | 轨迹主色 | 十六进制颜色字符串 | 未配置时回退到 `colorPreset` |
|
||||||
|
| `game.presentation.track.headColorHex` | 轨迹头部高亮色 | 十六进制颜色字符串 | 未配置时回退到 `colorPreset` |
|
||||||
|
| `game.presentation.track.widthPx` | 轨迹基础宽度 | `number` | 选填 |
|
||||||
|
| `game.presentation.track.headWidthPx` | 轨迹头部宽度 | `number` | 选填 |
|
||||||
|
| `game.presentation.track.glowStrength` | 轨迹光晕强度 | 建议 `0 ~ 1` | 选填 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. `game.presentation.gpsMarker`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `game.presentation.gpsMarker.visible` | 是否显示 GPS 点 | `true` / `false` | 默认 `true` |
|
||||||
|
| `game.presentation.gpsMarker.style` | GPS 点基础样式 | `dot` `beacon` `disc` `badge` | 当前默认 `beacon` |
|
||||||
|
| `game.presentation.gpsMarker.size` | GPS 点尺寸档位 | `small` `medium` `large` | 当前默认 `medium` |
|
||||||
|
| `game.presentation.gpsMarker.colorPreset` | GPS 点预设色盘 | `mint` `cyan` `sky` `blue` `violet` `pink` `orange` `yellow` | 当前默认 `cyan` |
|
||||||
|
| `game.presentation.gpsMarker.colorHex` | GPS 点主色 | 十六进制颜色字符串 | 未配置时回退到 `colorPreset` |
|
||||||
|
| `game.presentation.gpsMarker.ringColorHex` | GPS 点外环颜色 | 十六进制颜色字符串 | 未配置时回退到 `colorPreset` |
|
||||||
|
| `game.presentation.gpsMarker.indicatorColorHex` | 朝向指示颜色 | 十六进制颜色字符串 | 未配置时回退到 `colorPreset` |
|
||||||
|
| `game.presentation.gpsMarker.showHeadingIndicator` | 是否显示朝向小三角 | `true` / `false` | 默认 `true` |
|
||||||
|
| `game.presentation.gpsMarker.animationProfile` | GPS 点动画 profile | `minimal` `dynamic-runner` `warning-reactive` | 当前默认 `dynamic-runner` |
|
||||||
|
| `game.presentation.gpsMarker.logoUrl` | 中心贴片 logo 地址 | URL 字符串 | 选填 |
|
||||||
|
| `game.presentation.gpsMarker.logoMode` | logo 嵌入方式 | `center-badge` | 当前支持该值 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. `resources`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `resources.audioProfile` | 音效资源档 | `string` | 默认 `default` |
|
||||||
|
| `resources.contentProfile` | 内容资源档 | `string` | 默认 `default` |
|
||||||
|
| `resources.themeProfile` | 主题资源档 | `string` | 默认 `default-race` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 22. `debug`
|
||||||
|
|
||||||
|
| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `debug.allowModeSwitch` | 是否允许玩法切换调试 | `true` / `false` | 默认 `false` |
|
||||||
|
| `debug.allowMockInput` | 是否允许模拟输入 | `true` / `false` | 默认 `false` |
|
||||||
|
| `debug.allowSimulator` | 是否允许模拟器 | `true` / `false` | 默认 `false` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 23. 推荐阅读顺序
|
||||||
|
|
||||||
|
如果你是为了推进顺序打点开发,建议按这个顺序阅读:
|
||||||
|
|
||||||
|
1. [顺序打点规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md)
|
||||||
|
2. 本文档
|
||||||
|
3. [顺序打点最小配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最小配置模板.md)
|
||||||
|
4. [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
||||||
|
5. [顺序赛样例配置](D:/dev/cmr-mini/event/classic-sequential.json)
|
||||||
|
|
||||||
302
doc/games/顺序打点/规则说明文档.md
Normal file
302
doc/games/顺序打点/规则说明文档.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# 顺序打点规则说明文档
|
||||||
|
|
||||||
|
本文档用于定义 `classic-sequential` 在**最小模板**下的系统默认规则,作为后续实现、联调和配置扩展的共同基线。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 明确顺序打点在“不额外写规则字段”时应该怎么跑
|
||||||
|
- 把开局、打点、跳点、答题、结束和结算流程写清楚
|
||||||
|
- 为后续配置化拆分提供规则依据
|
||||||
|
|
||||||
|
如果后续要继续扩写更多正式玩法文档,建议统一使用:
|
||||||
|
|
||||||
|
- [玩法设计文档模板](D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 适用范围
|
||||||
|
|
||||||
|
本文默认规则适用于:
|
||||||
|
|
||||||
|
- `game.mode = "classic-sequential"`
|
||||||
|
- 使用最小顺序赛模板启动
|
||||||
|
- 未显式覆盖开局流程、跳点、答题、结算和默认表现规则
|
||||||
|
|
||||||
|
本文定义的是**系统默认行为**,不是所有字段都必须先配置出来。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 一句话规则
|
||||||
|
|
||||||
|
玩家进入游戏后先看到起点,完成起点打卡后才正式开赛;中间控制点允许跳点;每个成功打到的普通控制点默认先完成基础得分结算;如某点显式配置了答题 CTA,则该点打卡后再进入答题流程;完成终点打卡后结束比赛并进入结算页。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 默认流程总览
|
||||||
|
|
||||||
|
### 3.1 进入游戏
|
||||||
|
|
||||||
|
- 系统进入“待起跑”状态
|
||||||
|
- 地图默认只显示:
|
||||||
|
- 起点
|
||||||
|
- 玩家当前位置
|
||||||
|
- 基础 HUD
|
||||||
|
- 普通控制点、终点、路线和腿线默认不显示
|
||||||
|
- 页面提示玩家:需要先打开始点,比赛才会正式开始并开始计时
|
||||||
|
- 最小模板下,点击检查点默认不弹详情卡
|
||||||
|
|
||||||
|
### 3.2 打开始点
|
||||||
|
|
||||||
|
- 玩家首次成功打开始点后,比赛正式开始
|
||||||
|
- 系统立即初始化本局数据
|
||||||
|
- 系统开始计时
|
||||||
|
- 系统显示全部普通控制点、终点和路线腿线
|
||||||
|
- 默认只给出“比赛开始”短反馈,并同步更新引导提示和 HUD
|
||||||
|
- 最小模板下,开始点完成后默认不弹白色内容卡
|
||||||
|
- 默认关门时间为开赛后 `2` 小时
|
||||||
|
- 当距离关门时间小于等于 `10` 分钟时,HUD 时间区默认切换为倒计时强调样式
|
||||||
|
|
||||||
|
### 3.3 中间控制点推进
|
||||||
|
|
||||||
|
- 玩家按顺序推进普通控制点
|
||||||
|
- 默认允许跳点
|
||||||
|
- 底部 HUD 信息面板默认同步显示当前目标摘要与目标距离
|
||||||
|
- 成功打到普通控制点后:
|
||||||
|
- 该点记为成功
|
||||||
|
- 获得该点基础分
|
||||||
|
- 最小模板下默认不弹答题卡
|
||||||
|
- 如显式配置答题 CTA,则在该点完成后进入答题
|
||||||
|
|
||||||
|
### 3.4 打结束点
|
||||||
|
|
||||||
|
- 当所有中间控制点都已经被标记为“成功”或“跳过”后,终点才可生效
|
||||||
|
- 成功打结束点后:
|
||||||
|
- 系统停止计时
|
||||||
|
- 先给出完成短反馈
|
||||||
|
- 直接进入默认成绩结算页
|
||||||
|
- 默认不再额外叠加终点白色内容卡
|
||||||
|
- 如果超过关门时间仍未完赛,系统按“超时结束”处理
|
||||||
|
- “超时结束”需与正常完赛、主动退出明确区分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 局状态与数据初始化
|
||||||
|
|
||||||
|
### 4.1 默认局状态
|
||||||
|
|
||||||
|
顺序打点默认包含以下状态:
|
||||||
|
|
||||||
|
1. `ready`
|
||||||
|
2. `running`
|
||||||
|
3. `finished`
|
||||||
|
4. `settled`
|
||||||
|
|
||||||
|
### 4.2 起点打卡后的初始化数据
|
||||||
|
|
||||||
|
首次成功打开始点时,系统至少初始化以下运行时数据:
|
||||||
|
|
||||||
|
- `startTime`
|
||||||
|
- `elapsedTime`
|
||||||
|
- `currentTargetIndex`
|
||||||
|
- `completedControls`
|
||||||
|
- `skippedControls`
|
||||||
|
- `visitedControls`
|
||||||
|
- `score`
|
||||||
|
- `baseScore`
|
||||||
|
- `quizBonusScore`
|
||||||
|
- `quizCorrectCount`
|
||||||
|
- `quizWrongCount`
|
||||||
|
- `quizTimeoutCount`
|
||||||
|
- `finishTime`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 打点规则
|
||||||
|
|
||||||
|
### 5.1 开始点
|
||||||
|
|
||||||
|
- 开始点必须打卡
|
||||||
|
- 开始点不弹答题卡
|
||||||
|
- 开始点默认只给短反馈和引导更新
|
||||||
|
- 开始点默认不弹白色内容卡
|
||||||
|
- 未打开始点前进入其他控制点范围,不触发正式打点逻辑
|
||||||
|
|
||||||
|
### 5.2 普通控制点
|
||||||
|
|
||||||
|
- 默认按顺序推进
|
||||||
|
- 当前目标点进入打点半径后可成功打点
|
||||||
|
- 同一点成功后不得重复得分,不得重复出题
|
||||||
|
- 同一点再次进入范围时,只保留已完成状态展示,不重复触发结算
|
||||||
|
|
||||||
|
### 5.3 结束点
|
||||||
|
|
||||||
|
- 结束点必须打卡
|
||||||
|
- 结束点不弹答题卡
|
||||||
|
- 结束点默认只给完成短反馈并直接进入结果页
|
||||||
|
- 结束点默认不弹白色内容卡
|
||||||
|
- 结束点不可被跳过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 跳点规则
|
||||||
|
|
||||||
|
### 6.1 默认是否允许跳点
|
||||||
|
|
||||||
|
- 默认允许跳点
|
||||||
|
|
||||||
|
### 6.2 默认跳点半径
|
||||||
|
|
||||||
|
- 默认跳点半径 = 打点半径的 `2` 倍
|
||||||
|
- 例如打点半径为 `5m` 时,默认跳点半径为 `10m`
|
||||||
|
- 默认点击跳点按钮后先弹出确认框
|
||||||
|
|
||||||
|
### 6.3 默认跳点判定
|
||||||
|
|
||||||
|
- 当玩家尚未完成当前目标点,但进入后续普通控制点的跳点半径时:
|
||||||
|
- 当前目标点标记为“跳过”
|
||||||
|
- 当前进入的后续点允许按“成功到达”处理
|
||||||
|
- 跳点后流程继续向后推进
|
||||||
|
|
||||||
|
### 6.4 跳点结算
|
||||||
|
|
||||||
|
- 被跳过的点不得基础分
|
||||||
|
- 被跳过的点不弹答题卡
|
||||||
|
- 被跳过的点不计入成功打点数
|
||||||
|
|
||||||
|
### 6.5 不可跳点对象
|
||||||
|
|
||||||
|
- 开始点不可跳
|
||||||
|
- 结束点不可跳
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 计分与答题规则
|
||||||
|
|
||||||
|
### 7.1 普通控制点默认分值
|
||||||
|
|
||||||
|
每个普通控制点默认基础分为 `1` 分。
|
||||||
|
|
||||||
|
### 7.2 基础分结算
|
||||||
|
|
||||||
|
- 普通控制点成功打点后,立即获得 `1` 分基础分
|
||||||
|
- 跳过点不得基础分
|
||||||
|
|
||||||
|
### 7.3 默认答题规则
|
||||||
|
|
||||||
|
- 最小模板下默认不启用答题
|
||||||
|
- 如需答题,需显式为对应点位配置 `quiz` CTA
|
||||||
|
- 显式启用后,答题时比赛继续计时,不暂停比赛时间
|
||||||
|
|
||||||
|
### 7.4 答题结算
|
||||||
|
|
||||||
|
- 最小模板下默认不存在答题奖励分
|
||||||
|
- 如显式启用答题,则按该点题卡配置进行奖励结算
|
||||||
|
|
||||||
|
### 7.5 不弹题对象
|
||||||
|
|
||||||
|
- 开始点不弹题
|
||||||
|
- 结束点不弹题
|
||||||
|
- 跳过点不弹题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 地图与表现默认规则
|
||||||
|
|
||||||
|
### 8.1 起跑前显示
|
||||||
|
|
||||||
|
- 起跑前仅显示开始点
|
||||||
|
- 其他普通控制点、终点和腿线隐藏
|
||||||
|
|
||||||
|
### 8.2 起跑后显示
|
||||||
|
|
||||||
|
- 打完开始点后显示全部普通控制点、终点和路线腿线
|
||||||
|
- 当前目标点需要有明确强调态
|
||||||
|
- 底部 HUD 信息面板默认显示当前目标点摘要,并随推进自动更新
|
||||||
|
|
||||||
|
### 8.3 点位默认表现
|
||||||
|
|
||||||
|
- 默认路线和控制点主色参考传统定向运动紫红色系
|
||||||
|
- 开始点、结束点、当前目标点都应带动效
|
||||||
|
- 已成功点默认变灰
|
||||||
|
- 已跳过点使用另一套默认颜色,建议为偏橙色系,避免与已完成点混淆
|
||||||
|
|
||||||
|
### 8.4 腿线默认表现
|
||||||
|
|
||||||
|
- 默认显示腿线
|
||||||
|
- 腿线带电流动效
|
||||||
|
- 重点强调当前目标腿线
|
||||||
|
- 已完成腿线弱化显示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 反馈与结算页默认规则
|
||||||
|
|
||||||
|
### 9.1 开始反馈
|
||||||
|
|
||||||
|
- 开始点成功后默认给出“比赛开始”类短反馈
|
||||||
|
- 同时更新黑色引导提示条和 HUD 当前目标摘要
|
||||||
|
- 最小模板下默认不弹开始白卡
|
||||||
|
- 如需开始点完成后展示白色内容卡,需显式开启对应点位的 `autoPopup = true`
|
||||||
|
|
||||||
|
### 9.2 结束反馈
|
||||||
|
|
||||||
|
- 结束点成功后默认先给短反馈
|
||||||
|
- 随后直接进入默认成绩结算页
|
||||||
|
- 默认不再额外弹出终点白色内容卡
|
||||||
|
- 如需再次查看终点说明,需显式配置点击内容能力
|
||||||
|
|
||||||
|
### 9.3 默认成绩结算页
|
||||||
|
|
||||||
|
默认结算页至少显示:
|
||||||
|
|
||||||
|
- 总用时
|
||||||
|
- 总分
|
||||||
|
- 基础分
|
||||||
|
- 答题奖励分
|
||||||
|
- 成功打点数
|
||||||
|
- 跳过点数
|
||||||
|
- 答题正确数
|
||||||
|
- 答题错误数
|
||||||
|
- 答题超时数
|
||||||
|
- 是否完赛
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 边界与容错默认规则
|
||||||
|
|
||||||
|
### 10.1 GPS 防重复触发
|
||||||
|
|
||||||
|
- 同一点成功结算后,系统应有短暂防抖或冷却,避免 GPS 抖动导致重复触发
|
||||||
|
|
||||||
|
### 10.2 重复进入已完成点
|
||||||
|
|
||||||
|
- 已完成点再次进入范围时不重复得分、不重复答题、不重复弹完成提示
|
||||||
|
|
||||||
|
### 10.3 重复进入已跳过点
|
||||||
|
|
||||||
|
- 已跳过点再次进入范围时保持“跳过”状态,不回补得分或答题
|
||||||
|
|
||||||
|
### 10.4 页面中断恢复
|
||||||
|
|
||||||
|
- 小程序切后台或页面重进后,默认应恢复当前比赛状态
|
||||||
|
- 已开始的比赛默认不应自动重开
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 与最小模板的关系
|
||||||
|
|
||||||
|
顺序赛最小模板只需要保证以下骨架存在:
|
||||||
|
|
||||||
|
- `playfield.kind = "course"`
|
||||||
|
- `game.mode = "classic-sequential"`
|
||||||
|
- `game.punch.policy`
|
||||||
|
- `game.punch.radiusMeters`
|
||||||
|
|
||||||
|
其余流程默认按本文档执行。
|
||||||
|
|
||||||
|
也就是说,本文档定义的是:
|
||||||
|
|
||||||
|
- 最小模板下的系统默认局流程
|
||||||
|
- 后续配置化扩展前的默认产品行为
|
||||||
|
- 顺序赛样例配置和实现验收时的基准口径
|
||||||
|
|
||||||
40
doc/文档索引.md
40
doc/文档索引.md
@@ -1,11 +1,42 @@
|
|||||||
# 文档索引
|
# 文档索引
|
||||||
|
|
||||||
## 配置
|
## 按游戏分类
|
||||||
|
|
||||||
|
### 顺序打点
|
||||||
|
|
||||||
|
- [游戏说明文档](/D:/dev/cmr-mini/doc/games/顺序打点/游戏说明文档.md)
|
||||||
|
- [规则说明文档](/D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md)
|
||||||
|
- [最小配置模板](/D:/dev/cmr-mini/doc/games/顺序打点/最小配置模板.md)
|
||||||
|
- [最大配置模板](/D:/dev/cmr-mini/doc/games/顺序打点/最大配置模板.md)
|
||||||
|
- [全局配置项](/D:/dev/cmr-mini/doc/games/顺序打点/全局配置项.md)
|
||||||
|
- [游戏配置项](/D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md)
|
||||||
|
|
||||||
|
### 积分赛
|
||||||
|
|
||||||
|
- [游戏说明文档](/D:/dev/cmr-mini/doc/games/积分赛/游戏说明文档.md)
|
||||||
|
- [规则说明文档](/D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md)
|
||||||
|
- [最小配置模板](/D:/dev/cmr-mini/doc/games/积分赛/最小配置模板.md)
|
||||||
|
- [最大配置模板](/D:/dev/cmr-mini/doc/games/积分赛/最大配置模板.md)
|
||||||
|
- [全局配置项](/D:/dev/cmr-mini/doc/games/积分赛/全局配置项.md)
|
||||||
|
- [游戏配置项](/D:/dev/cmr-mini/doc/games/积分赛/游戏配置项.md)
|
||||||
|
|
||||||
|
## 公共配置
|
||||||
|
|
||||||
- [配置文档索引](/D:/dev/cmr-mini/doc/config/配置文档索引.md)
|
- [配置文档索引](/D:/dev/cmr-mini/doc/config/配置文档索引.md)
|
||||||
- [配置选项字典](/D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
- [配置选项字典](/D:/dev/cmr-mini/doc/config/配置选项字典.md)
|
||||||
|
- [配置分级总表](/D:/dev/cmr-mini/doc/config/配置分级总表.md)
|
||||||
|
- [全局规则与配置维度清单](/D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md)
|
||||||
- [当前最全配置模板](/D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
- [当前最全配置模板](/D:/dev/cmr-mini/doc/config/当前最全配置模板.md)
|
||||||
|
|
||||||
|
## 通用玩法设计
|
||||||
|
|
||||||
|
- [玩法构想方案](/D:/dev/cmr-mini/doc/gameplay/玩法构想方案.md)
|
||||||
|
- [程序默认规则基线](/D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md)
|
||||||
|
- [游戏规则架构](/D:/dev/cmr-mini/doc/gameplay/游戏规则架构.md)
|
||||||
|
- [故障恢复机制](/D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md)
|
||||||
|
- [运行时编译层总表](/D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md)
|
||||||
|
- [玩法设计文档模板](/D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md)
|
||||||
|
|
||||||
## 动画
|
## 动画
|
||||||
|
|
||||||
- [动画管线阶段总结](/D:/dev/cmr-mini/doc/animation/动画管线总结.md)
|
- [动画管线阶段总结](/D:/dev/cmr-mini/doc/animation/动画管线总结.md)
|
||||||
@@ -32,12 +63,7 @@
|
|||||||
|
|
||||||
- [网关文档索引](/D:/dev/cmr-mini/doc/gateway/网关文档索引.md)
|
- [网关文档索引](/D:/dev/cmr-mini/doc/gateway/网关文档索引.md)
|
||||||
|
|
||||||
## 玩法
|
|
||||||
|
|
||||||
- [玩法构想方案](/D:/dev/cmr-mini/doc/gameplay/玩法构想方案.md)
|
|
||||||
|
|
||||||
## 备注与归档
|
## 备注与归档
|
||||||
|
|
||||||
- 长期保留的少量工作便签见 [notes](/D:/dev/cmr-mini/doc/notes)。
|
- 长期保留的少量工作便签见 [notes](/D:/dev/cmr-mini/doc/notes)。
|
||||||
- 历史方案稿和阶段性讨论稿已移到 [archive](/D:/dev/cmr-mini/doc/archive/归档索引.md)。
|
- 历史方案稿和阶段性讨论稿已移到 [archive](/D:/dev/cmr-mini/doc/archive/归档索引.md)。
|
||||||
- 正式阅读建议优先从本页和配置索引进入,不再直接平铺浏览全部文档。
|
|
||||||
|
|||||||
@@ -1,300 +1,22 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": "1",
|
"schemaVersion": "1",
|
||||||
"version": "2026.03.25",
|
"version": "2026.04.01",
|
||||||
"app": {
|
"app": {
|
||||||
"id": "sample-classic-001",
|
"id": "sample-classic-001",
|
||||||
"title": "顺序赛示例",
|
"title": "顺序赛示例"
|
||||||
"locale": "zh-CN"
|
|
||||||
},
|
},
|
||||||
"map": {
|
"map": {
|
||||||
"tiles": "../map/lxcb-001/tiles/",
|
"tiles": "../map/lxcb-001/tiles/",
|
||||||
"mapmeta": "../map/lxcb-001/tiles/meta.json",
|
"mapmeta": "../map/lxcb-001/tiles/meta.json"
|
||||||
"declination": 6.91,
|
|
||||||
"initialView": {
|
|
||||||
"zoom": 17
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"playfield": {
|
"playfield": {
|
||||||
"kind": "course",
|
"kind": "course",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "kml",
|
"type": "kml",
|
||||||
"url": "../kml/lxcb-001/10/c01.kml"
|
"url": "../kml/lxcb-001/10/c01.kml"
|
||||||
},
|
|
||||||
"CPRadius": 6,
|
|
||||||
"controlOverrides": {
|
|
||||||
"start-1": {
|
|
||||||
"template": "focus",
|
|
||||||
"title": "比赛开始",
|
|
||||||
"body": "从这里出发,先熟悉地图方向,再推进到第一个目标点。点击“查看详情”可打开 H5 详情页。",
|
|
||||||
"autoPopup": true,
|
|
||||||
"once": true,
|
|
||||||
"priority": 1,
|
|
||||||
"ctas": [
|
|
||||||
{
|
|
||||||
"type": "quiz",
|
|
||||||
"label": "答题加分",
|
|
||||||
"bonusScore": 1,
|
|
||||||
"countdownSeconds": 10,
|
|
||||||
"minValue": 10,
|
|
||||||
"maxValue": 99,
|
|
||||||
"allowSubtraction": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"contentExperience": {
|
|
||||||
"type": "h5",
|
|
||||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
|
||||||
"bridge": "content-v1",
|
|
||||||
"presentation": "dialog"
|
|
||||||
},
|
|
||||||
"clickTitle": "起点说明",
|
|
||||||
"clickBody": "点击起点可再次查看起跑说明与路线背景。",
|
|
||||||
"clickExperience": {
|
|
||||||
"type": "h5",
|
|
||||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
|
||||||
"bridge": "content-v1",
|
|
||||||
"presentation": "dialog"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"control-1": {
|
|
||||||
"template": "story",
|
|
||||||
"title": "图书馆前广场",
|
|
||||||
"body": "这是第一检查点,完成后沿主路继续前进。卡片先原生弹出,再可进入 H5 详情。",
|
|
||||||
"autoPopup": true,
|
|
||||||
"once": false,
|
|
||||||
"priority": 1,
|
|
||||||
"ctas": [
|
|
||||||
{
|
|
||||||
"type": "photo",
|
|
||||||
"label": "拍照打卡"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "detail",
|
|
||||||
"label": "查看详情"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"clickTitle": "图书馆前广场",
|
|
||||||
"clickBody": "这里是顺序赛的首个关键点位,适合确认路线方向。",
|
|
||||||
"contentExperience": {
|
|
||||||
"type": "h5",
|
|
||||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
|
||||||
"bridge": "content-v1",
|
|
||||||
"presentation": "dialog"
|
|
||||||
},
|
|
||||||
"clickExperience": {
|
|
||||||
"type": "h5",
|
|
||||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
|
||||||
"bridge": "content-v1",
|
|
||||||
"presentation": "dialog"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"control-2": {
|
|
||||||
"template": "minimal",
|
|
||||||
"pointStyle": "badge",
|
|
||||||
"pointColorHex": "#27ae60",
|
|
||||||
"pointSizeScale": 0.92,
|
|
||||||
"pointAccentRingScale": 1.1,
|
|
||||||
"pointLabelScale": 0.94,
|
|
||||||
"pointLabelColorHex": "#ffffff",
|
|
||||||
"title": "教学楼南侧",
|
|
||||||
"body": "注意这里地形开阔,适合快速判断下一段方向。这个点配置成手动查看后可进 H5。",
|
|
||||||
"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",
|
|
||||||
"presentation": "dialog"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"control-3": {
|
|
||||||
"template": "story",
|
|
||||||
"title": "湖边步道",
|
|
||||||
"body": "经过这里时可以观察水边和林带的边界关系。",
|
|
||||||
"autoPopup": true,
|
|
||||||
"once": false,
|
|
||||||
"priority": 1,
|
|
||||||
"clickTitle": "湖边步道",
|
|
||||||
"clickBody": "点击可查看更详细的路线观察建议。"
|
|
||||||
},
|
|
||||||
"finish-1": {
|
|
||||||
"template": "focus",
|
|
||||||
"title": "终点到达",
|
|
||||||
"body": "恭喜完成本次顺序赛,准备查看结果。这里也保留 H5 详情入口用于测试。",
|
|
||||||
"autoPopup": true,
|
|
||||||
"once": true,
|
|
||||||
"priority": 2,
|
|
||||||
"ctas": [
|
|
||||||
{
|
|
||||||
"type": "audio",
|
|
||||||
"label": "语音留言"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "detail",
|
|
||||||
"label": "查看详情"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"clickTitle": "终点说明",
|
|
||||||
"clickBody": "点击终点可再次查看本局结束说明。",
|
|
||||||
"clickExperience": {
|
|
||||||
"type": "h5",
|
|
||||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
|
||||||
"bridge": "content-v1",
|
|
||||||
"presentation": "dialog"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"legOverrides": {
|
|
||||||
"leg-2": {
|
|
||||||
"style": "glow-leg",
|
|
||||||
"colorHex": "#27ae60",
|
|
||||||
"widthScale": 1.12,
|
|
||||||
"glowStrength": 0.82
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"title": "顺序赛路线示例",
|
|
||||||
"code": "classic-001"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"mode": "classic-sequential",
|
"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
|
|
||||||
},
|
|
||||||
"presentation": {
|
|
||||||
"track": {
|
|
||||||
"mode": "full",
|
|
||||||
"style": "neon",
|
|
||||||
"tailLength": "medium",
|
|
||||||
"colorPreset": "mint",
|
|
||||||
"colorHex": "#176d5d",
|
|
||||||
"headColorHex": "#54f3d8",
|
|
||||||
"widthPx": 4.2,
|
|
||||||
"headWidthPx": 6.6,
|
|
||||||
"glowStrength": 0.18
|
|
||||||
},
|
|
||||||
"gpsMarker": {
|
|
||||||
"visible": true,
|
|
||||||
"style": "beacon",
|
|
||||||
"size": "medium",
|
|
||||||
"colorPreset": "cyan",
|
|
||||||
"showHeadingIndicator": true,
|
|
||||||
"logoUrl": "https://oss-mbh5.colormaprun.com/gotomars/test/me.jpg",
|
|
||||||
"logoMode": "center-badge"
|
|
||||||
},
|
|
||||||
"sequential": {
|
|
||||||
"controls": {
|
|
||||||
"default": {
|
|
||||||
"style": "classic-ring",
|
|
||||||
"colorHex": "#cc006b",
|
|
||||||
"sizeScale": 1,
|
|
||||||
"labelScale": 1
|
|
||||||
},
|
|
||||||
"current": {
|
|
||||||
"style": "pulse-core",
|
|
||||||
"colorHex": "#38fff2",
|
|
||||||
"sizeScale": 1.1,
|
|
||||||
"accentRingScale": 1.32,
|
|
||||||
"glowStrength": 0.95,
|
|
||||||
"labelScale": 1.08,
|
|
||||||
"labelColorHex": "#fff8fb"
|
|
||||||
},
|
|
||||||
"completed": {
|
|
||||||
"style": "solid-dot",
|
|
||||||
"colorHex": "#7e838a",
|
|
||||||
"sizeScale": 0.88,
|
|
||||||
"labelScale": 0.94
|
|
||||||
},
|
|
||||||
"skipped": {
|
|
||||||
"style": "badge",
|
|
||||||
"colorHex": "#8a9198",
|
|
||||||
"sizeScale": 0.9,
|
|
||||||
"accentRingScale": 1.12,
|
|
||||||
"labelScale": 0.96
|
|
||||||
},
|
|
||||||
"start": {
|
|
||||||
"style": "double-ring",
|
|
||||||
"colorHex": "#2f80ed",
|
|
||||||
"sizeScale": 1.04,
|
|
||||||
"accentRingScale": 1.28,
|
|
||||||
"labelScale": 1.02
|
|
||||||
},
|
|
||||||
"finish": {
|
|
||||||
"style": "double-ring",
|
|
||||||
"colorHex": "#f2994a",
|
|
||||||
"sizeScale": 1.08,
|
|
||||||
"accentRingScale": 1.34,
|
|
||||||
"glowStrength": 0.3,
|
|
||||||
"labelScale": 1.06,
|
|
||||||
"labelColorHex": "#fff4de"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"legs": {
|
|
||||||
"default": {
|
|
||||||
"style": "dashed-leg",
|
|
||||||
"colorHex": "#cc006b",
|
|
||||||
"widthScale": 1
|
|
||||||
},
|
|
||||||
"completed": {
|
|
||||||
"style": "progress-leg",
|
|
||||||
"colorHex": "#7a8088",
|
|
||||||
"widthScale": 0.92,
|
|
||||||
"glowStrength": 0.22
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,324 +1,22 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": "1",
|
"schemaVersion": "1",
|
||||||
"version": "2026.03.25",
|
"version": "2026.04.01",
|
||||||
"app": {
|
"app": {
|
||||||
"id": "sample-score-o-001",
|
"id": "sample-score-o-001",
|
||||||
"title": "积分赛示例",
|
"title": "积分赛示例"
|
||||||
"locale": "zh-CN"
|
|
||||||
},
|
},
|
||||||
"map": {
|
"map": {
|
||||||
"tiles": "../map/lxcb-001/tiles/",
|
"tiles": "../map/lxcb-001/tiles/",
|
||||||
"mapmeta": "../map/lxcb-001/tiles/meta.json",
|
"mapmeta": "../map/lxcb-001/tiles/meta.json"
|
||||||
"declination": 6.91,
|
|
||||||
"initialView": {
|
|
||||||
"zoom": 17
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"playfield": {
|
"playfield": {
|
||||||
"kind": "control-set",
|
"kind": "control-set",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "kml",
|
"type": "kml",
|
||||||
"url": "../kml/lxcb-001/10/c01.kml"
|
"url": "../kml/lxcb-001/10/c01.kml"
|
||||||
},
|
|
||||||
"CPRadius": 6,
|
|
||||||
"controlOverrides": {
|
|
||||||
"start-1": {
|
|
||||||
"template": "focus",
|
|
||||||
"title": "比赛开始",
|
|
||||||
"body": "从这里触发,先熟悉地图方向。原生内容卡会先弹出,再可进入 H5 详情。",
|
|
||||||
"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",
|
|
||||||
"presentation": "dialog"
|
|
||||||
},
|
|
||||||
"clickTitle": "积分赛起点",
|
|
||||||
"clickBody": "点击起点可查看自由打点规则与终点说明。",
|
|
||||||
"clickExperience": {
|
|
||||||
"type": "h5",
|
|
||||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
|
||||||
"bridge": "content-v1",
|
|
||||||
"presentation": "dialog"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"control-1": {
|
|
||||||
"template": "minimal",
|
|
||||||
"score": 10,
|
|
||||||
"clickTitle": "1号点",
|
|
||||||
"clickBody": "这是一个基础积分点,适合作为开局热身。",
|
|
||||||
"clickExperience": {
|
|
||||||
"type": "h5",
|
|
||||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
|
||||||
"bridge": "content-v1",
|
|
||||||
"presentation": "dialog"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"control-2": {
|
|
||||||
"template": "minimal",
|
|
||||||
"score": 20,
|
|
||||||
"title": "2号点",
|
|
||||||
"body": "这个点配置成手动查看。点击“查看内容”后先出原生卡,再可进入 H5。",
|
|
||||||
"autoPopup": false,
|
|
||||||
"once": true,
|
|
||||||
"priority": 1,
|
|
||||||
"ctas": [
|
|
||||||
{
|
|
||||||
"type": "quiz",
|
|
||||||
"label": "答题加分",
|
|
||||||
"bonusScore": 8,
|
|
||||||
"countdownSeconds": 12,
|
|
||||||
"minValue": 20,
|
|
||||||
"maxValue": 199,
|
|
||||||
"allowSubtraction": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"clickTitle": "2号点",
|
|
||||||
"clickBody": "这个点配置成点击查看,经过时不会自动弹。",
|
|
||||||
"clickExperience": {
|
|
||||||
"type": "h5",
|
|
||||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
|
||||||
"bridge": "content-v1",
|
|
||||||
"presentation": "dialog"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"control-3": {
|
|
||||||
"template": "story",
|
|
||||||
"score": 30,
|
|
||||||
"title": "湖边步道",
|
|
||||||
"body": "这里适合短暂停留观察周边地形。自动弹原生内容卡,并提供 H5 详情入口。",
|
|
||||||
"autoPopup": true,
|
|
||||||
"once": false,
|
|
||||||
"priority": 1,
|
|
||||||
"ctas": [
|
|
||||||
{
|
|
||||||
"type": "photo",
|
|
||||||
"label": "拍照打卡"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "detail",
|
|
||||||
"label": "查看详情"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"clickTitle": "湖边步道",
|
|
||||||
"clickBody": "点击可查看这一区域的补充说明。",
|
|
||||||
"contentExperience": {
|
|
||||||
"type": "h5",
|
|
||||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
|
||||||
"bridge": "content-v1",
|
|
||||||
"presentation": "dialog"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"control-4": {
|
|
||||||
"score": 40
|
|
||||||
},
|
|
||||||
"control-5": {
|
|
||||||
"score": 50
|
|
||||||
},
|
|
||||||
"control-6": {
|
|
||||||
"template": "focus",
|
|
||||||
"score": 60,
|
|
||||||
"pointStyle": "pulse-core",
|
|
||||||
"pointColorHex": "#ff4d6d",
|
|
||||||
"pointSizeScale": 1.18,
|
|
||||||
"pointAccentRingScale": 1.36,
|
|
||||||
"pointGlowStrength": 1,
|
|
||||||
"pointLabelScale": 1.12,
|
|
||||||
"pointLabelColorHex": "#fff9fb",
|
|
||||||
"title": "悬崖边",
|
|
||||||
"body": "这里很危险啊。",
|
|
||||||
"autoPopup": true,
|
|
||||||
"once": true,
|
|
||||||
"priority": 2,
|
|
||||||
"clickTitle": "悬崖边",
|
|
||||||
"clickBody": "点击查看地形风险提示。"
|
|
||||||
},
|
|
||||||
"control-7": {
|
|
||||||
"score": 70
|
|
||||||
},
|
|
||||||
"control-8": {
|
|
||||||
"score": 80
|
|
||||||
},
|
|
||||||
"finish-1": {
|
|
||||||
"template": "focus",
|
|
||||||
"title": "比赛结束",
|
|
||||||
"body": "恭喜完成本次路线,准备查看结果。这里也保留 H5 详情入口用于测试。",
|
|
||||||
"autoPopup": true,
|
|
||||||
"once": true,
|
|
||||||
"priority": 2,
|
|
||||||
"ctas": [
|
|
||||||
{
|
|
||||||
"type": "audio",
|
|
||||||
"label": "语音留言"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"clickTitle": "终点说明",
|
|
||||||
"clickBody": "点击终点可再次查看结束与结算提示。",
|
|
||||||
"clickExperience": {
|
|
||||||
"type": "h5",
|
|
||||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
|
||||||
"bridge": "content-v1",
|
|
||||||
"presentation": "dialog"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"title": "积分赛控制点示例(2 起终点 + 8 积分点)",
|
|
||||||
"code": "score-o-001"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"mode": "score-o",
|
"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
|
|
||||||
},
|
|
||||||
"presentation": {
|
|
||||||
"track": {
|
|
||||||
"mode": "tail",
|
|
||||||
"style": "neon",
|
|
||||||
"tailLength": "long",
|
|
||||||
"colorPreset": "cyan",
|
|
||||||
"tailMeters": 60,
|
|
||||||
"tailMaxSeconds": 30,
|
|
||||||
"fadeOutWhenStill": true,
|
|
||||||
"stillSpeedKmh": 0.6,
|
|
||||||
"fadeOutDurationMs": 3000,
|
|
||||||
"colorHex": "#149a86",
|
|
||||||
"headColorHex": "#62fff0",
|
|
||||||
"widthPx": 3.8,
|
|
||||||
"headWidthPx": 6.8,
|
|
||||||
"glowStrength": 0.32
|
|
||||||
},
|
|
||||||
"gpsMarker": {
|
|
||||||
"visible": true,
|
|
||||||
"style": "beacon",
|
|
||||||
"size": "medium",
|
|
||||||
"colorPreset": "cyan",
|
|
||||||
"showHeadingIndicator": true,
|
|
||||||
"logoUrl": "https://oss-mbh5.colormaprun.com/gotomars/test/me.jpg",
|
|
||||||
"logoMode": "center-badge"
|
|
||||||
},
|
|
||||||
"scoreO": {
|
|
||||||
"controls": {
|
|
||||||
"default": {
|
|
||||||
"style": "badge",
|
|
||||||
"colorHex": "#cc006b",
|
|
||||||
"sizeScale": 0.96,
|
|
||||||
"accentRingScale": 1.1,
|
|
||||||
"labelScale": 1.02
|
|
||||||
},
|
|
||||||
"focused": {
|
|
||||||
"style": "pulse-core",
|
|
||||||
"colorHex": "#fff0fa",
|
|
||||||
"sizeScale": 1.12,
|
|
||||||
"accentRingScale": 1.36,
|
|
||||||
"glowStrength": 1,
|
|
||||||
"labelScale": 1.14,
|
|
||||||
"labelColorHex": "#fffafc"
|
|
||||||
},
|
|
||||||
"collected": {
|
|
||||||
"style": "solid-dot",
|
|
||||||
"colorHex": "#d6dae0",
|
|
||||||
"sizeScale": 0.82,
|
|
||||||
"labelScale": 0.92
|
|
||||||
},
|
|
||||||
"start": {
|
|
||||||
"style": "double-ring",
|
|
||||||
"colorHex": "#2f80ed",
|
|
||||||
"sizeScale": 1.02,
|
|
||||||
"accentRingScale": 1.24,
|
|
||||||
"labelScale": 1.02
|
|
||||||
},
|
|
||||||
"finish": {
|
|
||||||
"style": "double-ring",
|
|
||||||
"colorHex": "#f2994a",
|
|
||||||
"sizeScale": 1.06,
|
|
||||||
"accentRingScale": 1.28,
|
|
||||||
"glowStrength": 0.26,
|
|
||||||
"labelScale": 1.04,
|
|
||||||
"labelColorHex": "#fff4de"
|
|
||||||
},
|
|
||||||
"scoreBands": [
|
|
||||||
{
|
|
||||||
"min": 0,
|
|
||||||
"max": 19,
|
|
||||||
"style": "badge",
|
|
||||||
"colorHex": "#56ccf2",
|
|
||||||
"sizeScale": 0.88,
|
|
||||||
"accentRingScale": 1.06,
|
|
||||||
"labelScale": 0.92
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"min": 20,
|
|
||||||
"max": 49,
|
|
||||||
"style": "badge",
|
|
||||||
"colorHex": "#f2c94c",
|
|
||||||
"sizeScale": 1.02,
|
|
||||||
"accentRingScale": 1.18,
|
|
||||||
"labelScale": 1.02
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"min": 50,
|
|
||||||
"max": 999999,
|
|
||||||
"style": "badge",
|
|
||||||
"colorHex": "#eb5757",
|
|
||||||
"sizeScale": 1.14,
|
|
||||||
"accentRingScale": 1.32,
|
|
||||||
"glowStrength": 0.72,
|
|
||||||
"labelScale": 1.1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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 @@
|
|||||||
// app.ts
|
// app.ts
|
||||||
App<IAppOption>({
|
App<IAppOption>({
|
||||||
globalData: {},
|
globalData: {
|
||||||
|
telemetryPlayerProfile: null,
|
||||||
|
},
|
||||||
onLaunch() {
|
onLaunch() {
|
||||||
// 展示本地存储能力
|
// 展示本地存储能力
|
||||||
const logs = wx.getStorageSync('logs') || []
|
const logs = wx.getStorageSync('logs') || []
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -126,7 +126,7 @@ export class CourseLabelRenderer {
|
|||||||
const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO)
|
const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO)
|
||||||
const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO)
|
const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO)
|
||||||
|
|
||||||
if (scene.controlVisualMode === 'multi-target') {
|
if (scene.gameMode === 'score-o' || scene.controlVisualMode === 'multi-target') {
|
||||||
ctx.textAlign = 'center'
|
ctx.textAlign = 'center'
|
||||||
ctx.textBaseline = 'middle'
|
ctx.textBaseline = 'middle'
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ export class CourseLabelRenderer {
|
|||||||
ctx.fillStyle = this.getScoreLabelColor(scene, control.sequence)
|
ctx.fillStyle = this.getScoreLabelColor(scene, control.sequence)
|
||||||
ctx.translate(control.point.x, control.point.y)
|
ctx.translate(control.point.x, control.point.y)
|
||||||
ctx.rotate(scene.rotationRad)
|
ctx.rotate(scene.rotationRad)
|
||||||
ctx.fillText(String(control.sequence), 0, scoreOffsetY)
|
ctx.fillText(this.getControlLabelText(scene, control.sequence), 0, scoreOffsetY)
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -388,6 +388,16 @@ export class CourseLabelRenderer {
|
|||||||
: rgbaToCss(resolvedStyle.color, 0.98)
|
: rgbaToCss(resolvedStyle.color, 0.98)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getControlLabelText(scene: MapScene, sequence: number): string {
|
||||||
|
if (scene.gameMode === 'score-o') {
|
||||||
|
const score = scene.controlScoresBySequence[sequence]
|
||||||
|
if (typeof score === 'number' && Number.isFinite(score)) {
|
||||||
|
return String(score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(sequence)
|
||||||
|
}
|
||||||
|
|
||||||
clearCanvas(ctx: any): void {
|
clearCanvas(ctx: any): void {
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||||
|
|||||||
@@ -13,6 +13,47 @@ export interface ResolvedLegStyle {
|
|||||||
color: RgbaColor
|
color: RgbaColor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCompletedBoundaryEntry(scene: MapScene, baseEntry: ControlPointStyleEntry): ControlPointStyleEntry {
|
||||||
|
const completedPalette = scene.gameMode === 'score-o'
|
||||||
|
? scene.courseStyleConfig.scoreO.controls.collected
|
||||||
|
: scene.courseStyleConfig.sequential.controls.completed
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseEntry,
|
||||||
|
colorHex: completedPalette.colorHex,
|
||||||
|
labelColorHex: completedPalette.labelColorHex || baseEntry.labelColorHex,
|
||||||
|
glowStrength: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeControlStyleEntries(
|
||||||
|
baseEntry: ControlPointStyleEntry,
|
||||||
|
overrideEntry?: ControlPointStyleEntry | null,
|
||||||
|
): ControlPointStyleEntry {
|
||||||
|
if (!overrideEntry) {
|
||||||
|
return baseEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseEntry,
|
||||||
|
...overrideEntry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeLegStyleEntries(
|
||||||
|
baseEntry: CourseLegStyleEntry,
|
||||||
|
overrideEntry?: CourseLegStyleEntry | null,
|
||||||
|
): CourseLegStyleEntry {
|
||||||
|
if (!overrideEntry) {
|
||||||
|
return baseEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseEntry,
|
||||||
|
...overrideEntry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function hexToRgbaColor(hex: string, alphaOverride?: number): RgbaColor {
|
export function hexToRgbaColor(hex: string, alphaOverride?: number): RgbaColor {
|
||||||
const fallback: RgbaColor = [1, 1, 1, alphaOverride !== undefined ? alphaOverride : 1]
|
const fallback: RgbaColor = [1, 1, 1, alphaOverride !== undefined ? alphaOverride : 1]
|
||||||
if (typeof hex !== 'string' || !hex || hex.charAt(0) !== '#') {
|
if (typeof hex !== 'string' || !hex || hex.charAt(0) !== '#') {
|
||||||
@@ -59,24 +100,26 @@ function resolveScoreBandStyle(scene: MapScene, sequence: number): ScoreBandStyl
|
|||||||
|
|
||||||
export function resolveControlStyle(scene: MapScene, kind: 'start' | 'control' | 'finish', sequence: number | null, index?: number): ResolvedControlStyle {
|
export function resolveControlStyle(scene: MapScene, kind: 'start' | 'control' | 'finish', sequence: number | null, index?: number): ResolvedControlStyle {
|
||||||
if (kind === 'start') {
|
if (kind === 'start') {
|
||||||
if (index !== undefined && scene.startStyleOverrides[index]) {
|
const baseEntry = index !== undefined && scene.startStyleOverrides[index]
|
||||||
const entry = scene.startStyleOverrides[index]
|
? scene.startStyleOverrides[index]
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
: scene.gameMode === 'score-o'
|
||||||
}
|
|
||||||
const entry = scene.gameMode === 'score-o'
|
|
||||||
? scene.courseStyleConfig.scoreO.controls.start
|
? scene.courseStyleConfig.scoreO.controls.start
|
||||||
: scene.courseStyleConfig.sequential.controls.start
|
: scene.courseStyleConfig.sequential.controls.start
|
||||||
|
const entry = scene.completedStart
|
||||||
|
? resolveCompletedBoundaryEntry(scene, baseEntry)
|
||||||
|
: baseEntry
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'finish') {
|
if (kind === 'finish') {
|
||||||
if (index !== undefined && scene.finishStyleOverrides[index]) {
|
const baseEntry = index !== undefined && scene.finishStyleOverrides[index]
|
||||||
const entry = scene.finishStyleOverrides[index]
|
? scene.finishStyleOverrides[index]
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
: scene.gameMode === 'score-o'
|
||||||
}
|
|
||||||
const entry = scene.gameMode === 'score-o'
|
|
||||||
? scene.courseStyleConfig.scoreO.controls.finish
|
? scene.courseStyleConfig.scoreO.controls.finish
|
||||||
: scene.courseStyleConfig.sequential.controls.finish
|
: scene.courseStyleConfig.sequential.controls.finish
|
||||||
|
const entry = scene.completedFinish
|
||||||
|
? resolveCompletedBoundaryEntry(scene, baseEntry)
|
||||||
|
: baseEntry
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,59 +127,81 @@ export function resolveControlStyle(scene: MapScene, kind: 'start' | 'control' |
|
|||||||
const entry = scene.courseStyleConfig.sequential.controls.default
|
const entry = scene.courseStyleConfig.sequential.controls.default
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||||
}
|
}
|
||||||
|
const sequenceOverride = scene.controlStyleOverridesBySequence[sequence]
|
||||||
if (scene.controlStyleOverridesBySequence[sequence]) {
|
const defaultOverride = scene.defaultControlStyleOverride
|
||||||
const entry = scene.controlStyleOverridesBySequence[sequence]
|
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scene.gameMode === 'score-o') {
|
if (scene.gameMode === 'score-o') {
|
||||||
if (scene.completedControlSequences.includes(sequence)) {
|
if (scene.completedControlSequences.includes(sequence)) {
|
||||||
const entry = scene.courseStyleConfig.scoreO.controls.collected
|
const entry = mergeControlStyleEntries(
|
||||||
|
scene.courseStyleConfig.scoreO.controls.collected,
|
||||||
|
sequenceOverride || defaultOverride,
|
||||||
|
)
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scene.focusedControlSequences.includes(sequence)) {
|
if (scene.focusedControlSequences.includes(sequence)) {
|
||||||
const entry = scene.courseStyleConfig.scoreO.controls.focused
|
const bandEntry = resolveScoreBandStyle(scene, sequence)
|
||||||
|
const baseEntry = bandEntry || scene.courseStyleConfig.scoreO.controls.default
|
||||||
|
const focusedEntry = scene.courseStyleConfig.scoreO.controls.focused
|
||||||
|
const focusedMergedEntry: ControlPointStyleEntry = {
|
||||||
|
...baseEntry,
|
||||||
|
...focusedEntry,
|
||||||
|
colorHex: baseEntry.colorHex,
|
||||||
|
}
|
||||||
|
const entry = mergeControlStyleEntries(focusedMergedEntry, sequenceOverride || defaultOverride)
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const bandEntry = resolveScoreBandStyle(scene, sequence)
|
const bandEntry = resolveScoreBandStyle(scene, sequence)
|
||||||
const entry = bandEntry || scene.courseStyleConfig.scoreO.controls.default
|
const entry = mergeControlStyleEntries(
|
||||||
|
bandEntry || scene.courseStyleConfig.scoreO.controls.default,
|
||||||
|
sequenceOverride || defaultOverride,
|
||||||
|
)
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scene.readyControlSequences.includes(sequence) || scene.activeControlSequences.includes(sequence)) {
|
if (scene.readyControlSequences.includes(sequence) || scene.activeControlSequences.includes(sequence)) {
|
||||||
const entry = scene.courseStyleConfig.sequential.controls.current
|
const entry = mergeControlStyleEntries(
|
||||||
|
scene.courseStyleConfig.sequential.controls.current,
|
||||||
|
sequenceOverride || defaultOverride,
|
||||||
|
)
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scene.completedControlSequences.includes(sequence)) {
|
if (scene.completedControlSequences.includes(sequence)) {
|
||||||
const entry = scene.courseStyleConfig.sequential.controls.completed
|
const entry = mergeControlStyleEntries(
|
||||||
|
scene.courseStyleConfig.sequential.controls.completed,
|
||||||
|
sequenceOverride || defaultOverride,
|
||||||
|
)
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scene.skippedControlSequences.includes(sequence)) {
|
if (scene.skippedControlSequences.includes(sequence)) {
|
||||||
const entry = scene.courseStyleConfig.sequential.controls.skipped
|
const entry = mergeControlStyleEntries(
|
||||||
|
scene.courseStyleConfig.sequential.controls.skipped,
|
||||||
|
sequenceOverride || defaultOverride,
|
||||||
|
)
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = scene.courseStyleConfig.sequential.controls.default
|
const entry = mergeControlStyleEntries(
|
||||||
|
scene.courseStyleConfig.sequential.controls.default,
|
||||||
|
sequenceOverride || defaultOverride,
|
||||||
|
)
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLegStyle(scene: MapScene, index: number): ResolvedLegStyle {
|
export function resolveLegStyle(scene: MapScene, index: number): ResolvedLegStyle {
|
||||||
if (scene.legStyleOverridesByIndex[index]) {
|
|
||||||
const entry = scene.legStyleOverridesByIndex[index]
|
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scene.gameMode === 'score-o') {
|
if (scene.gameMode === 'score-o') {
|
||||||
const entry = scene.courseStyleConfig.sequential.legs.default
|
const entry = mergeLegStyleEntries(
|
||||||
|
scene.courseStyleConfig.sequential.legs.default,
|
||||||
|
scene.legStyleOverridesByIndex[index] || scene.defaultLegStyleOverride,
|
||||||
|
)
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const completed = scene.completedLegIndices.includes(index)
|
const completed = scene.completedLegIndices.includes(index)
|
||||||
const entry = completed ? scene.courseStyleConfig.sequential.legs.completed : scene.courseStyleConfig.sequential.legs.default
|
const baseEntry = completed ? scene.courseStyleConfig.sequential.legs.completed : scene.courseStyleConfig.sequential.legs.default
|
||||||
|
const entry = mergeLegStyleEntries(baseEntry, scene.legStyleOverridesByIndex[index] || scene.defaultLegStyleOverride)
|
||||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ export interface MapScene {
|
|||||||
gameMode: 'classic-sequential' | 'score-o'
|
gameMode: 'classic-sequential' | 'score-o'
|
||||||
courseStyleConfig: CourseStyleConfig
|
courseStyleConfig: CourseStyleConfig
|
||||||
controlScoresBySequence: Record<number, number>
|
controlScoresBySequence: Record<number, number>
|
||||||
|
defaultControlStyleOverride: ControlPointStyleEntry | null
|
||||||
controlStyleOverridesBySequence: Record<number, ControlPointStyleEntry>
|
controlStyleOverridesBySequence: Record<number, ControlPointStyleEntry>
|
||||||
startStyleOverrides: ControlPointStyleEntry[]
|
startStyleOverrides: ControlPointStyleEntry[]
|
||||||
finishStyleOverrides: ControlPointStyleEntry[]
|
finishStyleOverrides: ControlPointStyleEntry[]
|
||||||
|
defaultLegStyleOverride: CourseLegStyleEntry | null
|
||||||
legStyleOverridesByIndex: Record<number, CourseLegStyleEntry>
|
legStyleOverridesByIndex: Record<number, CourseLegStyleEntry>
|
||||||
controlVisualMode: 'single-target' | 'multi-target'
|
controlVisualMode: 'single-target' | 'multi-target'
|
||||||
showCourseLegs: boolean
|
showCourseLegs: boolean
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface HeartRateInputControllerDebugState {
|
|||||||
mockBridgeConnected: boolean
|
mockBridgeConnected: boolean
|
||||||
mockBridgeStatusText: string
|
mockBridgeStatusText: string
|
||||||
mockBridgeUrlText: string
|
mockBridgeUrlText: string
|
||||||
|
mockChannelIdText: string
|
||||||
mockHeartRateText: string
|
mockHeartRateText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ export class HeartRateInputController {
|
|||||||
sourceMode: HeartRateSourceMode
|
sourceMode: HeartRateSourceMode
|
||||||
mockBridgeStatusText: string
|
mockBridgeStatusText: string
|
||||||
mockBridgeUrl: string
|
mockBridgeUrl: string
|
||||||
|
mockChannelId: string
|
||||||
mockBpm: number | null
|
mockBpm: number | null
|
||||||
|
|
||||||
constructor(callbacks: HeartRateInputControllerCallbacks) {
|
constructor(callbacks: HeartRateInputControllerCallbacks) {
|
||||||
@@ -62,6 +64,7 @@ export class HeartRateInputController {
|
|||||||
this.sourceMode = 'real'
|
this.sourceMode = 'real'
|
||||||
this.mockBridgeUrl = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
|
this.mockBridgeUrl = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
|
||||||
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||||
|
this.mockChannelId = 'default'
|
||||||
this.mockBpm = null
|
this.mockBpm = null
|
||||||
|
|
||||||
const realCallbacks: HeartRateControllerCallbacks = {
|
const realCallbacks: HeartRateControllerCallbacks = {
|
||||||
@@ -194,6 +197,7 @@ export class HeartRateInputController {
|
|||||||
mockBridgeConnected: this.mockBridge.connected,
|
mockBridgeConnected: this.mockBridge.connected,
|
||||||
mockBridgeStatusText: this.mockBridgeStatusText,
|
mockBridgeStatusText: this.mockBridgeStatusText,
|
||||||
mockBridgeUrlText: this.mockBridgeUrl,
|
mockBridgeUrlText: this.mockBridgeUrl,
|
||||||
|
mockChannelIdText: this.mockChannelId,
|
||||||
mockHeartRateText: formatMockHeartRateText(this.mockBpm),
|
mockHeartRateText: formatMockHeartRateText(this.mockBpm),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,6 +273,16 @@ export class HeartRateInputController {
|
|||||||
this.emitDebugState()
|
this.emitDebugState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMockChannelId(channelId: string): void {
|
||||||
|
const normalized = String(channelId || '').trim() || 'default'
|
||||||
|
this.mockChannelId = normalized
|
||||||
|
this.mockBridge.setChannelId(normalized)
|
||||||
|
if (this.sourceMode === 'mock') {
|
||||||
|
this.callbacks.onStatus(`模拟心率通道已切换到 ${normalized}`)
|
||||||
|
}
|
||||||
|
this.emitDebugState()
|
||||||
|
}
|
||||||
|
|
||||||
connectMockBridge(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
|
connectMockBridge(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
|
||||||
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||||
if (this.sourceMode === 'mock') {
|
if (this.sourceMode === 'mock') {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface LocationControllerDebugState {
|
|||||||
mockBridgeConnected: boolean
|
mockBridgeConnected: boolean
|
||||||
mockBridgeStatusText: string
|
mockBridgeStatusText: string
|
||||||
mockBridgeUrlText: string
|
mockBridgeUrlText: string
|
||||||
|
mockChannelIdText: string
|
||||||
mockCoordText: string
|
mockCoordText: string
|
||||||
mockSpeedText: string
|
mockSpeedText: string
|
||||||
}
|
}
|
||||||
@@ -70,12 +71,14 @@ export class LocationController {
|
|||||||
sourceMode: LocationSourceMode
|
sourceMode: LocationSourceMode
|
||||||
mockBridgeStatusText: string
|
mockBridgeStatusText: string
|
||||||
mockBridgeUrl: string
|
mockBridgeUrl: string
|
||||||
|
mockChannelId: string
|
||||||
|
|
||||||
constructor(callbacks: LocationControllerCallbacks) {
|
constructor(callbacks: LocationControllerCallbacks) {
|
||||||
this.callbacks = callbacks
|
this.callbacks = callbacks
|
||||||
this.sourceMode = 'real'
|
this.sourceMode = 'real'
|
||||||
this.mockBridgeUrl = DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
this.mockBridgeUrl = DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
||||||
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||||
|
this.mockChannelId = 'default'
|
||||||
|
|
||||||
const sourceCallbacks: LocationSourceCallbacks = {
|
const sourceCallbacks: LocationSourceCallbacks = {
|
||||||
onLocation: (sample) => {
|
onLocation: (sample) => {
|
||||||
@@ -129,6 +132,7 @@ export class LocationController {
|
|||||||
mockBridgeConnected: this.mockBridge.connected,
|
mockBridgeConnected: this.mockBridge.connected,
|
||||||
mockBridgeStatusText: this.mockBridgeStatusText,
|
mockBridgeStatusText: this.mockBridgeStatusText,
|
||||||
mockBridgeUrlText: this.mockBridgeUrl,
|
mockBridgeUrlText: this.mockBridgeUrl,
|
||||||
|
mockChannelIdText: this.mockChannelId,
|
||||||
mockCoordText: formatMockCoordText(this.mockSource.lastSample),
|
mockCoordText: formatMockCoordText(this.mockSource.lastSample),
|
||||||
mockSpeedText: formatMockSpeedText(this.mockSource.lastSample),
|
mockSpeedText: formatMockSpeedText(this.mockSource.lastSample),
|
||||||
}
|
}
|
||||||
@@ -187,6 +191,14 @@ export class LocationController {
|
|||||||
this.emitDebugState()
|
this.emitDebugState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMockChannelId(channelId: string): void {
|
||||||
|
const normalized = String(channelId || '').trim() || 'default'
|
||||||
|
this.mockChannelId = normalized
|
||||||
|
this.mockBridge.setChannelId(normalized)
|
||||||
|
this.callbacks.onStatus(`模拟定位通道已切换到 ${normalized}`)
|
||||||
|
this.emitDebugState()
|
||||||
|
}
|
||||||
|
|
||||||
connectMockBridge(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
|
connectMockBridge(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
|
||||||
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||||
this.callbacks.onStatus('模拟定位源已连接')
|
this.callbacks.onStatus('模拟定位源已连接')
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ type RawMockHeartRateMessage = {
|
|||||||
type?: string
|
type?: string
|
||||||
timestamp?: number
|
timestamp?: number
|
||||||
bpm?: number
|
bpm?: number
|
||||||
|
channelId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMockChannelId(rawChannelId: string | null | undefined): string {
|
||||||
|
const trimmed = String(rawChannelId || '').trim()
|
||||||
|
return trimmed || 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeParseMessage(data: string): RawMockHeartRateMessage | null {
|
function safeParseMessage(data: string): RawMockHeartRateMessage | null {
|
||||||
@@ -21,11 +27,15 @@ function safeParseMessage(data: string): RawMockHeartRateMessage | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toHeartRateValue(message: RawMockHeartRateMessage): number | null {
|
function toHeartRateValue(message: RawMockHeartRateMessage, expectedChannelId: string): number | null {
|
||||||
if (message.type !== 'mock_heart_rate' || !Number.isFinite(message.bpm)) {
|
if (message.type !== 'mock_heart_rate' || !Number.isFinite(message.bpm)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizeMockChannelId(message.channelId) !== expectedChannelId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const bpm = Math.round(Number(message.bpm))
|
const bpm = Math.round(Number(message.bpm))
|
||||||
if (bpm <= 0) {
|
if (bpm <= 0) {
|
||||||
return null
|
return null
|
||||||
@@ -40,6 +50,7 @@ export class MockHeartRateBridge {
|
|||||||
connected: boolean
|
connected: boolean
|
||||||
connecting: boolean
|
connecting: boolean
|
||||||
url: string
|
url: string
|
||||||
|
channelId: string
|
||||||
|
|
||||||
constructor(callbacks: MockHeartRateBridgeCallbacks) {
|
constructor(callbacks: MockHeartRateBridgeCallbacks) {
|
||||||
this.callbacks = callbacks
|
this.callbacks = callbacks
|
||||||
@@ -47,6 +58,11 @@ export class MockHeartRateBridge {
|
|||||||
this.connected = false
|
this.connected = false
|
||||||
this.connecting = false
|
this.connecting = false
|
||||||
this.url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
|
this.url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
|
||||||
|
this.channelId = 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
setChannelId(channelId: string): void {
|
||||||
|
this.channelId = normalizeMockChannelId(channelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
|
connect(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
|
||||||
@@ -96,7 +112,7 @@ export class MockHeartRateBridge {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const bpm = toHeartRateValue(parsed)
|
const bpm = toHeartRateValue(parsed, this.channelId)
|
||||||
if (bpm === null) {
|
if (bpm === null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ type RawMockGpsMessage = {
|
|||||||
accuracyMeters?: number
|
accuracyMeters?: number
|
||||||
speedMps?: number
|
speedMps?: number
|
||||||
headingDeg?: number
|
headingDeg?: number
|
||||||
|
channelId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMockChannelId(rawChannelId: string | null | undefined): string {
|
||||||
|
const trimmed = String(rawChannelId || '').trim()
|
||||||
|
return trimmed || 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeParseMessage(data: string): RawMockGpsMessage | null {
|
function safeParseMessage(data: string): RawMockGpsMessage | null {
|
||||||
@@ -27,7 +33,7 @@ function safeParseMessage(data: string): RawMockGpsMessage | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLocationSample(message: RawMockGpsMessage): LocationSample | null {
|
function toLocationSample(message: RawMockGpsMessage, expectedChannelId: string): LocationSample | null {
|
||||||
if (message.type !== 'mock_gps') {
|
if (message.type !== 'mock_gps') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -36,6 +42,10 @@ function toLocationSample(message: RawMockGpsMessage): LocationSample | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizeMockChannelId(message.channelId) !== expectedChannelId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
latitude: Number(message.lat),
|
latitude: Number(message.lat),
|
||||||
longitude: Number(message.lon),
|
longitude: Number(message.lon),
|
||||||
@@ -53,6 +63,7 @@ export class MockLocationBridge {
|
|||||||
connected: boolean
|
connected: boolean
|
||||||
connecting: boolean
|
connecting: boolean
|
||||||
url: string
|
url: string
|
||||||
|
channelId: string
|
||||||
|
|
||||||
constructor(callbacks: MockLocationBridgeCallbacks) {
|
constructor(callbacks: MockLocationBridgeCallbacks) {
|
||||||
this.callbacks = callbacks
|
this.callbacks = callbacks
|
||||||
@@ -60,6 +71,11 @@ export class MockLocationBridge {
|
|||||||
this.connected = false
|
this.connected = false
|
||||||
this.connecting = false
|
this.connecting = false
|
||||||
this.url = DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
this.url = DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
||||||
|
this.channelId = 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
setChannelId(channelId: string): void {
|
||||||
|
this.channelId = normalizeMockChannelId(channelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
|
connect(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
|
||||||
@@ -109,7 +125,7 @@ export class MockLocationBridge {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const sample = toLocationSample(parsed)
|
const sample = toLocationSample(parsed, this.channelId)
|
||||||
if (!sample) {
|
if (!sample) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type AudioCueKey =
|
|||||||
| 'control_completed:finish'
|
| 'control_completed:finish'
|
||||||
| 'punch_feedback:warning'
|
| 'punch_feedback:warning'
|
||||||
| 'guidance:searching'
|
| 'guidance:searching'
|
||||||
|
| 'guidance:distant'
|
||||||
| 'guidance:approaching'
|
| 'guidance:approaching'
|
||||||
| 'guidance:ready'
|
| 'guidance:ready'
|
||||||
|
|
||||||
@@ -21,7 +22,9 @@ export interface GameAudioConfig {
|
|||||||
masterVolume: number
|
masterVolume: number
|
||||||
obeyMuteSwitch: boolean
|
obeyMuteSwitch: boolean
|
||||||
backgroundAudioEnabled: boolean
|
backgroundAudioEnabled: boolean
|
||||||
|
distantDistanceMeters: number
|
||||||
approachDistanceMeters: number
|
approachDistanceMeters: number
|
||||||
|
readyDistanceMeters: number
|
||||||
cues: Record<AudioCueKey, AudioCueConfig>
|
cues: Record<AudioCueKey, AudioCueConfig>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +41,9 @@ export interface GameAudioConfigOverrides {
|
|||||||
masterVolume?: number
|
masterVolume?: number
|
||||||
obeyMuteSwitch?: boolean
|
obeyMuteSwitch?: boolean
|
||||||
backgroundAudioEnabled?: boolean
|
backgroundAudioEnabled?: boolean
|
||||||
|
distantDistanceMeters?: number
|
||||||
approachDistanceMeters?: number
|
approachDistanceMeters?: number
|
||||||
|
readyDistanceMeters?: number
|
||||||
cues?: Partial<Record<AudioCueKey, PartialAudioCueConfig>>
|
cues?: Partial<Record<AudioCueKey, PartialAudioCueConfig>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +52,9 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
|
|||||||
masterVolume: 1,
|
masterVolume: 1,
|
||||||
obeyMuteSwitch: true,
|
obeyMuteSwitch: true,
|
||||||
backgroundAudioEnabled: true,
|
backgroundAudioEnabled: true,
|
||||||
|
distantDistanceMeters: 80,
|
||||||
approachDistanceMeters: 20,
|
approachDistanceMeters: 20,
|
||||||
|
readyDistanceMeters: 5,
|
||||||
cues: {
|
cues: {
|
||||||
session_started: {
|
session_started: {
|
||||||
src: '/assets/sounds/session-start.wav',
|
src: '/assets/sounds/session-start.wav',
|
||||||
@@ -91,6 +98,13 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
|
|||||||
loopGapMs: 1800,
|
loopGapMs: 1800,
|
||||||
backgroundMode: 'guidance',
|
backgroundMode: 'guidance',
|
||||||
},
|
},
|
||||||
|
'guidance:distant': {
|
||||||
|
src: '/assets/sounds/guidance-searching.wav',
|
||||||
|
volume: 0.34,
|
||||||
|
loop: true,
|
||||||
|
loopGapMs: 4800,
|
||||||
|
backgroundMode: 'guidance',
|
||||||
|
},
|
||||||
'guidance:approaching': {
|
'guidance:approaching': {
|
||||||
src: '/assets/sounds/guidance-approaching.wav',
|
src: '/assets/sounds/guidance-approaching.wav',
|
||||||
volume: 0.58,
|
volume: 0.58,
|
||||||
@@ -129,6 +143,7 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null
|
|||||||
'control_completed:finish': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:finish'] },
|
'control_completed:finish': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:finish'] },
|
||||||
'punch_feedback:warning': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['punch_feedback:warning'] },
|
'punch_feedback:warning': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['punch_feedback:warning'] },
|
||||||
'guidance:searching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:searching'] },
|
'guidance:searching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:searching'] },
|
||||||
|
'guidance:distant': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:distant'] },
|
||||||
'guidance:approaching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:approaching'] },
|
'guidance:approaching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:approaching'] },
|
||||||
'guidance:ready': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:ready'] },
|
'guidance:ready': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:ready'] },
|
||||||
}
|
}
|
||||||
@@ -170,7 +185,15 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null
|
|||||||
backgroundAudioEnabled: overrides && overrides.backgroundAudioEnabled !== undefined
|
backgroundAudioEnabled: overrides && overrides.backgroundAudioEnabled !== undefined
|
||||||
? !!overrides.backgroundAudioEnabled
|
? !!overrides.backgroundAudioEnabled
|
||||||
: true,
|
: true,
|
||||||
|
distantDistanceMeters: clampDistance(
|
||||||
|
Number(overrides && overrides.distantDistanceMeters),
|
||||||
|
DEFAULT_GAME_AUDIO_CONFIG.distantDistanceMeters,
|
||||||
|
),
|
||||||
approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters),
|
approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters),
|
||||||
|
readyDistanceMeters: clampDistance(
|
||||||
|
Number(overrides && overrides.readyDistanceMeters),
|
||||||
|
DEFAULT_GAME_AUDIO_CONFIG.readyDistanceMeters,
|
||||||
|
),
|
||||||
cues,
|
cues,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ export class SoundDirector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasFinishCompletion = effects.some((effect) => effect.type === 'control_completed' && effect.controlKind === 'finish')
|
const hasFinishCompletion = effects.some((effect) => effect.type === 'control_completed' && effect.controlKind === 'finish')
|
||||||
|
if (hasFinishCompletion) {
|
||||||
|
this.stopGuidanceLoop()
|
||||||
|
this.play('control_completed:finish')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for (const effect of effects) {
|
for (const effect of effects) {
|
||||||
if (effect.type === 'session_started') {
|
if (effect.type === 'session_started') {
|
||||||
@@ -85,17 +90,21 @@ export class SoundDirector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (effect.type === 'guidance_state_changed') {
|
if (effect.type === 'guidance_state_changed') {
|
||||||
if (effect.guidanceState === 'searching') {
|
if (effect.guidanceState === 'distant') {
|
||||||
this.startGuidanceLoop('guidance:searching')
|
this.startGuidanceLoop('guidance:distant')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (effect.guidanceState === 'approaching') {
|
if (effect.guidanceState === 'approaching') {
|
||||||
this.startGuidanceLoop('guidance:approaching')
|
this.startGuidanceLoop('guidance:approaching')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (effect.guidanceState === 'ready') {
|
||||||
this.startGuidanceLoop('guidance:ready')
|
this.startGuidanceLoop('guidance:ready')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
this.stopGuidanceLoop()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (effect.type === 'control_completed') {
|
if (effect.type === 'control_completed') {
|
||||||
this.stopGuidanceLoop()
|
this.stopGuidanceLoop()
|
||||||
@@ -273,6 +282,7 @@ export class SoundDirector {
|
|||||||
|
|
||||||
isGuidanceCue(key: AudioCueKey): boolean {
|
isGuidanceCue(key: AudioCueKey): boolean {
|
||||||
return key === 'guidance:searching'
|
return key === 'guidance:searching'
|
||||||
|
|| key === 'guidance:distant'
|
||||||
|| key === 'guidance:approaching'
|
|| key === 'guidance:approaching'
|
||||||
|| key === 'guidance:ready'
|
|| key === 'guidance:ready'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
resolveContentCardCtaConfig,
|
resolveContentCardCtaConfig,
|
||||||
} from '../experience/contentCard'
|
} from '../experience/contentCard'
|
||||||
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
||||||
|
import {
|
||||||
|
getDefaultSkipRadiusMeters,
|
||||||
|
getGameModeDefaults,
|
||||||
|
resolveDefaultControlScore,
|
||||||
|
} from '../core/gameModeDefaults'
|
||||||
|
|
||||||
function sortBySequence<T extends { sequence: number | null }>(items: T[]): T[] {
|
function sortBySequence<T extends { sequence: number | null }>(items: T[]): T[] {
|
||||||
return [...items].sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
|
return [...items].sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
|
||||||
@@ -86,18 +91,48 @@ export function buildGameDefinitionFromCourse(
|
|||||||
course: OrienteeringCourseData,
|
course: OrienteeringCourseData,
|
||||||
controlRadiusMeters: number,
|
controlRadiusMeters: number,
|
||||||
mode: GameDefinition['mode'] = 'classic-sequential',
|
mode: GameDefinition['mode'] = 'classic-sequential',
|
||||||
autoFinishOnLastControl = true,
|
sessionCloseAfterMs?: number,
|
||||||
|
sessionCloseWarningMs?: number,
|
||||||
|
minCompletedControlsBeforeFinish?: number,
|
||||||
|
autoFinishOnLastControl?: boolean,
|
||||||
punchPolicy: PunchPolicyType = 'enter-confirm',
|
punchPolicy: PunchPolicyType = 'enter-confirm',
|
||||||
punchRadiusMeters = 5,
|
punchRadiusMeters = 5,
|
||||||
requiresFocusSelection = false,
|
requiresFocusSelection?: boolean,
|
||||||
skipEnabled = false,
|
skipEnabled?: boolean,
|
||||||
skipRadiusMeters = 30,
|
skipRadiusMeters?: number,
|
||||||
skipRequiresConfirm = true,
|
skipRequiresConfirm?: boolean,
|
||||||
controlScoreOverrides: Record<string, number> = {},
|
controlScoreOverrides: Record<string, number> = {},
|
||||||
|
defaultControlContentOverride: GameControlDisplayContentOverride | null = null,
|
||||||
controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {},
|
controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {},
|
||||||
defaultControlScore: number | null = null,
|
defaultControlScore: number | null = null,
|
||||||
): GameDefinition {
|
): GameDefinition {
|
||||||
const controls: GameControl[] = []
|
const controls: GameControl[] = []
|
||||||
|
const modeDefaults = getGameModeDefaults(mode)
|
||||||
|
const resolvedSessionCloseAfterMs = sessionCloseAfterMs !== undefined
|
||||||
|
? sessionCloseAfterMs
|
||||||
|
: modeDefaults.sessionCloseAfterMs
|
||||||
|
const resolvedSessionCloseWarningMs = sessionCloseWarningMs !== undefined
|
||||||
|
? sessionCloseWarningMs
|
||||||
|
: modeDefaults.sessionCloseWarningMs
|
||||||
|
const resolvedMinCompletedControlsBeforeFinish = minCompletedControlsBeforeFinish !== undefined
|
||||||
|
? minCompletedControlsBeforeFinish
|
||||||
|
: modeDefaults.minCompletedControlsBeforeFinish
|
||||||
|
const resolvedRequiresFocusSelection = requiresFocusSelection !== undefined
|
||||||
|
? requiresFocusSelection
|
||||||
|
: modeDefaults.requiresFocusSelection
|
||||||
|
const resolvedSkipEnabled = skipEnabled !== undefined
|
||||||
|
? skipEnabled
|
||||||
|
: modeDefaults.skipEnabled
|
||||||
|
const resolvedSkipRadiusMeters = skipRadiusMeters !== undefined
|
||||||
|
? skipRadiusMeters
|
||||||
|
: getDefaultSkipRadiusMeters(mode, punchRadiusMeters)
|
||||||
|
const resolvedSkipRequiresConfirm = skipRequiresConfirm !== undefined
|
||||||
|
? skipRequiresConfirm
|
||||||
|
: modeDefaults.skipRequiresConfirm
|
||||||
|
const resolvedAutoFinishOnLastControl = autoFinishOnLastControl !== undefined
|
||||||
|
? autoFinishOnLastControl
|
||||||
|
: modeDefaults.autoFinishOnLastControl
|
||||||
|
const resolvedDefaultControlScore = resolveDefaultControlScore(mode, defaultControlScore)
|
||||||
|
|
||||||
for (let startIndex = 0; startIndex < course.layers.starts.length; startIndex += 1) {
|
for (let startIndex = 0; startIndex < course.layers.starts.length; startIndex += 1) {
|
||||||
const start = course.layers.starts[startIndex]
|
const start = course.layers.starts[startIndex]
|
||||||
@@ -114,11 +149,11 @@ export function buildGameDefinitionFromCourse(
|
|||||||
template: 'focus',
|
template: 'focus',
|
||||||
title: '比赛开始',
|
title: '比赛开始',
|
||||||
body: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
|
body: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
|
||||||
autoPopup: true,
|
autoPopup: false,
|
||||||
once: false,
|
once: false,
|
||||||
priority: 1,
|
priority: 1,
|
||||||
clickTitle: '比赛开始',
|
clickTitle: null,
|
||||||
clickBody: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
|
clickBody: null,
|
||||||
ctas: [],
|
ctas: [],
|
||||||
contentExperience: null,
|
contentExperience: null,
|
||||||
clickExperience: null,
|
clickExperience: null,
|
||||||
@@ -131,7 +166,7 @@ export function buildGameDefinitionFromCourse(
|
|||||||
const controlId = `control-${control.sequence}`
|
const controlId = `control-${control.sequence}`
|
||||||
const score = controlId in controlScoreOverrides
|
const score = controlId in controlScoreOverrides
|
||||||
? controlScoreOverrides[controlId]
|
? controlScoreOverrides[controlId]
|
||||||
: defaultControlScore
|
: resolvedDefaultControlScore
|
||||||
controls.push({
|
controls.push({
|
||||||
id: controlId,
|
id: controlId,
|
||||||
code: label,
|
code: label,
|
||||||
@@ -140,19 +175,22 @@ export function buildGameDefinitionFromCourse(
|
|||||||
point: control.point,
|
point: control.point,
|
||||||
sequence: control.sequence,
|
sequence: control.sequence,
|
||||||
score,
|
score,
|
||||||
displayContent: applyDisplayContentOverride({
|
displayContent: applyDisplayContentOverride(
|
||||||
|
applyDisplayContentOverride({
|
||||||
template: 'story',
|
template: 'story',
|
||||||
title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
|
title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
|
||||||
body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence),
|
body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence),
|
||||||
autoPopup: true,
|
autoPopup: false,
|
||||||
once: false,
|
once: false,
|
||||||
priority: 1,
|
priority: 1,
|
||||||
clickTitle: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
|
clickTitle: null,
|
||||||
clickBody: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence),
|
clickBody: null,
|
||||||
ctas: [],
|
ctas: [],
|
||||||
contentExperience: null,
|
contentExperience: null,
|
||||||
clickExperience: null,
|
clickExperience: null,
|
||||||
}, controlContentOverrides[controlId]),
|
}, defaultControlContentOverride || undefined),
|
||||||
|
controlContentOverrides[controlId],
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +210,11 @@ export function buildGameDefinitionFromCourse(
|
|||||||
template: 'focus',
|
template: 'focus',
|
||||||
title: '完成路线',
|
title: '完成路线',
|
||||||
body: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
|
body: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
|
||||||
autoPopup: true,
|
autoPopup: false,
|
||||||
once: false,
|
once: false,
|
||||||
priority: 2,
|
priority: 2,
|
||||||
clickTitle: '完成路线',
|
clickTitle: null,
|
||||||
clickBody: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
|
clickBody: null,
|
||||||
ctas: [],
|
ctas: [],
|
||||||
contentExperience: null,
|
contentExperience: null,
|
||||||
clickExperience: null,
|
clickExperience: null,
|
||||||
@@ -189,13 +227,16 @@ export function buildGameDefinitionFromCourse(
|
|||||||
mode,
|
mode,
|
||||||
title: course.title || (mode === 'score-o' ? 'Score-O' : 'Classic Sequential'),
|
title: course.title || (mode === 'score-o' ? 'Score-O' : 'Classic Sequential'),
|
||||||
controlRadiusMeters,
|
controlRadiusMeters,
|
||||||
|
sessionCloseAfterMs: resolvedSessionCloseAfterMs,
|
||||||
|
sessionCloseWarningMs: resolvedSessionCloseWarningMs,
|
||||||
|
minCompletedControlsBeforeFinish: resolvedMinCompletedControlsBeforeFinish,
|
||||||
punchRadiusMeters,
|
punchRadiusMeters,
|
||||||
punchPolicy,
|
punchPolicy,
|
||||||
requiresFocusSelection,
|
requiresFocusSelection: resolvedRequiresFocusSelection,
|
||||||
skipEnabled,
|
skipEnabled: resolvedSkipEnabled,
|
||||||
skipRadiusMeters,
|
skipRadiusMeters: resolvedSkipRadiusMeters,
|
||||||
skipRequiresConfirm,
|
skipRequiresConfirm: resolvedSkipRequiresConfirm,
|
||||||
controls,
|
controls,
|
||||||
autoFinishOnLastControl,
|
autoFinishOnLastControl: resolvedAutoFinishOnLastControl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export interface GameDefinition {
|
|||||||
mode: GameMode
|
mode: GameMode
|
||||||
title: string
|
title: string
|
||||||
controlRadiusMeters: number
|
controlRadiusMeters: number
|
||||||
|
sessionCloseAfterMs: number
|
||||||
|
sessionCloseWarningMs: number
|
||||||
|
minCompletedControlsBeforeFinish: number
|
||||||
punchRadiusMeters: number
|
punchRadiusMeters: number
|
||||||
punchPolicy: PunchPolicyType
|
punchPolicy: PunchPolicyType
|
||||||
requiresFocusSelection: boolean
|
requiresFocusSelection: boolean
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export type GameEvent =
|
export type GameEvent =
|
||||||
| { type: 'session_started'; at: number }
|
| { type: 'session_started'; at: number }
|
||||||
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
|
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
|
||||||
| { type: 'punch_requested'; at: number }
|
| { type: 'punch_requested'; at: number; lon: number | null; lat: number | null }
|
||||||
| { type: 'skip_requested'; at: number; lon: number | null; lat: number | null }
|
| { type: 'skip_requested'; at: number; lon: number | null; lat: number | null }
|
||||||
| { type: 'control_focused'; at: number; controlId: string | null }
|
| { type: 'control_focused'; at: number; controlId: string | null }
|
||||||
| { type: 'session_ended'; at: number }
|
| { type: 'session_ended'; at: number }
|
||||||
|
| { type: 'session_timed_out'; at: number }
|
||||||
|
|||||||
55
miniprogram/game/core/gameModeDefaults.ts
Normal file
55
miniprogram/game/core/gameModeDefaults.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { type GameMode } from './gameDefinition'
|
||||||
|
|
||||||
|
export interface GameModeDefaults {
|
||||||
|
sessionCloseAfterMs: number
|
||||||
|
sessionCloseWarningMs: number
|
||||||
|
minCompletedControlsBeforeFinish: number
|
||||||
|
requiresFocusSelection: boolean
|
||||||
|
skipEnabled: boolean
|
||||||
|
skipRequiresConfirm: boolean
|
||||||
|
autoFinishOnLastControl: boolean
|
||||||
|
defaultControlScore: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const GAME_MODE_DEFAULTS: Record<GameMode, GameModeDefaults> = {
|
||||||
|
'classic-sequential': {
|
||||||
|
sessionCloseAfterMs: 2 * 60 * 60 * 1000,
|
||||||
|
sessionCloseWarningMs: 10 * 60 * 1000,
|
||||||
|
minCompletedControlsBeforeFinish: 0,
|
||||||
|
requiresFocusSelection: false,
|
||||||
|
skipEnabled: true,
|
||||||
|
skipRequiresConfirm: true,
|
||||||
|
autoFinishOnLastControl: false,
|
||||||
|
defaultControlScore: 1,
|
||||||
|
},
|
||||||
|
'score-o': {
|
||||||
|
sessionCloseAfterMs: 2 * 60 * 60 * 1000,
|
||||||
|
sessionCloseWarningMs: 10 * 60 * 1000,
|
||||||
|
minCompletedControlsBeforeFinish: 1,
|
||||||
|
requiresFocusSelection: false,
|
||||||
|
skipEnabled: false,
|
||||||
|
skipRequiresConfirm: true,
|
||||||
|
autoFinishOnLastControl: false,
|
||||||
|
defaultControlScore: 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGameModeDefaults(mode: GameMode): GameModeDefaults {
|
||||||
|
return GAME_MODE_DEFAULTS[mode]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultSkipRadiusMeters(mode: GameMode, punchRadiusMeters: number): number {
|
||||||
|
if (mode === 'classic-sequential') {
|
||||||
|
return punchRadiusMeters * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return 30
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultControlScore(mode: GameMode, configuredDefaultScore: number | null): number {
|
||||||
|
if (typeof configuredDefaultScore === 'number') {
|
||||||
|
return configuredDefaultScore
|
||||||
|
}
|
||||||
|
|
||||||
|
return getGameModeDefaults(mode).defaultControlScore
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@ export type GameEffect =
|
|||||||
| { type: 'session_started' }
|
| { type: 'session_started' }
|
||||||
| { type: 'session_cancelled' }
|
| { type: 'session_cancelled' }
|
||||||
| { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' }
|
| { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' }
|
||||||
| { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string; displayAutoPopup: boolean; displayOnce: boolean; displayPriority: number }
|
| { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string; displayAutoPopup: boolean; displayOnce: boolean; displayPriority: number; autoOpenQuiz: boolean }
|
||||||
| { type: 'guidance_state_changed'; guidanceState: GuidanceState; controlId: string | null }
|
| { type: 'guidance_state_changed'; guidanceState: GuidanceState; controlId: string | null }
|
||||||
| { type: 'session_finished' }
|
| { type: 'session_finished' }
|
||||||
|
| { type: 'session_timed_out' }
|
||||||
|
|
||||||
export interface GameResult {
|
export interface GameResult {
|
||||||
nextState: GameSessionState
|
nextState: GameSessionState
|
||||||
|
|||||||
@@ -54,6 +54,36 @@ export class GameRuntime {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restoreDefinition(definition: GameDefinition, state: GameSessionState): GameResult {
|
||||||
|
this.definition = definition
|
||||||
|
this.plugin = this.resolvePlugin(definition)
|
||||||
|
this.state = {
|
||||||
|
status: state.status,
|
||||||
|
endReason: state.endReason,
|
||||||
|
startedAt: state.startedAt,
|
||||||
|
endedAt: state.endedAt,
|
||||||
|
completedControlIds: state.completedControlIds.slice(),
|
||||||
|
skippedControlIds: state.skippedControlIds.slice(),
|
||||||
|
currentTargetControlId: state.currentTargetControlId,
|
||||||
|
inRangeControlId: state.inRangeControlId,
|
||||||
|
score: state.score,
|
||||||
|
guidanceState: state.guidanceState,
|
||||||
|
modeState: state.modeState
|
||||||
|
? JSON.parse(JSON.stringify(state.modeState)) as Record<string, unknown>
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
const result: GameResult = {
|
||||||
|
nextState: this.state,
|
||||||
|
presentation: this.plugin.buildPresentation(definition, this.state),
|
||||||
|
effects: [],
|
||||||
|
}
|
||||||
|
this.presentation = result.presentation
|
||||||
|
this.mapPresentation = result.presentation.map
|
||||||
|
this.hudPresentation = result.presentation.hud
|
||||||
|
this.lastResult = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
startSession(startAt = Date.now()): GameResult {
|
startSession(startAt = Date.now()): GameResult {
|
||||||
return this.dispatch({ type: 'session_started', at: startAt })
|
return this.dispatch({ type: 'session_started', at: startAt })
|
||||||
}
|
}
|
||||||
@@ -62,6 +92,7 @@ export class GameRuntime {
|
|||||||
if (!this.definition || !this.plugin || !this.state) {
|
if (!this.definition || !this.plugin || !this.state) {
|
||||||
const emptyState: GameSessionState = {
|
const emptyState: GameSessionState = {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
endReason: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
completedControlIds: [],
|
completedControlIds: [],
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
|
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
|
||||||
export type GuidanceState = 'searching' | 'approaching' | 'ready'
|
export type GameSessionEndReason = 'completed' | 'timed_out' | 'cancelled' | null
|
||||||
|
export type GuidanceState = 'searching' | 'distant' | 'approaching' | 'ready'
|
||||||
export type GameModeState = Record<string, unknown> | null
|
export type GameModeState = Record<string, unknown> | null
|
||||||
|
|
||||||
export interface GameSessionState {
|
export interface GameSessionState {
|
||||||
status: GameSessionStatus
|
status: GameSessionStatus
|
||||||
|
endReason: GameSessionEndReason
|
||||||
startedAt: number | null
|
startedAt: number | null
|
||||||
endedAt: number | null
|
endedAt: number | null
|
||||||
completedControlIds: string[]
|
completedControlIds: string[]
|
||||||
|
|||||||
150
miniprogram/game/core/runtimeProfileCompiler.ts
Normal file
150
miniprogram/game/core/runtimeProfileCompiler.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { type RemoteMapConfig } from '../../utils/remoteMapConfig'
|
||||||
|
import { type GameAudioConfig } from '../audio/audioConfig'
|
||||||
|
import { type GameHapticsConfig, type GameUiEffectsConfig } from '../feedback/feedbackConfig'
|
||||||
|
import { getGameModeDefaults } from './gameModeDefaults'
|
||||||
|
import {
|
||||||
|
resolveSystemSettingsState,
|
||||||
|
type ResolvedSystemSettingsState,
|
||||||
|
} from './systemSettingsState'
|
||||||
|
import { type CourseStyleConfig } from '../presentation/courseStyleConfig'
|
||||||
|
import { type GpsMarkerStyleConfig } from '../presentation/gpsMarkerStyleConfig'
|
||||||
|
import { type TrackVisualizationConfig } from '../presentation/trackStyleConfig'
|
||||||
|
import { mergeTelemetrySources, type PlayerTelemetryProfile } from '../telemetry/playerTelemetryProfile'
|
||||||
|
import { type TelemetryConfig } from '../telemetry/telemetryConfig'
|
||||||
|
|
||||||
|
export interface RuntimeMapProfile {
|
||||||
|
title: string
|
||||||
|
tileSource: string
|
||||||
|
projectionModeText: string
|
||||||
|
magneticDeclinationText: string
|
||||||
|
cpRadiusMeters: number
|
||||||
|
projection: string
|
||||||
|
magneticDeclinationDeg: number
|
||||||
|
minZoom: number
|
||||||
|
maxZoom: number
|
||||||
|
initialZoom: number
|
||||||
|
initialCenterTileX: number
|
||||||
|
initialCenterTileY: number
|
||||||
|
tileBoundsByZoom: RemoteMapConfig['tileBoundsByZoom']
|
||||||
|
courseStatusText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeGameProfile {
|
||||||
|
mode: RemoteMapConfig['gameMode']
|
||||||
|
sessionCloseAfterMs: number
|
||||||
|
sessionCloseWarningMs: number
|
||||||
|
minCompletedControlsBeforeFinish: number
|
||||||
|
punchPolicy: RemoteMapConfig['punchPolicy']
|
||||||
|
punchRadiusMeters: number
|
||||||
|
requiresFocusSelection: boolean
|
||||||
|
skipEnabled: boolean
|
||||||
|
skipRadiusMeters: number
|
||||||
|
skipRequiresConfirm: boolean
|
||||||
|
autoFinishOnLastControl: boolean
|
||||||
|
defaultControlScore: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimePresentationProfile {
|
||||||
|
course: CourseStyleConfig
|
||||||
|
track: TrackVisualizationConfig
|
||||||
|
gpsMarker: GpsMarkerStyleConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeFeedbackProfile {
|
||||||
|
audio: GameAudioConfig
|
||||||
|
haptics: GameHapticsConfig
|
||||||
|
uiEffects: GameUiEffectsConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeTelemetryProfile {
|
||||||
|
config: TelemetryConfig
|
||||||
|
playerProfile: PlayerTelemetryProfile | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeSettingsProfile extends ResolvedSystemSettingsState {
|
||||||
|
lockLifetimeActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompiledRuntimeProfile {
|
||||||
|
map: RuntimeMapProfile
|
||||||
|
game: RuntimeGameProfile
|
||||||
|
settings: RuntimeSettingsProfile
|
||||||
|
telemetry: RuntimeTelemetryProfile
|
||||||
|
presentation: RuntimePresentationProfile
|
||||||
|
feedback: RuntimeFeedbackProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompileRuntimeProfileOptions {
|
||||||
|
playerTelemetryProfile?: PlayerTelemetryProfile | null
|
||||||
|
settingsLockLifetimeActive?: boolean
|
||||||
|
storedSettingsKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compileRuntimeProfile(
|
||||||
|
config: RemoteMapConfig,
|
||||||
|
options?: CompileRuntimeProfileOptions,
|
||||||
|
): CompiledRuntimeProfile {
|
||||||
|
const modeDefaults = getGameModeDefaults(config.gameMode)
|
||||||
|
const lockLifetimeActive = !!(options && options.settingsLockLifetimeActive === true)
|
||||||
|
const playerTelemetryProfile = options && options.playerTelemetryProfile
|
||||||
|
? Object.assign({}, options.playerTelemetryProfile)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const settings = resolveSystemSettingsState(
|
||||||
|
config.systemSettingsConfig,
|
||||||
|
options && options.storedSettingsKey ? options.storedSettingsKey : undefined,
|
||||||
|
lockLifetimeActive,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
map: {
|
||||||
|
title: config.configTitle,
|
||||||
|
tileSource: config.tileSource,
|
||||||
|
projectionModeText: config.projectionModeText,
|
||||||
|
magneticDeclinationText: config.magneticDeclinationText,
|
||||||
|
cpRadiusMeters: config.cpRadiusMeters,
|
||||||
|
projection: config.projection,
|
||||||
|
magneticDeclinationDeg: config.magneticDeclinationDeg,
|
||||||
|
minZoom: config.minZoom,
|
||||||
|
maxZoom: config.maxZoom,
|
||||||
|
initialZoom: config.defaultZoom,
|
||||||
|
initialCenterTileX: config.initialCenterTileX,
|
||||||
|
initialCenterTileY: config.initialCenterTileY,
|
||||||
|
tileBoundsByZoom: config.tileBoundsByZoom,
|
||||||
|
courseStatusText: config.courseStatusText,
|
||||||
|
},
|
||||||
|
game: {
|
||||||
|
mode: config.gameMode,
|
||||||
|
sessionCloseAfterMs: config.sessionCloseAfterMs || modeDefaults.sessionCloseAfterMs,
|
||||||
|
sessionCloseWarningMs: config.sessionCloseWarningMs || modeDefaults.sessionCloseWarningMs,
|
||||||
|
minCompletedControlsBeforeFinish: config.minCompletedControlsBeforeFinish,
|
||||||
|
punchPolicy: config.punchPolicy,
|
||||||
|
punchRadiusMeters: config.punchRadiusMeters,
|
||||||
|
requiresFocusSelection: config.requiresFocusSelection,
|
||||||
|
skipEnabled: config.skipEnabled,
|
||||||
|
skipRadiusMeters: config.skipRadiusMeters,
|
||||||
|
skipRequiresConfirm: config.skipRequiresConfirm,
|
||||||
|
autoFinishOnLastControl: config.autoFinishOnLastControl,
|
||||||
|
defaultControlScore: config.defaultControlScore,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
values: settings.values,
|
||||||
|
locks: settings.locks,
|
||||||
|
lockLifetimeActive,
|
||||||
|
},
|
||||||
|
telemetry: {
|
||||||
|
config: mergeTelemetrySources(config.telemetryConfig, playerTelemetryProfile),
|
||||||
|
playerProfile: playerTelemetryProfile,
|
||||||
|
},
|
||||||
|
presentation: {
|
||||||
|
course: config.courseStyleConfig,
|
||||||
|
track: config.trackStyleConfig,
|
||||||
|
gpsMarker: config.gpsMarkerStyleConfig,
|
||||||
|
},
|
||||||
|
feedback: {
|
||||||
|
audio: config.audioConfig,
|
||||||
|
haptics: config.hapticsConfig,
|
||||||
|
uiEffects: config.uiEffectsConfig,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
146
miniprogram/game/core/sessionRecovery.ts
Normal file
146
miniprogram/game/core/sessionRecovery.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { type LonLatPoint } from '../../utils/projection'
|
||||||
|
import { type GameLaunchEnvelope } from '../../utils/gameLaunch'
|
||||||
|
import { type GameSessionState } from './gameSessionState'
|
||||||
|
|
||||||
|
export interface RecoveryTelemetrySnapshot {
|
||||||
|
distanceMeters: number
|
||||||
|
currentSpeedKmh: number | null
|
||||||
|
averageSpeedKmh: number | null
|
||||||
|
heartRateBpm: number | null
|
||||||
|
caloriesKcal: number | null
|
||||||
|
lastGpsPoint: LonLatPoint | null
|
||||||
|
lastGpsAt: number | null
|
||||||
|
lastGpsAccuracyMeters: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecoveryViewportSnapshot {
|
||||||
|
zoom: number
|
||||||
|
centerTileX: number
|
||||||
|
centerTileY: number
|
||||||
|
rotationDeg: number
|
||||||
|
gpsLockEnabled: boolean
|
||||||
|
hasGpsCenteredOnce: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecoveryRuntimeSnapshot {
|
||||||
|
gameState: GameSessionState
|
||||||
|
telemetry: RecoveryTelemetrySnapshot
|
||||||
|
viewport: RecoveryViewportSnapshot
|
||||||
|
currentGpsPoint: LonLatPoint | null
|
||||||
|
currentGpsAccuracyMeters: number | null
|
||||||
|
currentGpsInsideMap: boolean
|
||||||
|
bonusScore: number
|
||||||
|
quizCorrectCount: number
|
||||||
|
quizWrongCount: number
|
||||||
|
quizTimeoutCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRecoverySnapshot {
|
||||||
|
schemaVersion: 1
|
||||||
|
savedAt: number
|
||||||
|
launchEnvelope: GameLaunchEnvelope
|
||||||
|
configAppId: string
|
||||||
|
configVersion: string
|
||||||
|
runtime: RecoveryRuntimeSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_RECOVERY_STORAGE_KEY = 'cmr.sessionRecovery.v1'
|
||||||
|
|
||||||
|
function cloneLonLatPoint(point: LonLatPoint | null): LonLatPoint | null {
|
||||||
|
if (!point) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
lon: point.lon,
|
||||||
|
lat: point.lat,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneGameSessionState(state: GameSessionState): GameSessionState {
|
||||||
|
return {
|
||||||
|
status: state.status,
|
||||||
|
endReason: state.endReason,
|
||||||
|
startedAt: state.startedAt,
|
||||||
|
endedAt: state.endedAt,
|
||||||
|
completedControlIds: state.completedControlIds.slice(),
|
||||||
|
skippedControlIds: state.skippedControlIds.slice(),
|
||||||
|
currentTargetControlId: state.currentTargetControlId,
|
||||||
|
inRangeControlId: state.inRangeControlId,
|
||||||
|
score: state.score,
|
||||||
|
guidanceState: state.guidanceState,
|
||||||
|
modeState: state.modeState
|
||||||
|
? JSON.parse(JSON.stringify(state.modeState)) as Record<string, unknown>
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneSessionRecoverySnapshot(snapshot: SessionRecoverySnapshot): SessionRecoverySnapshot {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
savedAt: snapshot.savedAt,
|
||||||
|
launchEnvelope: JSON.parse(JSON.stringify(snapshot.launchEnvelope)) as GameLaunchEnvelope,
|
||||||
|
configAppId: snapshot.configAppId,
|
||||||
|
configVersion: snapshot.configVersion,
|
||||||
|
runtime: {
|
||||||
|
gameState: cloneGameSessionState(snapshot.runtime.gameState),
|
||||||
|
telemetry: {
|
||||||
|
distanceMeters: snapshot.runtime.telemetry.distanceMeters,
|
||||||
|
currentSpeedKmh: snapshot.runtime.telemetry.currentSpeedKmh,
|
||||||
|
averageSpeedKmh: snapshot.runtime.telemetry.averageSpeedKmh,
|
||||||
|
heartRateBpm: snapshot.runtime.telemetry.heartRateBpm,
|
||||||
|
caloriesKcal: snapshot.runtime.telemetry.caloriesKcal,
|
||||||
|
lastGpsPoint: cloneLonLatPoint(snapshot.runtime.telemetry.lastGpsPoint),
|
||||||
|
lastGpsAt: snapshot.runtime.telemetry.lastGpsAt,
|
||||||
|
lastGpsAccuracyMeters: snapshot.runtime.telemetry.lastGpsAccuracyMeters,
|
||||||
|
},
|
||||||
|
viewport: {
|
||||||
|
zoom: snapshot.runtime.viewport.zoom,
|
||||||
|
centerTileX: snapshot.runtime.viewport.centerTileX,
|
||||||
|
centerTileY: snapshot.runtime.viewport.centerTileY,
|
||||||
|
rotationDeg: snapshot.runtime.viewport.rotationDeg,
|
||||||
|
gpsLockEnabled: snapshot.runtime.viewport.gpsLockEnabled,
|
||||||
|
hasGpsCenteredOnce: snapshot.runtime.viewport.hasGpsCenteredOnce,
|
||||||
|
},
|
||||||
|
currentGpsPoint: cloneLonLatPoint(snapshot.runtime.currentGpsPoint),
|
||||||
|
currentGpsAccuracyMeters: snapshot.runtime.currentGpsAccuracyMeters,
|
||||||
|
currentGpsInsideMap: snapshot.runtime.currentGpsInsideMap,
|
||||||
|
bonusScore: snapshot.runtime.bonusScore,
|
||||||
|
quizCorrectCount: snapshot.runtime.quizCorrectCount,
|
||||||
|
quizWrongCount: snapshot.runtime.quizWrongCount,
|
||||||
|
quizTimeoutCount: snapshot.runtime.quizTimeoutCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSessionRecoverySnapshot(raw: unknown): SessionRecoverySnapshot | null {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = raw as SessionRecoverySnapshot
|
||||||
|
if (candidate.schemaVersion !== 1 || !candidate.runtime || !candidate.runtime.gameState) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloneSessionRecoverySnapshot(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSessionRecoverySnapshot(): SessionRecoverySnapshot | null {
|
||||||
|
try {
|
||||||
|
return normalizeSessionRecoverySnapshot(wx.getStorageSync(SESSION_RECOVERY_STORAGE_KEY))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSessionRecoverySnapshot(snapshot: SessionRecoverySnapshot): void {
|
||||||
|
try {
|
||||||
|
wx.setStorageSync(SESSION_RECOVERY_STORAGE_KEY, cloneSessionRecoverySnapshot(snapshot))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionRecoverySnapshot(): void {
|
||||||
|
try {
|
||||||
|
wx.removeStorageSync(SESSION_RECOVERY_STORAGE_KEY)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
292
miniprogram/game/core/systemSettingsState.ts
Normal file
292
miniprogram/game/core/systemSettingsState.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { type AnimationLevel } from '../../utils/animationLevel'
|
||||||
|
import { type TrackColorPreset, type TrackDisplayMode, type TrackStyleProfile, type TrackTailLengthPreset } from '../presentation/trackStyleConfig'
|
||||||
|
import { type GpsMarkerColorPreset, type GpsMarkerSizePreset, type GpsMarkerStyleId } from '../presentation/gpsMarkerStyleConfig'
|
||||||
|
|
||||||
|
export type SideButtonPlacement = 'left' | 'right'
|
||||||
|
export type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center'
|
||||||
|
export type UserNorthReferenceMode = 'magnetic' | 'true'
|
||||||
|
export type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive'
|
||||||
|
|
||||||
|
export type SettingLockKey =
|
||||||
|
| 'lockAnimationLevel'
|
||||||
|
| 'lockTrackMode'
|
||||||
|
| 'lockTrackTailLength'
|
||||||
|
| 'lockTrackColor'
|
||||||
|
| 'lockTrackStyle'
|
||||||
|
| 'lockGpsMarkerVisible'
|
||||||
|
| 'lockGpsMarkerStyle'
|
||||||
|
| 'lockGpsMarkerSize'
|
||||||
|
| 'lockGpsMarkerColor'
|
||||||
|
| 'lockSideButtonPlacement'
|
||||||
|
| 'lockAutoRotate'
|
||||||
|
| 'lockCompassTuning'
|
||||||
|
| 'lockScaleRulerVisible'
|
||||||
|
| 'lockScaleRulerAnchor'
|
||||||
|
| 'lockNorthReference'
|
||||||
|
| 'lockHeartRateDevice'
|
||||||
|
|
||||||
|
export type StoredUserSettings = {
|
||||||
|
animationLevel?: AnimationLevel
|
||||||
|
trackDisplayMode?: TrackDisplayMode
|
||||||
|
trackTailLength?: TrackTailLengthPreset
|
||||||
|
trackColorPreset?: TrackColorPreset
|
||||||
|
trackStyleProfile?: TrackStyleProfile
|
||||||
|
gpsMarkerVisible?: boolean
|
||||||
|
gpsMarkerStyle?: GpsMarkerStyleId
|
||||||
|
gpsMarkerSize?: GpsMarkerSizePreset
|
||||||
|
gpsMarkerColorPreset?: GpsMarkerColorPreset
|
||||||
|
autoRotateEnabled?: boolean
|
||||||
|
compassTuningProfile?: CompassTuningProfile
|
||||||
|
northReferenceMode?: UserNorthReferenceMode
|
||||||
|
sideButtonPlacement?: SideButtonPlacement
|
||||||
|
showCenterScaleRuler?: boolean
|
||||||
|
centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemSettingsConfig {
|
||||||
|
values: Partial<StoredUserSettings>
|
||||||
|
locks: Partial<Record<SettingLockKey, boolean>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedSystemSettingsState = {
|
||||||
|
values: Required<StoredUserSettings>
|
||||||
|
locks: Record<SettingLockKey, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USER_SETTINGS_STORAGE_KEY = 'cmr_user_settings_v1'
|
||||||
|
|
||||||
|
export const DEFAULT_STORED_USER_SETTINGS: Required<StoredUserSettings> = {
|
||||||
|
animationLevel: 'standard',
|
||||||
|
trackDisplayMode: 'full',
|
||||||
|
trackTailLength: 'medium',
|
||||||
|
trackColorPreset: 'mint',
|
||||||
|
trackStyleProfile: 'neon',
|
||||||
|
gpsMarkerVisible: true,
|
||||||
|
gpsMarkerStyle: 'beacon',
|
||||||
|
gpsMarkerSize: 'medium',
|
||||||
|
gpsMarkerColorPreset: 'cyan',
|
||||||
|
autoRotateEnabled: true,
|
||||||
|
compassTuningProfile: 'balanced',
|
||||||
|
northReferenceMode: 'magnetic',
|
||||||
|
sideButtonPlacement: 'left',
|
||||||
|
showCenterScaleRuler: false,
|
||||||
|
centerScaleRulerAnchorMode: 'screen-center',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTING_LOCKS: Record<SettingLockKey, boolean> = {
|
||||||
|
lockAnimationLevel: false,
|
||||||
|
lockTrackMode: false,
|
||||||
|
lockTrackTailLength: false,
|
||||||
|
lockTrackColor: false,
|
||||||
|
lockTrackStyle: false,
|
||||||
|
lockGpsMarkerVisible: false,
|
||||||
|
lockGpsMarkerStyle: false,
|
||||||
|
lockGpsMarkerSize: false,
|
||||||
|
lockGpsMarkerColor: false,
|
||||||
|
lockSideButtonPlacement: false,
|
||||||
|
lockAutoRotate: false,
|
||||||
|
lockCompassTuning: false,
|
||||||
|
lockScaleRulerVisible: false,
|
||||||
|
lockScaleRulerAnchor: false,
|
||||||
|
lockNorthReference: false,
|
||||||
|
lockHeartRateDevice: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SETTING_LOCK_VALUE_MAP: Record<SettingLockKey, keyof StoredUserSettings | null> = {
|
||||||
|
lockAnimationLevel: 'animationLevel',
|
||||||
|
lockTrackMode: 'trackDisplayMode',
|
||||||
|
lockTrackTailLength: 'trackTailLength',
|
||||||
|
lockTrackColor: 'trackColorPreset',
|
||||||
|
lockTrackStyle: 'trackStyleProfile',
|
||||||
|
lockGpsMarkerVisible: 'gpsMarkerVisible',
|
||||||
|
lockGpsMarkerStyle: 'gpsMarkerStyle',
|
||||||
|
lockGpsMarkerSize: 'gpsMarkerSize',
|
||||||
|
lockGpsMarkerColor: 'gpsMarkerColorPreset',
|
||||||
|
lockSideButtonPlacement: 'sideButtonPlacement',
|
||||||
|
lockAutoRotate: 'autoRotateEnabled',
|
||||||
|
lockCompassTuning: 'compassTuningProfile',
|
||||||
|
lockScaleRulerVisible: 'showCenterScaleRuler',
|
||||||
|
lockScaleRulerAnchor: 'centerScaleRulerAnchorMode',
|
||||||
|
lockNorthReference: 'northReferenceMode',
|
||||||
|
lockHeartRateDevice: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStoredUserSettings(raw: unknown): StoredUserSettings {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = raw as Record<string, unknown>
|
||||||
|
const settings: StoredUserSettings = {}
|
||||||
|
if (normalized.animationLevel === 'standard' || normalized.animationLevel === 'lite') {
|
||||||
|
settings.animationLevel = normalized.animationLevel
|
||||||
|
}
|
||||||
|
if (normalized.trackDisplayMode === 'none' || normalized.trackDisplayMode === 'full' || normalized.trackDisplayMode === 'tail') {
|
||||||
|
settings.trackDisplayMode = normalized.trackDisplayMode
|
||||||
|
}
|
||||||
|
if (normalized.trackTailLength === 'short' || normalized.trackTailLength === 'medium' || normalized.trackTailLength === 'long') {
|
||||||
|
settings.trackTailLength = normalized.trackTailLength
|
||||||
|
}
|
||||||
|
if (normalized.trackStyleProfile === 'classic' || normalized.trackStyleProfile === 'neon') {
|
||||||
|
settings.trackStyleProfile = normalized.trackStyleProfile
|
||||||
|
}
|
||||||
|
if (typeof normalized.gpsMarkerVisible === 'boolean') {
|
||||||
|
settings.gpsMarkerVisible = normalized.gpsMarkerVisible
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized.gpsMarkerStyle === 'dot'
|
||||||
|
|| normalized.gpsMarkerStyle === 'beacon'
|
||||||
|
|| normalized.gpsMarkerStyle === 'disc'
|
||||||
|
|| normalized.gpsMarkerStyle === 'badge'
|
||||||
|
) {
|
||||||
|
settings.gpsMarkerStyle = normalized.gpsMarkerStyle
|
||||||
|
}
|
||||||
|
if (normalized.gpsMarkerSize === 'small' || normalized.gpsMarkerSize === 'medium' || normalized.gpsMarkerSize === 'large') {
|
||||||
|
settings.gpsMarkerSize = normalized.gpsMarkerSize
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized.gpsMarkerColorPreset === 'mint'
|
||||||
|
|| normalized.gpsMarkerColorPreset === 'cyan'
|
||||||
|
|| normalized.gpsMarkerColorPreset === 'sky'
|
||||||
|
|| normalized.gpsMarkerColorPreset === 'blue'
|
||||||
|
|| normalized.gpsMarkerColorPreset === 'violet'
|
||||||
|
|| normalized.gpsMarkerColorPreset === 'pink'
|
||||||
|
|| normalized.gpsMarkerColorPreset === 'orange'
|
||||||
|
|| normalized.gpsMarkerColorPreset === 'yellow'
|
||||||
|
) {
|
||||||
|
settings.gpsMarkerColorPreset = normalized.gpsMarkerColorPreset
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized.trackColorPreset === 'mint'
|
||||||
|
|| normalized.trackColorPreset === 'cyan'
|
||||||
|
|| normalized.trackColorPreset === 'sky'
|
||||||
|
|| normalized.trackColorPreset === 'blue'
|
||||||
|
|| normalized.trackColorPreset === 'violet'
|
||||||
|
|| normalized.trackColorPreset === 'pink'
|
||||||
|
|| normalized.trackColorPreset === 'orange'
|
||||||
|
|| normalized.trackColorPreset === 'yellow'
|
||||||
|
) {
|
||||||
|
settings.trackColorPreset = normalized.trackColorPreset
|
||||||
|
}
|
||||||
|
if (normalized.northReferenceMode === 'magnetic' || normalized.northReferenceMode === 'true') {
|
||||||
|
settings.northReferenceMode = normalized.northReferenceMode
|
||||||
|
}
|
||||||
|
if (typeof normalized.autoRotateEnabled === 'boolean') {
|
||||||
|
settings.autoRotateEnabled = normalized.autoRotateEnabled
|
||||||
|
}
|
||||||
|
if (normalized.compassTuningProfile === 'smooth' || normalized.compassTuningProfile === 'balanced' || normalized.compassTuningProfile === 'responsive') {
|
||||||
|
settings.compassTuningProfile = normalized.compassTuningProfile
|
||||||
|
}
|
||||||
|
if (normalized.sideButtonPlacement === 'left' || normalized.sideButtonPlacement === 'right') {
|
||||||
|
settings.sideButtonPlacement = normalized.sideButtonPlacement
|
||||||
|
}
|
||||||
|
if (typeof normalized.showCenterScaleRuler === 'boolean') {
|
||||||
|
settings.showCenterScaleRuler = normalized.showCenterScaleRuler
|
||||||
|
}
|
||||||
|
if (normalized.centerScaleRulerAnchorMode === 'screen-center' || normalized.centerScaleRulerAnchorMode === 'compass-center') {
|
||||||
|
settings.centerScaleRulerAnchorMode = normalized.centerScaleRulerAnchorMode
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStoredUserSettings(storageKey = USER_SETTINGS_STORAGE_KEY): StoredUserSettings {
|
||||||
|
try {
|
||||||
|
return normalizeStoredUserSettings(wx.getStorageSync(storageKey))
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistStoredUserSettings(
|
||||||
|
settings: StoredUserSettings,
|
||||||
|
storageKey = USER_SETTINGS_STORAGE_KEY,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
wx.setStorageSync(storageKey, settings)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeStoredUserSettings(
|
||||||
|
current: StoredUserSettings,
|
||||||
|
patch: Partial<StoredUserSettings>,
|
||||||
|
): StoredUserSettings {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...patch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInitialSystemSettingsState(
|
||||||
|
stored: StoredUserSettings,
|
||||||
|
config?: Partial<SystemSettingsConfig>,
|
||||||
|
): ResolvedSystemSettingsState {
|
||||||
|
const values = {
|
||||||
|
...DEFAULT_STORED_USER_SETTINGS,
|
||||||
|
...(config && config.values ? config.values : {}),
|
||||||
|
}
|
||||||
|
const locks = {
|
||||||
|
...DEFAULT_SETTING_LOCKS,
|
||||||
|
...(config && config.locks ? config.locks : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedValues: Required<StoredUserSettings> = {
|
||||||
|
...values,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [lockKey, isLocked] of Object.entries(locks) as Array<[SettingLockKey, boolean]>) {
|
||||||
|
const valueKey = SETTING_LOCK_VALUE_MAP[lockKey]
|
||||||
|
if (!valueKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!isLocked && stored[valueKey] !== undefined) {
|
||||||
|
;(resolvedValues as Record<string, unknown>)[valueKey] = stored[valueKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(stored) as Array<[keyof StoredUserSettings, StoredUserSettings[keyof StoredUserSettings]]>) {
|
||||||
|
const matchingLockKey = (Object.keys(SETTING_LOCK_VALUE_MAP) as SettingLockKey[])
|
||||||
|
.find((lockKey) => SETTING_LOCK_VALUE_MAP[lockKey] === key)
|
||||||
|
if (matchingLockKey && locks[matchingLockKey]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (value !== undefined) {
|
||||||
|
;(resolvedValues as Record<string, unknown>)[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
values: resolvedValues,
|
||||||
|
locks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRuntimeSettingLocks(
|
||||||
|
locks: Partial<Record<SettingLockKey, boolean>> | undefined,
|
||||||
|
runtimeActive: boolean,
|
||||||
|
): Partial<Record<SettingLockKey, boolean>> {
|
||||||
|
const sourceLocks = locks || {}
|
||||||
|
if (runtimeActive) {
|
||||||
|
return { ...sourceLocks }
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlocked: Partial<Record<SettingLockKey, boolean>> = {}
|
||||||
|
for (const key of Object.keys(sourceLocks) as SettingLockKey[]) {
|
||||||
|
unlocked[key] = false
|
||||||
|
}
|
||||||
|
return unlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSystemSettingsState(
|
||||||
|
config?: Partial<SystemSettingsConfig>,
|
||||||
|
storageKey = USER_SETTINGS_STORAGE_KEY,
|
||||||
|
runtimeActive = false,
|
||||||
|
): ResolvedSystemSettingsState {
|
||||||
|
return buildInitialSystemSettingsState(
|
||||||
|
loadStoredUserSettings(storageKey),
|
||||||
|
{
|
||||||
|
values: config && config.values ? config.values : {},
|
||||||
|
locks: buildRuntimeSettingLocks(config && config.locks ? config.locks : {}, runtimeActive),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export interface ContentCardActionViewModel {
|
|||||||
|
|
||||||
export const DEFAULT_CONTENT_CARD_QUIZ_CONFIG: ContentCardQuizConfig = {
|
export const DEFAULT_CONTENT_CARD_QUIZ_CONFIG: ContentCardQuizConfig = {
|
||||||
bonusScore: 1,
|
bonusScore: 1,
|
||||||
countdownSeconds: 12,
|
countdownSeconds: 10,
|
||||||
minValue: 10,
|
minValue: 10,
|
||||||
maxValue: 999,
|
maxValue: 999,
|
||||||
allowSubtraction: true,
|
allowSubtraction: true,
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { type AnimationLevel } from '../../utils/animationLevel'
|
|||||||
export type FeedbackCueKey =
|
export type FeedbackCueKey =
|
||||||
| 'session_started'
|
| 'session_started'
|
||||||
| 'session_finished'
|
| 'session_finished'
|
||||||
|
| 'hint:changed'
|
||||||
| 'control_completed:start'
|
| 'control_completed:start'
|
||||||
| 'control_completed:control'
|
| 'control_completed:control'
|
||||||
| 'control_completed:finish'
|
| 'control_completed:finish'
|
||||||
| 'punch_feedback:warning'
|
| 'punch_feedback:warning'
|
||||||
| 'guidance:searching'
|
| 'guidance:searching'
|
||||||
|
| 'guidance:distant'
|
||||||
| 'guidance:approaching'
|
| 'guidance:approaching'
|
||||||
| 'guidance:ready'
|
| 'guidance:ready'
|
||||||
|
|
||||||
@@ -83,12 +85,14 @@ export const DEFAULT_GAME_HAPTICS_CONFIG: GameHapticsConfig = {
|
|||||||
cues: {
|
cues: {
|
||||||
session_started: { enabled: false, pattern: 'short' },
|
session_started: { enabled: false, pattern: 'short' },
|
||||||
session_finished: { enabled: true, pattern: 'long' },
|
session_finished: { enabled: true, pattern: 'long' },
|
||||||
|
'hint:changed': { enabled: true, pattern: 'short' },
|
||||||
'control_completed:start': { enabled: true, pattern: 'short' },
|
'control_completed:start': { enabled: true, pattern: 'short' },
|
||||||
'control_completed:control': { enabled: true, pattern: 'short' },
|
'control_completed:control': { enabled: true, pattern: 'short' },
|
||||||
'control_completed:finish': { enabled: true, pattern: 'long' },
|
'control_completed:finish': { enabled: true, pattern: 'long' },
|
||||||
'punch_feedback:warning': { enabled: true, pattern: 'short' },
|
'punch_feedback:warning': { enabled: true, pattern: 'short' },
|
||||||
'guidance:searching': { enabled: false, pattern: 'short' },
|
'guidance:searching': { enabled: false, pattern: 'short' },
|
||||||
'guidance:approaching': { enabled: false, pattern: 'short' },
|
'guidance:distant': { enabled: true, pattern: 'short' },
|
||||||
|
'guidance:approaching': { enabled: true, pattern: 'short' },
|
||||||
'guidance:ready': { enabled: true, pattern: 'short' },
|
'guidance:ready': { enabled: true, pattern: 'short' },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -98,11 +102,13 @@ export const DEFAULT_GAME_UI_EFFECTS_CONFIG: GameUiEffectsConfig = {
|
|||||||
cues: {
|
cues: {
|
||||||
session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
||||||
session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
||||||
|
'hint:changed': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
||||||
'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 },
|
'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 },
|
||||||
'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 },
|
'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 },
|
||||||
'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', hudProgressMotion: 'finish', hudDistanceMotion: 'success', durationMs: 680 },
|
'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', hudProgressMotion: 'finish', hudDistanceMotion: 'success', durationMs: 680 },
|
||||||
'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 560 },
|
'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 560 },
|
||||||
'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
||||||
|
'guidance:distant': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
||||||
'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
|
||||||
'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 900 },
|
'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 900 },
|
||||||
},
|
},
|
||||||
@@ -137,11 +143,13 @@ export function mergeGameHapticsConfig(overrides?: GameHapticsConfigOverrides |
|
|||||||
const cues: GameHapticsConfig['cues'] = {
|
const cues: GameHapticsConfig['cues'] = {
|
||||||
session_started: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
|
session_started: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
|
||||||
session_finished: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
|
session_finished: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
|
||||||
|
'hint:changed': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['hint:changed'], overrides && overrides.cues ? overrides.cues['hint:changed'] : undefined),
|
||||||
'control_completed:start': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
|
'control_completed:start': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
|
||||||
'control_completed:control': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
|
'control_completed:control': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
|
||||||
'control_completed:finish': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
|
'control_completed:finish': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
|
||||||
'punch_feedback:warning': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
|
'punch_feedback:warning': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
|
||||||
'guidance:searching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
|
'guidance:searching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
|
||||||
|
'guidance:distant': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:distant'], overrides && overrides.cues ? overrides.cues['guidance:distant'] : undefined),
|
||||||
'guidance:approaching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
|
'guidance:approaching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
|
||||||
'guidance:ready': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
|
'guidance:ready': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
|
||||||
}
|
}
|
||||||
@@ -156,11 +164,13 @@ export function mergeGameUiEffectsConfig(overrides?: GameUiEffectsConfigOverride
|
|||||||
const cues: GameUiEffectsConfig['cues'] = {
|
const cues: GameUiEffectsConfig['cues'] = {
|
||||||
session_started: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
|
session_started: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
|
||||||
session_finished: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
|
session_finished: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
|
||||||
|
'hint:changed': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['hint:changed'], overrides && overrides.cues ? overrides.cues['hint:changed'] : undefined),
|
||||||
'control_completed:start': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
|
'control_completed:start': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
|
||||||
'control_completed:control': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
|
'control_completed:control': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
|
||||||
'control_completed:finish': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
|
'control_completed:finish': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
|
||||||
'punch_feedback:warning': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
|
'punch_feedback:warning': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
|
||||||
'guidance:searching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
|
'guidance:searching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
|
||||||
|
'guidance:distant': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:distant'], overrides && overrides.cues ? overrides.cues['guidance:distant'] : undefined),
|
||||||
'guidance:approaching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
|
'guidance:approaching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
|
||||||
'guidance:ready': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
|
'guidance:ready': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { DEFAULT_GAME_AUDIO_CONFIG, type GameAudioConfig } from '../audio/audioConfig'
|
import { DEFAULT_GAME_AUDIO_CONFIG, type AudioCueKey, type GameAudioConfig } from '../audio/audioConfig'
|
||||||
import { SoundDirector } from '../audio/soundDirector'
|
import { SoundDirector } from '../audio/soundDirector'
|
||||||
import { type GameEffect } from '../core/gameResult'
|
import { type GameEffect } from '../core/gameResult'
|
||||||
import { type AnimationLevel } from '../../utils/animationLevel'
|
import { type AnimationLevel } from '../../utils/animationLevel'
|
||||||
import {
|
import {
|
||||||
DEFAULT_GAME_HAPTICS_CONFIG,
|
DEFAULT_GAME_HAPTICS_CONFIG,
|
||||||
DEFAULT_GAME_UI_EFFECTS_CONFIG,
|
DEFAULT_GAME_UI_EFFECTS_CONFIG,
|
||||||
|
type FeedbackCueKey,
|
||||||
type GameHapticsConfig,
|
type GameHapticsConfig,
|
||||||
type GameUiEffectsConfig,
|
type GameUiEffectsConfig,
|
||||||
} from './feedbackConfig'
|
} from './feedbackConfig'
|
||||||
@@ -61,12 +62,20 @@ export class FeedbackDirector {
|
|||||||
this.soundDirector.setAppAudioMode(mode)
|
this.soundDirector.setAppAudioMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playAudioCue(key: AudioCueKey): void {
|
||||||
|
this.soundDirector.play(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
playHapticCue(key: FeedbackCueKey): void {
|
||||||
|
this.hapticsDirector.trigger(key)
|
||||||
|
}
|
||||||
|
|
||||||
handleEffects(effects: GameEffect[]): void {
|
handleEffects(effects: GameEffect[]): void {
|
||||||
this.soundDirector.handleEffects(effects)
|
this.soundDirector.handleEffects(effects)
|
||||||
this.hapticsDirector.handleEffects(effects)
|
this.hapticsDirector.handleEffects(effects)
|
||||||
this.uiEffectDirector.handleEffects(effects)
|
this.uiEffectDirector.handleEffects(effects)
|
||||||
|
|
||||||
if (effects.some((effect) => effect.type === 'session_finished')) {
|
if (effects.some((effect) => effect.type === 'session_finished' || effect.type === 'session_timed_out')) {
|
||||||
this.host.stopLocationTracking()
|
this.host.stopLocationTracking()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export class HapticsDirector {
|
|||||||
this.trigger('guidance:searching')
|
this.trigger('guidance:searching')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (effect.guidanceState === 'distant') {
|
||||||
|
this.trigger('guidance:distant')
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (effect.guidanceState === 'approaching') {
|
if (effect.guidanceState === 'approaching') {
|
||||||
this.trigger('guidance:approaching')
|
this.trigger('guidance:approaching')
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export class UiEffectDirector {
|
|||||||
'success',
|
'success',
|
||||||
cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '',
|
cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '',
|
||||||
)
|
)
|
||||||
|
if (effect.controlKind !== 'finish' && effect.displayAutoPopup) {
|
||||||
this.host.showContentCard(
|
this.host.showContentCard(
|
||||||
effect.displayTitle,
|
effect.displayTitle,
|
||||||
effect.displayBody,
|
effect.displayBody,
|
||||||
@@ -269,6 +270,7 @@ export class UiEffectDirector {
|
|||||||
priority: effect.displayPriority,
|
priority: effect.displayPriority,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
if (cue && cue.mapPulseMotion !== 'none') {
|
if (cue && cue.mapPulseMotion !== 'none') {
|
||||||
this.host.showMapPulse(effect.controlId, this.getMapPulseMotionClass(cue.mapPulseMotion))
|
this.host.showMapPulse(effect.controlId, this.getMapPulseMotionClass(cue.mapPulseMotion))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,15 +72,15 @@ export const DEFAULT_COURSE_STYLE_CONFIG: CourseStyleConfig = {
|
|||||||
},
|
},
|
||||||
scoreO: {
|
scoreO: {
|
||||||
controls: {
|
controls: {
|
||||||
default: { style: 'badge', colorHex: '#cc006b', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02 },
|
default: { style: 'badge', colorHex: '#cc006b', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02, labelColorHex: '#ffffff' },
|
||||||
focused: { style: 'pulse-core', colorHex: '#fff0fa', sizeScale: 1.12, accentRingScale: 1.36, glowStrength: 1, labelScale: 1.12, labelColorHex: '#fffafc' },
|
focused: { style: 'badge', colorHex: '#cc006b', sizeScale: 1.1, accentRingScale: 1.34, glowStrength: 0.92, labelScale: 1.08, labelColorHex: '#ffffff' },
|
||||||
collected: { style: 'solid-dot', colorHex: '#d6dae0', sizeScale: 0.82, labelScale: 0.92 },
|
collected: { style: 'badge', colorHex: '#9aa3ad', sizeScale: 0.86, accentRingScale: 1.08, labelScale: 0.94, labelColorHex: '#ffffff' },
|
||||||
start: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.02, accentRingScale: 1.24, labelScale: 1.02 },
|
start: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.02, accentRingScale: 1.24, labelScale: 1.02 },
|
||||||
finish: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.06, accentRingScale: 1.28, glowStrength: 0.26, labelScale: 1.04, labelColorHex: '#fff4de' },
|
finish: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.06, accentRingScale: 1.28, glowStrength: 0.26, labelScale: 1.04, labelColorHex: '#fff4de' },
|
||||||
scoreBands: [
|
scoreBands: [
|
||||||
{ min: 0, max: 19, style: 'badge', colorHex: '#56ccf2', sizeScale: 0.88, accentRingScale: 1.06, labelScale: 0.94 },
|
{ min: 0, max: 19, style: 'badge', colorHex: '#56ccf2', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02, labelColorHex: '#ffffff' },
|
||||||
{ min: 20, max: 49, style: 'badge', colorHex: '#f2c94c', sizeScale: 1.02, accentRingScale: 1.18, labelScale: 1.02 },
|
{ min: 20, max: 49, style: 'badge', colorHex: '#f2c94c', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02, labelColorHex: '#ffffff' },
|
||||||
{ min: 50, max: 999999, style: 'badge', colorHex: '#eb5757', sizeScale: 1.14, accentRingScale: 1.32, glowStrength: 0.72, labelScale: 1.1 },
|
{ min: 50, max: 999999, style: 'badge', colorHex: '#eb5757', sizeScale: 0.96, accentRingScale: 1.1, glowStrength: 0.72, labelScale: 1.02, labelColorHex: '#ffffff' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface HudPresentationState {
|
export interface HudPresentationState {
|
||||||
actionTagText: string
|
actionTagText: string
|
||||||
distanceTagText: string
|
distanceTagText: string
|
||||||
|
targetSummaryText: string
|
||||||
hudTargetControlId: string | null
|
hudTargetControlId: string | null
|
||||||
progressText: string
|
progressText: string
|
||||||
punchableControlId: string | null
|
punchableControlId: string | null
|
||||||
@@ -12,6 +13,7 @@ export interface HudPresentationState {
|
|||||||
export const EMPTY_HUD_PRESENTATION_STATE: HudPresentationState = {
|
export const EMPTY_HUD_PRESENTATION_STATE: HudPresentationState = {
|
||||||
actionTagText: '目标',
|
actionTagText: '目标',
|
||||||
distanceTagText: '点距',
|
distanceTagText: '点距',
|
||||||
|
targetSummaryText: '等待选择目标',
|
||||||
hudTargetControlId: null,
|
hudTargetControlId: null,
|
||||||
progressText: '0/0',
|
progressText: '0/0',
|
||||||
punchableControlId: null,
|
punchableControlId: null,
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from './hudPresentationState'
|
import { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from './hudPresentationState'
|
||||||
import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from './mapPresentationState'
|
import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from './mapPresentationState'
|
||||||
|
|
||||||
|
export interface GameTargetingPresentationState {
|
||||||
|
punchableControlId: string | null
|
||||||
|
guidanceControlId: string | null
|
||||||
|
hudControlId: string | null
|
||||||
|
highlightedControlId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface GamePresentationState {
|
export interface GamePresentationState {
|
||||||
map: MapPresentationState
|
map: MapPresentationState
|
||||||
hud: HudPresentationState
|
hud: HudPresentationState
|
||||||
|
targeting: GameTargetingPresentationState
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_GAME_PRESENTATION_STATE: GamePresentationState = {
|
export const EMPTY_GAME_PRESENTATION_STATE: GamePresentationState = {
|
||||||
map: EMPTY_MAP_PRESENTATION_STATE,
|
map: EMPTY_MAP_PRESENTATION_STATE,
|
||||||
hud: EMPTY_HUD_PRESENTATION_STATE,
|
hud: EMPTY_HUD_PRESENTATION_STATE,
|
||||||
|
targeting: {
|
||||||
|
punchableControlId: null,
|
||||||
|
guidanceControlId: null,
|
||||||
|
hudControlId: null,
|
||||||
|
highlightedControlId: null,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ export interface ResultSummarySnapshot {
|
|||||||
rows: ResultSummaryRow[]
|
rows: ResultSummaryRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResultSummaryMetrics {
|
||||||
|
totalScore?: number
|
||||||
|
baseScore?: number
|
||||||
|
bonusScore?: number
|
||||||
|
quizCorrectCount?: number
|
||||||
|
quizWrongCount?: number
|
||||||
|
quizTimeoutCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
function resolveTitle(definition: GameDefinition | null, mapTitle: string): string {
|
function resolveTitle(definition: GameDefinition | null, mapTitle: string): string {
|
||||||
if (mapTitle) {
|
if (mapTitle) {
|
||||||
return mapTitle
|
return mapTitle
|
||||||
@@ -25,11 +34,19 @@ function resolveTitle(definition: GameDefinition | null, mapTitle: string): stri
|
|||||||
return '本局结果'
|
return '本局结果'
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHeroValue(definition: GameDefinition | null, sessionState: GameSessionState, telemetryPresentation: TelemetryPresentation): string {
|
function buildHeroValue(
|
||||||
|
definition: GameDefinition | null,
|
||||||
|
sessionState: GameSessionState,
|
||||||
|
telemetryPresentation: TelemetryPresentation,
|
||||||
|
metrics?: ResultSummaryMetrics,
|
||||||
|
): string {
|
||||||
|
const totalScore = metrics && typeof metrics.totalScore === 'number'
|
||||||
|
? metrics.totalScore
|
||||||
|
: sessionState.score
|
||||||
if (definition && definition.mode === 'score-o') {
|
if (definition && definition.mode === 'score-o') {
|
||||||
return `${sessionState.score}`
|
return `${totalScore}`
|
||||||
}
|
}
|
||||||
return telemetryPresentation.timerText
|
return telemetryPresentation.elapsedTimerText
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHeroLabel(definition: GameDefinition | null): string {
|
function buildHeroLabel(definition: GameDefinition | null): string {
|
||||||
@@ -40,6 +57,9 @@ function buildSubtitle(sessionState: GameSessionState): string {
|
|||||||
if (sessionState.status === 'finished') {
|
if (sessionState.status === 'finished') {
|
||||||
return '本局已完成'
|
return '本局已完成'
|
||||||
}
|
}
|
||||||
|
if (sessionState.endReason === 'timed_out') {
|
||||||
|
return '本局超时结束'
|
||||||
|
}
|
||||||
if (sessionState.status === 'failed') {
|
if (sessionState.status === 'failed') {
|
||||||
return '本局已结束'
|
return '本局已结束'
|
||||||
}
|
}
|
||||||
@@ -51,9 +71,11 @@ export function buildResultSummarySnapshot(
|
|||||||
sessionState: GameSessionState | null,
|
sessionState: GameSessionState | null,
|
||||||
telemetryPresentation: TelemetryPresentation,
|
telemetryPresentation: TelemetryPresentation,
|
||||||
mapTitle: string,
|
mapTitle: string,
|
||||||
|
metrics?: ResultSummaryMetrics,
|
||||||
): ResultSummarySnapshot {
|
): ResultSummarySnapshot {
|
||||||
const resolvedSessionState: GameSessionState = sessionState || {
|
const resolvedSessionState: GameSessionState = sessionState || {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
endReason: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
completedControlIds: [],
|
completedControlIds: [],
|
||||||
@@ -71,21 +93,45 @@ export function buildResultSummarySnapshot(
|
|||||||
const averageHeartRateText = telemetryPresentation.heartRateValueText !== '--'
|
const averageHeartRateText = telemetryPresentation.heartRateValueText !== '--'
|
||||||
? `${telemetryPresentation.heartRateValueText} ${telemetryPresentation.heartRateUnitText || 'bpm'}`
|
? `${telemetryPresentation.heartRateValueText} ${telemetryPresentation.heartRateUnitText || 'bpm'}`
|
||||||
: '--'
|
: '--'
|
||||||
|
const totalScore = metrics && typeof metrics.totalScore === 'number' ? metrics.totalScore : resolvedSessionState.score
|
||||||
|
const baseScore = metrics && typeof metrics.baseScore === 'number' ? metrics.baseScore : resolvedSessionState.score
|
||||||
|
const bonusScore = metrics && typeof metrics.bonusScore === 'number' ? metrics.bonusScore : 0
|
||||||
|
const quizCorrectCount = metrics && typeof metrics.quizCorrectCount === 'number' ? metrics.quizCorrectCount : 0
|
||||||
|
const quizWrongCount = metrics && typeof metrics.quizWrongCount === 'number' ? metrics.quizWrongCount : 0
|
||||||
|
const quizTimeoutCount = metrics && typeof metrics.quizTimeoutCount === 'number' ? metrics.quizTimeoutCount : 0
|
||||||
|
const includeQuizRows = bonusScore > 0 || quizCorrectCount > 0 || quizWrongCount > 0 || quizTimeoutCount > 0
|
||||||
|
const rows: ResultSummaryRow[] = [
|
||||||
|
{
|
||||||
|
label: '状态',
|
||||||
|
value: resolvedSessionState.endReason === 'timed_out'
|
||||||
|
? '超时结束'
|
||||||
|
: resolvedSessionState.status === 'finished'
|
||||||
|
? '完成'
|
||||||
|
: (resolvedSessionState.status === 'failed' ? '结束' : '进行中'),
|
||||||
|
},
|
||||||
|
{ label: '完成点数', value: totalControlCount > 0 ? `${resolvedSessionState.completedControlIds.length}/${totalControlCount}` : `${resolvedSessionState.completedControlIds.length}` },
|
||||||
|
{ label: '跳过点数', value: `${skippedCount}` },
|
||||||
|
{ label: '总分', value: `${totalScore}` },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (includeQuizRows) {
|
||||||
|
rows.push({ label: '基础积分', value: `${baseScore}` })
|
||||||
|
rows.push({ label: '答题奖励积分', value: `${bonusScore}` })
|
||||||
|
rows.push({ label: '答题正确数', value: `${quizCorrectCount}` })
|
||||||
|
rows.push({ label: '答题错误数', value: `${quizWrongCount}` })
|
||||||
|
rows.push({ label: '答题超时数', value: `${quizTimeoutCount}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({ label: '累计里程', value: telemetryPresentation.mileageText })
|
||||||
|
rows.push({ label: '平均速度', value: `${telemetryPresentation.averageSpeedValueText}${telemetryPresentation.averageSpeedUnitText}` })
|
||||||
|
rows.push({ label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` })
|
||||||
|
rows.push({ label: '平均心率', value: averageHeartRateText })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: resolveTitle(definition, mapTitle),
|
title: resolveTitle(definition, mapTitle),
|
||||||
subtitle: buildSubtitle(resolvedSessionState),
|
subtitle: buildSubtitle(resolvedSessionState),
|
||||||
heroLabel: buildHeroLabel(definition),
|
heroLabel: buildHeroLabel(definition),
|
||||||
heroValue: buildHeroValue(definition, resolvedSessionState, telemetryPresentation),
|
heroValue: buildHeroValue(definition, resolvedSessionState, telemetryPresentation, metrics),
|
||||||
rows: [
|
rows,
|
||||||
{ label: '状态', value: resolvedSessionState.status === 'finished' ? '完成' : (resolvedSessionState.status === 'failed' ? '结束' : '进行中') },
|
|
||||||
{ label: '完成点数', value: totalControlCount > 0 ? `${resolvedSessionState.completedControlIds.length}/${totalControlCount}` : `${resolvedSessionState.completedControlIds.length}` },
|
|
||||||
{ label: '跳过点数', value: `${skippedCount}` },
|
|
||||||
{ label: '累计里程', value: telemetryPresentation.mileageText },
|
|
||||||
{ label: '平均速度', value: `${telemetryPresentation.averageSpeedValueText}${telemetryPresentation.averageSpeedUnitText}` },
|
|
||||||
{ label: '当前得分', value: `${resolvedSessionState.score}` },
|
|
||||||
{ label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` },
|
|
||||||
{ label: '平均心率', value: averageHeartRateText },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,15 +79,23 @@ function getTargetText(control: GameControl): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
|
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
|
||||||
if (distanceMeters <= definition.punchRadiusMeters) {
|
const audioConfig = definition.audioConfig || DEFAULT_GAME_AUDIO_CONFIG
|
||||||
|
const readyDistanceMeters = Math.max(definition.punchRadiusMeters, audioConfig.readyDistanceMeters)
|
||||||
|
const approachDistanceMeters = Math.max(readyDistanceMeters, audioConfig.approachDistanceMeters)
|
||||||
|
const distantDistanceMeters = Math.max(approachDistanceMeters, audioConfig.distantDistanceMeters)
|
||||||
|
|
||||||
|
if (distanceMeters <= readyDistanceMeters) {
|
||||||
return 'ready'
|
return 'ready'
|
||||||
}
|
}
|
||||||
|
|
||||||
const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
|
|
||||||
if (distanceMeters <= approachDistanceMeters) {
|
if (distanceMeters <= approachDistanceMeters) {
|
||||||
return 'approaching'
|
return 'approaching'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (distanceMeters <= distantDistanceMeters) {
|
||||||
|
return 'distant'
|
||||||
|
}
|
||||||
|
|
||||||
return 'searching'
|
return 'searching'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +137,29 @@ function buildPunchHintText(definition: GameDefinition, state: GameSessionState,
|
|||||||
: `${targetText}内,可点击打点`
|
: `${targetText}内,可点击打点`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTargetSummaryText(state: GameSessionState, currentTarget: GameControl | null): string {
|
||||||
|
if (state.status === 'finished') {
|
||||||
|
return '本局已完成'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentTarget) {
|
||||||
|
return '等待路线初始化'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTarget.kind === 'start') {
|
||||||
|
return `${currentTarget.label} / 先打开始点`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTarget.kind === 'finish') {
|
||||||
|
return `${currentTarget.label} / 前往终点`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sequenceText = typeof currentTarget.sequence === 'number'
|
||||||
|
? `第 ${currentTarget.sequence} 点`
|
||||||
|
: '当前目标点'
|
||||||
|
return `${sequenceText} / ${currentTarget.label}`
|
||||||
|
}
|
||||||
|
|
||||||
function buildSkipFeedbackText(currentTarget: GameControl): string {
|
function buildSkipFeedbackText(currentTarget: GameControl): string {
|
||||||
if (currentTarget.kind === 'start') {
|
if (currentTarget.kind === 'start') {
|
||||||
return '开始点不可跳过'
|
return '开始点不可跳过'
|
||||||
@@ -193,6 +224,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
|||||||
const hudPresentation: HudPresentationState = {
|
const hudPresentation: HudPresentationState = {
|
||||||
actionTagText: '目标',
|
actionTagText: '目标',
|
||||||
distanceTagText: '点距',
|
distanceTagText: '点距',
|
||||||
|
targetSummaryText: buildTargetSummaryText(state, currentTarget),
|
||||||
hudTargetControlId: currentTarget ? currentTarget.id : null,
|
hudTargetControlId: currentTarget ? currentTarget.id : null,
|
||||||
progressText: '0/0',
|
progressText: '0/0',
|
||||||
punchButtonText,
|
punchButtonText,
|
||||||
@@ -200,6 +232,12 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
|||||||
punchButtonEnabled,
|
punchButtonEnabled,
|
||||||
punchHintText: buildPunchHintText(definition, state, currentTarget),
|
punchHintText: buildPunchHintText(definition, state, currentTarget),
|
||||||
}
|
}
|
||||||
|
const targetingPresentation = {
|
||||||
|
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
|
||||||
|
guidanceControlId: currentTarget ? currentTarget.id : null,
|
||||||
|
hudControlId: currentTarget ? currentTarget.id : null,
|
||||||
|
highlightedControlId: running && currentTarget ? currentTarget.id : null,
|
||||||
|
}
|
||||||
|
|
||||||
if (!scoringControls.length) {
|
if (!scoringControls.length) {
|
||||||
return {
|
return {
|
||||||
@@ -223,6 +261,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
|||||||
skippedControlSequences: [],
|
skippedControlSequences: [],
|
||||||
},
|
},
|
||||||
hud: hudPresentation,
|
hud: hudPresentation,
|
||||||
|
targeting: targetingPresentation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +294,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
|||||||
...hudPresentation,
|
...hudPresentation,
|
||||||
progressText: `${completedControls.length}/${scoringControls.length}`,
|
progressText: `${completedControls.length}/${scoringControls.length}`,
|
||||||
},
|
},
|
||||||
|
targeting: targetingPresentation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +323,9 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
|||||||
const allowAutoPopup = punchPolicy === 'enter'
|
const allowAutoPopup = punchPolicy === 'enter'
|
||||||
? false
|
? false
|
||||||
: (control.displayContent ? control.displayContent.autoPopup : true)
|
: (control.displayContent ? control.displayContent.autoPopup : true)
|
||||||
|
const autoOpenQuiz = control.kind === 'control'
|
||||||
|
&& !!control.displayContent
|
||||||
|
&& control.displayContent.ctas.some((item) => item.type === 'quiz')
|
||||||
if (control.kind === 'start') {
|
if (control.kind === 'start') {
|
||||||
return {
|
return {
|
||||||
type: 'control_completed',
|
type: 'control_completed',
|
||||||
@@ -295,6 +338,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
|||||||
displayAutoPopup: allowAutoPopup,
|
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,
|
||||||
|
autoOpenQuiz: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +354,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
|||||||
displayAutoPopup: allowAutoPopup,
|
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,
|
||||||
|
autoOpenQuiz: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +373,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
|||||||
displayAutoPopup: allowAutoPopup,
|
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,
|
||||||
|
autoOpenQuiz,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +386,7 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
|
|||||||
const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl)
|
const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl)
|
||||||
const nextState: GameSessionState = {
|
const nextState: GameSessionState = {
|
||||||
...state,
|
...state,
|
||||||
|
endReason: finished ? 'completed' : state.endReason,
|
||||||
startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt,
|
startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt,
|
||||||
completedControlIds,
|
completedControlIds,
|
||||||
skippedControlIds: currentTarget.id === state.currentTargetControlId
|
skippedControlIds: currentTarget.id === state.currentTargetControlId
|
||||||
@@ -410,6 +457,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
|||||||
initialize(definition: GameDefinition): GameSessionState {
|
initialize(definition: GameDefinition): GameSessionState {
|
||||||
return {
|
return {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
endReason: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
completedControlIds: [],
|
completedControlIds: [],
|
||||||
@@ -434,6 +482,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
|||||||
const nextState: GameSessionState = {
|
const nextState: GameSessionState = {
|
||||||
...state,
|
...state,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
endReason: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
inRangeControlId: null,
|
inRangeControlId: null,
|
||||||
@@ -454,6 +503,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
|||||||
const nextState: GameSessionState = {
|
const nextState: GameSessionState = {
|
||||||
...state,
|
...state,
|
||||||
status: 'finished',
|
status: 'finished',
|
||||||
|
endReason: 'completed',
|
||||||
endedAt: event.at,
|
endedAt: event.at,
|
||||||
guidanceState: 'searching',
|
guidanceState: 'searching',
|
||||||
modeState: {
|
modeState: {
|
||||||
@@ -468,6 +518,25 @@ export class ClassicSequentialRule implements RulePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.type === 'session_timed_out') {
|
||||||
|
const nextState: GameSessionState = {
|
||||||
|
...state,
|
||||||
|
status: 'failed',
|
||||||
|
endReason: 'timed_out',
|
||||||
|
endedAt: event.at,
|
||||||
|
guidanceState: 'searching',
|
||||||
|
modeState: {
|
||||||
|
mode: 'classic-sequential',
|
||||||
|
phase: 'done',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
nextState,
|
||||||
|
presentation: buildPresentation(definition, nextState),
|
||||||
|
effects: [{ type: 'session_timed_out' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (state.status !== 'running' || !state.currentTargetControlId) {
|
if (state.status !== 'running' || !state.currentTargetControlId) {
|
||||||
return {
|
return {
|
||||||
nextState: state,
|
nextState: state,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { type RulePlugin } from './rulePlugin'
|
|||||||
type ScoreOModeState = {
|
type ScoreOModeState = {
|
||||||
phase: 'start' | 'controls' | 'finish' | 'done'
|
phase: 'start' | 'controls' | 'finish' | 'done'
|
||||||
focusedControlId: string | null
|
focusedControlId: string | null
|
||||||
|
guidanceControlId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
|
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
|
||||||
@@ -46,6 +47,7 @@ function getModeState(state: GameSessionState): ScoreOModeState {
|
|||||||
return {
|
return {
|
||||||
phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start',
|
phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start',
|
||||||
focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null,
|
focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null,
|
||||||
|
guidanceControlId: rawModeState && typeof rawModeState.guidanceControlId === 'string' ? rawModeState.guidanceControlId : null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,12 +58,27 @@ function withModeState(state: GameSessionState, modeState: ScoreOModeState): Gam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasCompletedEnoughControlsForFinish(definition: GameDefinition, state: GameSessionState): boolean {
|
||||||
|
const completedScoreControls = getScoreControls(definition)
|
||||||
|
.filter((control) => state.completedControlIds.includes(control.id))
|
||||||
|
.length
|
||||||
|
return completedScoreControls >= definition.minCompletedControlsBeforeFinish
|
||||||
|
}
|
||||||
|
|
||||||
function canFocusFinish(definition: GameDefinition, state: GameSessionState): boolean {
|
function canFocusFinish(definition: GameDefinition, state: GameSessionState): boolean {
|
||||||
const startControl = getStartControl(definition)
|
const startControl = getStartControl(definition)
|
||||||
const finishControl = getFinishControl(definition)
|
const finishControl = getFinishControl(definition)
|
||||||
const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
|
const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
|
||||||
const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
|
const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
|
||||||
return completedStart && !completedFinish
|
return completedStart && !completedFinish && hasCompletedEnoughControlsForFinish(definition, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFinishPunchAvailable(
|
||||||
|
definition: GameDefinition,
|
||||||
|
state: GameSessionState,
|
||||||
|
_modeState: ScoreOModeState,
|
||||||
|
): boolean {
|
||||||
|
return canFocusFinish(definition, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNearestRemainingControl(
|
function getNearestRemainingControl(
|
||||||
@@ -91,6 +108,38 @@ function getNearestRemainingControl(
|
|||||||
return nearestControl
|
return nearestControl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNearestGuidanceTarget(
|
||||||
|
definition: GameDefinition,
|
||||||
|
state: GameSessionState,
|
||||||
|
modeState: ScoreOModeState,
|
||||||
|
referencePoint: LonLatPoint,
|
||||||
|
): GameControl | null {
|
||||||
|
const candidates = getRemainingScoreControls(definition, state).slice()
|
||||||
|
if (isFinishPunchAvailable(definition, state, modeState)) {
|
||||||
|
const finishControl = getFinishControl(definition)
|
||||||
|
if (finishControl && !state.completedControlIds.includes(finishControl.id)) {
|
||||||
|
candidates.push(finishControl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidates.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let nearestControl = candidates[0]
|
||||||
|
let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point)
|
||||||
|
for (let index = 1; index < candidates.length; index += 1) {
|
||||||
|
const control = candidates[index]
|
||||||
|
const distance = getApproxDistanceMeters(referencePoint, control.point)
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearestControl = control
|
||||||
|
nearestDistance = distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearestControl
|
||||||
|
}
|
||||||
|
|
||||||
function getFocusedTarget(
|
function getFocusedTarget(
|
||||||
definition: GameDefinition,
|
definition: GameDefinition,
|
||||||
state: GameSessionState,
|
state: GameSessionState,
|
||||||
@@ -118,6 +167,7 @@ function getFocusedTarget(
|
|||||||
|
|
||||||
function resolveInteractiveTarget(
|
function resolveInteractiveTarget(
|
||||||
definition: GameDefinition,
|
definition: GameDefinition,
|
||||||
|
state: GameSessionState,
|
||||||
modeState: ScoreOModeState,
|
modeState: ScoreOModeState,
|
||||||
primaryTarget: GameControl | null,
|
primaryTarget: GameControl | null,
|
||||||
focusedTarget: GameControl | null,
|
focusedTarget: GameControl | null,
|
||||||
@@ -126,11 +176,23 @@ function resolveInteractiveTarget(
|
|||||||
return primaryTarget
|
return primaryTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (modeState.phase === 'finish') {
|
||||||
|
return primaryTarget
|
||||||
|
}
|
||||||
|
|
||||||
if (definition.requiresFocusSelection) {
|
if (definition.requiresFocusSelection) {
|
||||||
return focusedTarget
|
return focusedTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
return focusedTarget || primaryTarget
|
if (focusedTarget) {
|
||||||
|
return focusedTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState)) {
|
||||||
|
return getFinishControl(definition)
|
||||||
|
}
|
||||||
|
|
||||||
|
return primaryTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNearestInRangeControl(
|
function getNearestInRangeControl(
|
||||||
@@ -157,15 +219,23 @@ function getNearestInRangeControl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
|
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
|
||||||
if (distanceMeters <= definition.punchRadiusMeters) {
|
const audioConfig = definition.audioConfig || DEFAULT_GAME_AUDIO_CONFIG
|
||||||
|
const readyDistanceMeters = Math.max(definition.punchRadiusMeters, audioConfig.readyDistanceMeters)
|
||||||
|
const approachDistanceMeters = Math.max(readyDistanceMeters, audioConfig.approachDistanceMeters)
|
||||||
|
const distantDistanceMeters = Math.max(approachDistanceMeters, audioConfig.distantDistanceMeters)
|
||||||
|
|
||||||
|
if (distanceMeters <= readyDistanceMeters) {
|
||||||
return 'ready'
|
return 'ready'
|
||||||
}
|
}
|
||||||
|
|
||||||
const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
|
|
||||||
if (distanceMeters <= approachDistanceMeters) {
|
if (distanceMeters <= approachDistanceMeters) {
|
||||||
return 'approaching'
|
return 'approaching'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (distanceMeters <= distantDistanceMeters) {
|
||||||
|
return 'distant'
|
||||||
|
}
|
||||||
|
|
||||||
return 'searching'
|
return 'searching'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,13 +280,11 @@ function buildPunchHintText(
|
|||||||
|
|
||||||
const modeState = getModeState(state)
|
const modeState = getModeState(state)
|
||||||
if (modeState.phase === 'controls' || modeState.phase === 'finish') {
|
if (modeState.phase === 'controls' || modeState.phase === 'finish') {
|
||||||
if (definition.requiresFocusSelection && !focusedTarget) {
|
if (modeState.phase === 'controls' && definition.requiresFocusSelection && !focusedTarget) {
|
||||||
return modeState.phase === 'finish'
|
return '点击地图选中一个目标点'
|
||||||
? '点击地图选中终点后结束比赛'
|
|
||||||
: '点击地图选中一个目标点'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
|
const displayTarget = resolveInteractiveTarget(definition, state, modeState, primaryTarget, focusedTarget)
|
||||||
const targetLabel = getDisplayTargetLabel(displayTarget)
|
const targetLabel = getDisplayTargetLabel(displayTarget)
|
||||||
if (displayTarget && state.inRangeControlId === displayTarget.id) {
|
if (displayTarget && state.inRangeControlId === displayTarget.id) {
|
||||||
return definition.punchPolicy === 'enter'
|
return definition.punchPolicy === 'enter'
|
||||||
@@ -241,10 +309,55 @@ function buildPunchHintText(
|
|||||||
: `进入${targetLabel}后点击打点`
|
: `进入${targetLabel}后点击打点`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTargetSummaryText(
|
||||||
|
definition: GameDefinition,
|
||||||
|
state: GameSessionState,
|
||||||
|
primaryTarget: GameControl | null,
|
||||||
|
focusedTarget: GameControl | null,
|
||||||
|
): string {
|
||||||
|
if (state.status === 'idle') {
|
||||||
|
return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === 'finished') {
|
||||||
|
return '本局已完成'
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeState = getModeState(state)
|
||||||
|
if (modeState.phase === 'start') {
|
||||||
|
return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeState.phase === 'finish') {
|
||||||
|
return primaryTarget ? `${primaryTarget.label} / 可随时结束` : '可前往终点结束'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedTarget && focusedTarget.kind === 'control') {
|
||||||
|
return `${focusedTarget.label} / ${getControlScore(focusedTarget)} 分目标`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedTarget && focusedTarget.kind === 'finish') {
|
||||||
|
return `${focusedTarget.label} / 结束比赛`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.requiresFocusSelection) {
|
||||||
|
return '请选择目标点'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryTarget && primaryTarget.kind === 'control') {
|
||||||
|
return `${primaryTarget.label} / ${getControlScore(primaryTarget)} 分目标`
|
||||||
|
}
|
||||||
|
|
||||||
|
return primaryTarget ? primaryTarget.label : '自由打点'
|
||||||
|
}
|
||||||
|
|
||||||
function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect {
|
function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect {
|
||||||
const allowAutoPopup = punchPolicy === 'enter'
|
const allowAutoPopup = punchPolicy === 'enter'
|
||||||
? false
|
? false
|
||||||
: (control.displayContent ? control.displayContent.autoPopup : true)
|
: (control.displayContent ? control.displayContent.autoPopup : true)
|
||||||
|
const autoOpenQuiz = control.kind === 'control'
|
||||||
|
&& !!control.displayContent
|
||||||
|
&& control.displayContent.ctas.some((item) => item.type === 'quiz')
|
||||||
if (control.kind === 'start') {
|
if (control.kind === 'start') {
|
||||||
return {
|
return {
|
||||||
type: 'control_completed',
|
type: 'control_completed',
|
||||||
@@ -257,6 +370,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
|||||||
displayAutoPopup: allowAutoPopup,
|
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,
|
||||||
|
autoOpenQuiz: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +386,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
|||||||
displayAutoPopup: allowAutoPopup,
|
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,
|
||||||
|
autoOpenQuiz: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,9 +402,50 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[
|
|||||||
displayAutoPopup: allowAutoPopup,
|
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,
|
||||||
|
autoOpenQuiz,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePunchableControl(
|
||||||
|
definition: GameDefinition,
|
||||||
|
state: GameSessionState,
|
||||||
|
modeState: ScoreOModeState,
|
||||||
|
focusedTarget: GameControl | null,
|
||||||
|
): GameControl | null {
|
||||||
|
if (!state.inRangeControlId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const inRangeControl = definition.controls.find((control) => control.id === state.inRangeControlId) || null
|
||||||
|
if (!inRangeControl) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeState.phase === 'start') {
|
||||||
|
return inRangeControl.kind === 'start' ? inRangeControl : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeState.phase === 'finish') {
|
||||||
|
return inRangeControl.kind === 'finish' ? inRangeControl : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeState.phase === 'controls') {
|
||||||
|
if (inRangeControl.kind === 'finish' && isFinishPunchAvailable(definition, state, modeState)) {
|
||||||
|
return inRangeControl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.requiresFocusSelection) {
|
||||||
|
return focusedTarget && inRangeControl.id === focusedTarget.id ? inRangeControl : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inRangeControl.kind === 'control') {
|
||||||
|
return inRangeControl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
|
function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
|
||||||
const modeState = getModeState(state)
|
const modeState = getModeState(state)
|
||||||
const running = state.status === 'running'
|
const running = state.status === 'running'
|
||||||
@@ -315,14 +471,35 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
|||||||
.filter((control) => typeof control.sequence === 'number')
|
.filter((control) => typeof control.sequence === 'number')
|
||||||
.map((control) => control.sequence as number)
|
.map((control) => control.sequence as number)
|
||||||
const revealFullCourse = completedStart
|
const revealFullCourse = completedStart
|
||||||
const interactiveTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
|
const punchableControl = resolvePunchableControl(definition, state, modeState, focusedTarget)
|
||||||
|
const guidanceControl = modeState.guidanceControlId
|
||||||
|
? definition.controls.find((control) => control.id === modeState.guidanceControlId) || null
|
||||||
|
: null
|
||||||
const punchButtonEnabled = running
|
const punchButtonEnabled = running
|
||||||
&& definition.punchPolicy === 'enter-confirm'
|
&& definition.punchPolicy === 'enter-confirm'
|
||||||
&& !!interactiveTarget
|
&& !!punchableControl
|
||||||
&& state.inRangeControlId === interactiveTarget.id
|
const hudTargetControlId = modeState.phase === 'finish'
|
||||||
|
? (primaryTarget ? primaryTarget.id : null)
|
||||||
|
: focusedTarget
|
||||||
|
? focusedTarget.id
|
||||||
|
: modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState)
|
||||||
|
? (getFinishControl(definition) ? getFinishControl(definition)!.id : null)
|
||||||
|
: definition.requiresFocusSelection
|
||||||
|
? null
|
||||||
|
: primaryTarget
|
||||||
|
? primaryTarget.id
|
||||||
|
: null
|
||||||
|
const highlightedControlId = focusedTarget
|
||||||
|
? focusedTarget.id
|
||||||
|
: punchableControl
|
||||||
|
? punchableControl.id
|
||||||
|
: guidanceControl
|
||||||
|
? guidanceControl.id
|
||||||
|
: null
|
||||||
|
|
||||||
|
const showMultiTargetLabels = completedStart && modeState.phase !== 'start'
|
||||||
const mapPresentation: MapPresentationState = {
|
const mapPresentation: MapPresentationState = {
|
||||||
controlVisualMode: modeState.phase === 'controls' ? 'multi-target' : 'single-target',
|
controlVisualMode: showMultiTargetLabels ? 'multi-target' : 'single-target',
|
||||||
showCourseLegs: false,
|
showCourseLegs: false,
|
||||||
guidanceLegAnimationEnabled: false,
|
guidanceLegAnimationEnabled: false,
|
||||||
focusableControlIds: canSelectFinish
|
focusableControlIds: canSelectFinish
|
||||||
@@ -336,7 +513,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
|||||||
activeControlSequences,
|
activeControlSequences,
|
||||||
activeStart: running && modeState.phase === 'start',
|
activeStart: running && modeState.phase === 'start',
|
||||||
completedStart,
|
completedStart,
|
||||||
activeFinish: running && modeState.phase === 'finish',
|
activeFinish: running && (modeState.phase === 'finish' || (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState))),
|
||||||
focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish',
|
focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish',
|
||||||
completedFinish,
|
completedFinish,
|
||||||
revealFullCourse,
|
revealFullCourse,
|
||||||
@@ -351,34 +528,35 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
|||||||
const hudPresentation: HudPresentationState = {
|
const hudPresentation: HudPresentationState = {
|
||||||
actionTagText: modeState.phase === 'start'
|
actionTagText: modeState.phase === 'start'
|
||||||
? '目标'
|
? '目标'
|
||||||
: focusedTarget && focusedTarget.kind === 'finish'
|
|
||||||
? '终点'
|
|
||||||
: modeState.phase === 'finish'
|
: modeState.phase === 'finish'
|
||||||
? '终点'
|
? '终点'
|
||||||
|
: focusedTarget && focusedTarget.kind === 'finish'
|
||||||
|
? '终点'
|
||||||
|
: focusedTarget
|
||||||
|
? '目标'
|
||||||
: '自由',
|
: '自由',
|
||||||
distanceTagText: modeState.phase === 'start'
|
distanceTagText: modeState.phase === 'start'
|
||||||
? '点距'
|
? '点距'
|
||||||
|
: modeState.phase === 'finish'
|
||||||
|
? '终点距'
|
||||||
: focusedTarget && focusedTarget.kind === 'finish'
|
: focusedTarget && focusedTarget.kind === 'finish'
|
||||||
? '终点距'
|
? '终点距'
|
||||||
: focusedTarget
|
: focusedTarget
|
||||||
? '选中点距'
|
? '选中点距'
|
||||||
: modeState.phase === 'finish'
|
: '目标距',
|
||||||
? '终点距'
|
targetSummaryText: buildTargetSummaryText(definition, state, primaryTarget, focusedTarget),
|
||||||
: '最近点距',
|
hudTargetControlId,
|
||||||
hudTargetControlId: focusedTarget
|
|
||||||
? focusedTarget.id
|
|
||||||
: primaryTarget
|
|
||||||
? primaryTarget.id
|
|
||||||
: null,
|
|
||||||
progressText: `已收集 ${completedControls.length}/${scoreControls.length}`,
|
progressText: `已收集 ${completedControls.length}/${scoreControls.length}`,
|
||||||
punchableControlId: punchButtonEnabled && interactiveTarget ? interactiveTarget.id : null,
|
punchableControlId: punchableControl ? punchableControl.id : null,
|
||||||
punchButtonEnabled,
|
punchButtonEnabled,
|
||||||
punchButtonText: modeState.phase === 'start'
|
punchButtonText: modeState.phase === 'start'
|
||||||
? '开始打卡'
|
? '开始打卡'
|
||||||
: focusedTarget && focusedTarget.kind === 'finish'
|
: (punchableControl && punchableControl.kind === 'finish')
|
||||||
? '结束打卡'
|
? '结束打卡'
|
||||||
: modeState.phase === 'finish'
|
: modeState.phase === 'finish'
|
||||||
? '结束打卡'
|
? '结束打卡'
|
||||||
|
: focusedTarget && focusedTarget.kind === 'finish'
|
||||||
|
? '结束打卡'
|
||||||
: '打点',
|
: '打点',
|
||||||
punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget),
|
punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget),
|
||||||
}
|
}
|
||||||
@@ -386,6 +564,12 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
|||||||
return {
|
return {
|
||||||
map: mapPresentation,
|
map: mapPresentation,
|
||||||
hud: hudPresentation,
|
hud: hudPresentation,
|
||||||
|
targeting: {
|
||||||
|
punchableControlId: punchableControl ? punchableControl.id : null,
|
||||||
|
guidanceControlId: guidanceControl ? guidanceControl.id : null,
|
||||||
|
hudControlId: hudTargetControlId,
|
||||||
|
highlightedControlId,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +586,7 @@ function applyCompletion(
|
|||||||
const previousModeState = getModeState(state)
|
const previousModeState = getModeState(state)
|
||||||
const nextStateDraft: GameSessionState = {
|
const nextStateDraft: GameSessionState = {
|
||||||
...state,
|
...state,
|
||||||
|
endReason: control.kind === 'finish' ? 'completed' : state.endReason,
|
||||||
startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt,
|
startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt,
|
||||||
endedAt: control.kind === 'finish' ? at : state.endedAt,
|
endedAt: control.kind === 'finish' ? at : state.endedAt,
|
||||||
completedControlIds,
|
completedControlIds,
|
||||||
@@ -424,15 +609,16 @@ function applyCompletion(
|
|||||||
phase = remainingControls.length ? 'controls' : 'finish'
|
phase = remainingControls.length ? 'controls' : 'finish'
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextModeState: ScoreOModeState = {
|
|
||||||
phase,
|
|
||||||
focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId,
|
|
||||||
}
|
|
||||||
const nextPrimaryTarget = phase === 'controls'
|
const nextPrimaryTarget = phase === 'controls'
|
||||||
? getNearestRemainingControl(definition, nextStateDraft, referencePoint)
|
? getNearestRemainingControl(definition, nextStateDraft, referencePoint)
|
||||||
: phase === 'finish'
|
: phase === 'finish'
|
||||||
? getFinishControl(definition)
|
? getFinishControl(definition)
|
||||||
: null
|
: null
|
||||||
|
const nextModeState: ScoreOModeState = {
|
||||||
|
phase,
|
||||||
|
focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId,
|
||||||
|
guidanceControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
|
||||||
|
}
|
||||||
const nextState = withModeState({
|
const nextState = withModeState({
|
||||||
...nextStateDraft,
|
...nextStateDraft,
|
||||||
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
|
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
|
||||||
@@ -459,6 +645,7 @@ export class ScoreORule implements RulePlugin {
|
|||||||
const startControl = getStartControl(definition)
|
const startControl = getStartControl(definition)
|
||||||
return {
|
return {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
endReason: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
completedControlIds: [],
|
completedControlIds: [],
|
||||||
@@ -470,6 +657,7 @@ export class ScoreORule implements RulePlugin {
|
|||||||
modeState: {
|
modeState: {
|
||||||
phase: 'start',
|
phase: 'start',
|
||||||
focusedControlId: null,
|
focusedControlId: null,
|
||||||
|
guidanceControlId: startControl ? startControl.id : null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,6 +672,7 @@ export class ScoreORule implements RulePlugin {
|
|||||||
const nextState = withModeState({
|
const nextState = withModeState({
|
||||||
...state,
|
...state,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
endReason: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
currentTargetControlId: startControl ? startControl.id : null,
|
currentTargetControlId: startControl ? startControl.id : null,
|
||||||
@@ -492,6 +681,7 @@ export class ScoreORule implements RulePlugin {
|
|||||||
}, {
|
}, {
|
||||||
phase: 'start',
|
phase: 'start',
|
||||||
focusedControlId: null,
|
focusedControlId: null,
|
||||||
|
guidanceControlId: startControl ? startControl.id : null,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
nextState,
|
nextState,
|
||||||
@@ -504,11 +694,13 @@ export class ScoreORule implements RulePlugin {
|
|||||||
const nextState = withModeState({
|
const nextState = withModeState({
|
||||||
...state,
|
...state,
|
||||||
status: 'finished',
|
status: 'finished',
|
||||||
|
endReason: 'completed',
|
||||||
endedAt: event.at,
|
endedAt: event.at,
|
||||||
guidanceState: 'searching',
|
guidanceState: 'searching',
|
||||||
}, {
|
}, {
|
||||||
phase: 'done',
|
phase: 'done',
|
||||||
focusedControlId: null,
|
focusedControlId: null,
|
||||||
|
guidanceControlId: null,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
nextState,
|
nextState,
|
||||||
@@ -517,6 +709,25 @@ export class ScoreORule implements RulePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.type === 'session_timed_out') {
|
||||||
|
const nextState = withModeState({
|
||||||
|
...state,
|
||||||
|
status: 'failed',
|
||||||
|
endReason: 'timed_out',
|
||||||
|
endedAt: event.at,
|
||||||
|
guidanceState: 'searching',
|
||||||
|
}, {
|
||||||
|
phase: 'done',
|
||||||
|
focusedControlId: null,
|
||||||
|
guidanceControlId: null,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
nextState,
|
||||||
|
presentation: buildPresentation(definition, nextState),
|
||||||
|
effects: [{ type: 'session_timed_out' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (state.status !== 'running') {
|
if (state.status !== 'running') {
|
||||||
return {
|
return {
|
||||||
nextState: state,
|
nextState: state,
|
||||||
@@ -533,25 +744,30 @@ export class ScoreORule implements RulePlugin {
|
|||||||
if (event.type === 'gps_updated') {
|
if (event.type === 'gps_updated') {
|
||||||
const referencePoint = { lon: event.lon, lat: event.lat }
|
const referencePoint = { lon: event.lon, lat: event.lat }
|
||||||
const remainingControls = getRemainingScoreControls(definition, state)
|
const remainingControls = getRemainingScoreControls(definition, state)
|
||||||
const focusedTarget = getFocusedTarget(definition, state, remainingControls)
|
const nextStateBase = withModeState(state, modeState)
|
||||||
|
const focusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls)
|
||||||
let nextPrimaryTarget = targetControl
|
let nextPrimaryTarget = targetControl
|
||||||
let guidanceTarget = targetControl
|
let guidanceTarget = targetControl
|
||||||
let punchTarget: GameControl | null = null
|
let punchTarget: GameControl | null = null
|
||||||
|
|
||||||
if (modeState.phase === 'controls') {
|
if (modeState.phase === 'controls') {
|
||||||
nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint)
|
nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint)
|
||||||
guidanceTarget = focusedTarget || nextPrimaryTarget
|
guidanceTarget = getNearestGuidanceTarget(definition, state, modeState, referencePoint)
|
||||||
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||||
punchTarget = focusedTarget
|
punchTarget = focusedTarget
|
||||||
} else if (!definition.requiresFocusSelection) {
|
} else if (isFinishPunchAvailable(definition, state, modeState)) {
|
||||||
|
const finishControl = getFinishControl(definition)
|
||||||
|
if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||||
|
punchTarget = finishControl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!punchTarget && !definition.requiresFocusSelection) {
|
||||||
punchTarget = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
|
punchTarget = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
|
||||||
}
|
}
|
||||||
} else if (modeState.phase === 'finish') {
|
} else if (modeState.phase === 'finish') {
|
||||||
nextPrimaryTarget = getFinishControl(definition)
|
nextPrimaryTarget = getFinishControl(definition)
|
||||||
guidanceTarget = focusedTarget || nextPrimaryTarget
|
guidanceTarget = nextPrimaryTarget
|
||||||
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
if (nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||||
punchTarget = focusedTarget
|
|
||||||
} else if (!definition.requiresFocusSelection && nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
|
||||||
punchTarget = nextPrimaryTarget
|
punchTarget = nextPrimaryTarget
|
||||||
}
|
}
|
||||||
} else if (targetControl) {
|
} else if (targetControl) {
|
||||||
@@ -565,15 +781,19 @@ export class ScoreORule implements RulePlugin {
|
|||||||
? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint))
|
? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint))
|
||||||
: 'searching'
|
: 'searching'
|
||||||
const nextState: GameSessionState = {
|
const nextState: GameSessionState = {
|
||||||
...state,
|
...nextStateBase,
|
||||||
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
|
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
|
||||||
inRangeControlId: punchTarget ? punchTarget.id : null,
|
inRangeControlId: punchTarget ? punchTarget.id : null,
|
||||||
guidanceState,
|
guidanceState,
|
||||||
}
|
}
|
||||||
|
const nextStateWithMode = withModeState(nextState, {
|
||||||
|
...modeState,
|
||||||
|
guidanceControlId: guidanceTarget ? guidanceTarget.id : null,
|
||||||
|
})
|
||||||
const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null)
|
const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null)
|
||||||
|
|
||||||
if (definition.punchPolicy === 'enter' && punchTarget) {
|
if (definition.punchPolicy === 'enter' && punchTarget) {
|
||||||
const completionResult = applyCompletion(definition, nextState, punchTarget, event.at, referencePoint)
|
const completionResult = applyCompletion(definition, nextStateWithMode, punchTarget, event.at, referencePoint)
|
||||||
return {
|
return {
|
||||||
...completionResult,
|
...completionResult,
|
||||||
effects: [...guidanceEffects, ...completionResult.effects],
|
effects: [...guidanceEffects, ...completionResult.effects],
|
||||||
@@ -581,8 +801,8 @@ export class ScoreORule implements RulePlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nextState,
|
nextState: nextStateWithMode,
|
||||||
presentation: buildPresentation(definition, nextState),
|
presentation: buildPresentation(definition, nextStateWithMode),
|
||||||
effects: guidanceEffects,
|
effects: guidanceEffects,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -612,6 +832,7 @@ export class ScoreORule implements RulePlugin {
|
|||||||
}, {
|
}, {
|
||||||
...modeState,
|
...modeState,
|
||||||
focusedControlId: nextFocusedControlId,
|
focusedControlId: nextFocusedControlId,
|
||||||
|
guidanceControlId: modeState.guidanceControlId,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
nextState,
|
nextState,
|
||||||
@@ -622,11 +843,19 @@ export class ScoreORule implements RulePlugin {
|
|||||||
|
|
||||||
if (event.type === 'punch_requested') {
|
if (event.type === 'punch_requested') {
|
||||||
const focusedTarget = getFocusedTarget(definition, state)
|
const focusedTarget = getFocusedTarget(definition, state)
|
||||||
if (definition.requiresFocusSelection && (modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
|
let stateForPunch = state
|
||||||
|
const finishControl = getFinishControl(definition)
|
||||||
|
const finishInRange = !!(
|
||||||
|
finishControl
|
||||||
|
&& event.lon !== null
|
||||||
|
&& event.lat !== null
|
||||||
|
&& getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters
|
||||||
|
)
|
||||||
|
if (definition.requiresFocusSelection && modeState.phase === 'controls' && !focusedTarget && !finishInRange) {
|
||||||
return {
|
return {
|
||||||
nextState: state,
|
nextState: state,
|
||||||
presentation: buildPresentation(definition, state),
|
presentation: buildPresentation(definition, state),
|
||||||
effects: [{ type: 'punch_feedback', text: modeState.phase === 'finish' ? '请先选中终点' : '请先选中目标点', tone: 'warning' }],
|
effects: [{ type: 'punch_feedback', text: '请先选中目标点', tone: 'warning' }],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,13 +864,43 @@ export class ScoreORule implements RulePlugin {
|
|||||||
controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
|
controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!controlToPunch || (definition.requiresFocusSelection && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
|
if (!controlToPunch && event.lon !== null && event.lat !== null) {
|
||||||
|
const referencePoint = { lon: event.lon, lat: event.lat }
|
||||||
|
const nextStateBase = withModeState(state, modeState)
|
||||||
|
stateForPunch = nextStateBase
|
||||||
|
const remainingControls = getRemainingScoreControls(definition, state)
|
||||||
|
const resolvedFocusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls)
|
||||||
|
|
||||||
|
if (resolvedFocusedTarget && getApproxDistanceMeters(resolvedFocusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||||
|
controlToPunch = resolvedFocusedTarget
|
||||||
|
} else if (isFinishPunchAvailable(definition, state, modeState)) {
|
||||||
|
const finishControl = getFinishControl(definition)
|
||||||
|
if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||||
|
controlToPunch = finishControl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!controlToPunch && !definition.requiresFocusSelection && modeState.phase === 'controls') {
|
||||||
|
controlToPunch = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!controlToPunch || (definition.requiresFocusSelection && modeState.phase === 'controls' && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
|
||||||
|
const isFinishLockedAttempt = !!(
|
||||||
|
finishControl
|
||||||
|
&& event.lon !== null
|
||||||
|
&& event.lat !== null
|
||||||
|
&& getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters
|
||||||
|
&& !hasCompletedEnoughControlsForFinish(definition, state)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
nextState: state,
|
nextState: state,
|
||||||
presentation: buildPresentation(definition, state),
|
presentation: buildPresentation(definition, state),
|
||||||
effects: [{
|
effects: [{
|
||||||
type: 'punch_feedback',
|
type: 'punch_feedback',
|
||||||
text: focusedTarget
|
text: isFinishLockedAttempt
|
||||||
|
? `至少完成 ${definition.minCompletedControlsBeforeFinish} 个积分点后才能结束`
|
||||||
|
: focusedTarget
|
||||||
? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围`
|
? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围`
|
||||||
: modeState.phase === 'start'
|
: modeState.phase === 'start'
|
||||||
? '未进入开始点打卡范围'
|
? '未进入开始点打卡范围'
|
||||||
@@ -651,7 +910,7 @@ export class ScoreORule implements RulePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return applyCompletion(definition, state, controlToPunch, event.at, this.getReferencePoint(definition, state, controlToPunch))
|
return applyCompletion(definition, stateForPunch, controlToPunch, event.at, this.getReferencePoint(definition, stateForPunch, controlToPunch))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
36
miniprogram/game/telemetry/playerTelemetryProfile.ts
Normal file
36
miniprogram/game/telemetry/playerTelemetryProfile.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { mergeTelemetryConfig, type TelemetryConfig } from './telemetryConfig'
|
||||||
|
|
||||||
|
export interface PlayerTelemetryProfile {
|
||||||
|
heartRateAge?: number
|
||||||
|
restingHeartRateBpm?: number
|
||||||
|
userWeightKg?: number
|
||||||
|
source?: 'server' | 'device' | 'manual'
|
||||||
|
updatedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTelemetryValue<T extends keyof TelemetryConfig>(
|
||||||
|
key: T,
|
||||||
|
activityConfig: Partial<TelemetryConfig> | null | undefined,
|
||||||
|
playerProfile: PlayerTelemetryProfile | null | undefined,
|
||||||
|
): TelemetryConfig[T] | undefined {
|
||||||
|
if (playerProfile && playerProfile[key] !== undefined) {
|
||||||
|
return playerProfile[key] as TelemetryConfig[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activityConfig && activityConfig[key] !== undefined) {
|
||||||
|
return activityConfig[key] as TelemetryConfig[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeTelemetrySources(
|
||||||
|
activityConfig?: Partial<TelemetryConfig> | null,
|
||||||
|
playerProfile?: PlayerTelemetryProfile | null,
|
||||||
|
): TelemetryConfig {
|
||||||
|
return mergeTelemetryConfig({
|
||||||
|
heartRateAge: pickTelemetryValue('heartRateAge', activityConfig, playerProfile),
|
||||||
|
restingHeartRateBpm: pickTelemetryValue('restingHeartRateBpm', activityConfig, playerProfile),
|
||||||
|
userWeightKg: pickTelemetryValue('userWeightKg', activityConfig, playerProfile),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
export interface TelemetryPresentation {
|
export interface TelemetryPresentation {
|
||||||
timerText: string
|
timerText: string
|
||||||
|
elapsedTimerText: string
|
||||||
|
timerMode: 'elapsed' | 'countdown'
|
||||||
mileageText: string
|
mileageText: string
|
||||||
distanceToTargetValueText: string
|
distanceToTargetValueText: string
|
||||||
distanceToTargetUnitText: string
|
distanceToTargetUnitText: string
|
||||||
@@ -19,6 +21,8 @@ export interface TelemetryPresentation {
|
|||||||
|
|
||||||
export const EMPTY_TELEMETRY_PRESENTATION: TelemetryPresentation = {
|
export const EMPTY_TELEMETRY_PRESENTATION: TelemetryPresentation = {
|
||||||
timerText: '00:00:00',
|
timerText: '00:00:00',
|
||||||
|
elapsedTimerText: '00:00:00',
|
||||||
|
timerMode: 'elapsed',
|
||||||
mileageText: '0m',
|
mileageText: '0m',
|
||||||
distanceToTargetValueText: '--',
|
distanceToTargetValueText: '--',
|
||||||
distanceToTargetUnitText: '',
|
distanceToTargetUnitText: '',
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import {
|
|||||||
getHeartRateToneLabel,
|
getHeartRateToneLabel,
|
||||||
getHeartRateToneRangeText,
|
getHeartRateToneRangeText,
|
||||||
getSpeedToneRangeText,
|
getSpeedToneRangeText,
|
||||||
mergeTelemetryConfig,
|
|
||||||
type HeartRateTone,
|
type HeartRateTone,
|
||||||
type TelemetryConfig,
|
type TelemetryConfig,
|
||||||
} from './telemetryConfig'
|
} from './telemetryConfig'
|
||||||
|
import { mergeTelemetrySources, type PlayerTelemetryProfile } from './playerTelemetryProfile'
|
||||||
import { type GameSessionState } from '../core/gameSessionState'
|
import { type GameSessionState } from '../core/gameSessionState'
|
||||||
import { type TelemetryEvent } from './telemetryEvent'
|
import { type TelemetryEvent } from './telemetryEvent'
|
||||||
import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation'
|
import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation'
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type HeadingConfidence,
|
type HeadingConfidence,
|
||||||
type TelemetryState,
|
type TelemetryState,
|
||||||
} from './telemetryState'
|
} from './telemetryState'
|
||||||
|
import { type RecoveryTelemetrySnapshot } from '../core/sessionRecovery'
|
||||||
const SPEED_SMOOTHING_ALPHA = 0.35
|
const SPEED_SMOOTHING_ALPHA = 0.35
|
||||||
const DEVICE_HEADING_SMOOTHING_ALPHA = 0.28
|
const DEVICE_HEADING_SMOOTHING_ALPHA = 0.28
|
||||||
const ACCELEROMETER_SMOOTHING_ALPHA = 0.2
|
const ACCELEROMETER_SMOOTHING_ALPHA = 0.2
|
||||||
@@ -109,6 +110,10 @@ function formatElapsedTimerText(totalMs: number): string {
|
|||||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCountdownTimerText(remainingMs: number): string {
|
||||||
|
return formatElapsedTimerText(Math.max(0, remainingMs))
|
||||||
|
}
|
||||||
|
|
||||||
function formatDistanceText(distanceMeters: number): string {
|
function formatDistanceText(distanceMeters: number): string {
|
||||||
if (distanceMeters >= 1000) {
|
if (distanceMeters >= 1000) {
|
||||||
return `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}km`
|
return `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}km`
|
||||||
@@ -419,10 +424,18 @@ function shouldTrackCalories(state: TelemetryState): boolean {
|
|||||||
export class TelemetryRuntime {
|
export class TelemetryRuntime {
|
||||||
state: TelemetryState
|
state: TelemetryState
|
||||||
config: TelemetryConfig
|
config: TelemetryConfig
|
||||||
|
activityConfig: TelemetryConfig
|
||||||
|
playerProfile: PlayerTelemetryProfile | null
|
||||||
|
sessionCloseAfterMs: number
|
||||||
|
sessionCloseWarningMs: number
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.state = { ...EMPTY_TELEMETRY_STATE }
|
this.state = { ...EMPTY_TELEMETRY_STATE }
|
||||||
this.config = { ...DEFAULT_TELEMETRY_CONFIG }
|
this.config = { ...DEFAULT_TELEMETRY_CONFIG }
|
||||||
|
this.activityConfig = { ...DEFAULT_TELEMETRY_CONFIG }
|
||||||
|
this.playerProfile = null
|
||||||
|
this.sessionCloseAfterMs = 2 * 60 * 60 * 1000
|
||||||
|
this.sessionCloseWarningMs = 10 * 60 * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
@@ -440,10 +453,102 @@ export class TelemetryRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configure(config?: Partial<TelemetryConfig> | null): void {
|
configure(config?: Partial<TelemetryConfig> | null): void {
|
||||||
this.config = mergeTelemetryConfig(config)
|
this.activityConfig = mergeTelemetrySources(config, null)
|
||||||
|
this.syncEffectiveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCompiledProfile(
|
||||||
|
config: TelemetryConfig,
|
||||||
|
playerProfile?: PlayerTelemetryProfile | null,
|
||||||
|
): void {
|
||||||
|
this.activityConfig = { ...config }
|
||||||
|
this.playerProfile = playerProfile ? { ...playerProfile } : null
|
||||||
|
this.config = { ...config }
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlayerProfile(profile?: PlayerTelemetryProfile | null): void {
|
||||||
|
this.playerProfile = profile ? { ...profile } : null
|
||||||
|
this.syncEffectiveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPlayerProfile(): void {
|
||||||
|
this.playerProfile = null
|
||||||
|
this.syncEffectiveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
exportRecoveryState(): RecoveryTelemetrySnapshot {
|
||||||
|
return {
|
||||||
|
distanceMeters: this.state.distanceMeters,
|
||||||
|
currentSpeedKmh: this.state.currentSpeedKmh,
|
||||||
|
averageSpeedKmh: this.state.averageSpeedKmh,
|
||||||
|
heartRateBpm: this.state.heartRateBpm,
|
||||||
|
caloriesKcal: this.state.caloriesKcal,
|
||||||
|
lastGpsPoint: this.state.lastGpsPoint
|
||||||
|
? {
|
||||||
|
lon: this.state.lastGpsPoint.lon,
|
||||||
|
lat: this.state.lastGpsPoint.lat,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
lastGpsAt: this.state.lastGpsAt,
|
||||||
|
lastGpsAccuracyMeters: this.state.lastGpsAccuracyMeters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreRecoveryState(
|
||||||
|
definition: GameDefinition,
|
||||||
|
gameState: GameSessionState,
|
||||||
|
snapshot: RecoveryTelemetrySnapshot,
|
||||||
|
hudTargetControlId?: string | null,
|
||||||
|
): void {
|
||||||
|
const targetControlId = hudTargetControlId || null
|
||||||
|
const targetControl = targetControlId
|
||||||
|
? definition.controls.find((control) => control.id === targetControlId) || null
|
||||||
|
: null
|
||||||
|
|
||||||
|
this.sessionCloseAfterMs = definition.sessionCloseAfterMs
|
||||||
|
this.sessionCloseWarningMs = definition.sessionCloseWarningMs
|
||||||
|
this.state = {
|
||||||
|
...EMPTY_TELEMETRY_STATE,
|
||||||
|
accelerometer: this.state.accelerometer,
|
||||||
|
accelerometerUpdatedAt: this.state.accelerometerUpdatedAt,
|
||||||
|
accelerometerSampleCount: this.state.accelerometerSampleCount,
|
||||||
|
gyroscope: this.state.gyroscope,
|
||||||
|
deviceMotion: this.state.deviceMotion,
|
||||||
|
deviceHeadingDeg: this.state.deviceHeadingDeg,
|
||||||
|
devicePose: this.state.devicePose,
|
||||||
|
headingConfidence: this.state.headingConfidence,
|
||||||
|
sessionStatus: gameState.status,
|
||||||
|
sessionStartedAt: gameState.startedAt,
|
||||||
|
sessionEndedAt: gameState.endedAt,
|
||||||
|
elapsedMs: gameState.startedAt === null
|
||||||
|
? 0
|
||||||
|
: Math.max(0, ((gameState.endedAt || Date.now()) - gameState.startedAt)),
|
||||||
|
distanceMeters: snapshot.distanceMeters,
|
||||||
|
currentSpeedKmh: snapshot.currentSpeedKmh,
|
||||||
|
averageSpeedKmh: snapshot.averageSpeedKmh,
|
||||||
|
distanceToTargetMeters: targetControl && snapshot.lastGpsPoint
|
||||||
|
? getApproxDistanceMeters(snapshot.lastGpsPoint, targetControl.point)
|
||||||
|
: null,
|
||||||
|
targetControlId: targetControl ? targetControl.id : null,
|
||||||
|
targetPoint: targetControl ? targetControl.point : null,
|
||||||
|
lastGpsPoint: snapshot.lastGpsPoint
|
||||||
|
? {
|
||||||
|
lon: snapshot.lastGpsPoint.lon,
|
||||||
|
lat: snapshot.lastGpsPoint.lat,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
lastGpsAt: snapshot.lastGpsAt,
|
||||||
|
lastGpsAccuracyMeters: snapshot.lastGpsAccuracyMeters,
|
||||||
|
heartRateBpm: snapshot.heartRateBpm,
|
||||||
|
caloriesKcal: snapshot.caloriesKcal,
|
||||||
|
calorieTrackingAt: snapshot.lastGpsAt,
|
||||||
|
}
|
||||||
|
this.recomputeDerivedState()
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDefinition(_definition: GameDefinition): void {
|
loadDefinition(_definition: GameDefinition): void {
|
||||||
|
this.sessionCloseAfterMs = _definition.sessionCloseAfterMs
|
||||||
|
this.sessionCloseWarningMs = _definition.sessionCloseWarningMs
|
||||||
this.reset()
|
this.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +737,15 @@ export class TelemetryRuntime {
|
|||||||
this.syncCalorieAccumulation(now)
|
this.syncCalorieAccumulation(now)
|
||||||
this.alignCalorieTracking(now)
|
this.alignCalorieTracking(now)
|
||||||
this.recomputeDerivedState(now)
|
this.recomputeDerivedState(now)
|
||||||
|
const elapsedTimerText = formatElapsedTimerText(this.state.elapsedMs)
|
||||||
|
const countdownActive = this.state.sessionStatus === 'running'
|
||||||
|
&& this.state.sessionEndedAt === null
|
||||||
|
&& this.state.sessionStartedAt !== null
|
||||||
|
&& this.sessionCloseAfterMs > 0
|
||||||
|
&& (this.sessionCloseAfterMs - this.state.elapsedMs) <= this.sessionCloseWarningMs
|
||||||
|
const countdownRemainingMs = countdownActive
|
||||||
|
? Math.max(0, this.sessionCloseAfterMs - this.state.elapsedMs)
|
||||||
|
: 0
|
||||||
const targetDistance = formatTargetDistance(this.state.distanceToTargetMeters)
|
const targetDistance = formatTargetDistance(this.state.distanceToTargetMeters)
|
||||||
const hasHeartRate = hasHeartRateSignal(this.state)
|
const hasHeartRate = hasHeartRateSignal(this.state)
|
||||||
const heartRateTone = hasHeartRate
|
const heartRateTone = hasHeartRate
|
||||||
@@ -643,7 +757,9 @@ export class TelemetryRuntime {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...EMPTY_TELEMETRY_PRESENTATION,
|
...EMPTY_TELEMETRY_PRESENTATION,
|
||||||
timerText: formatElapsedTimerText(this.state.elapsedMs),
|
timerText: countdownActive ? formatCountdownTimerText(countdownRemainingMs) : elapsedTimerText,
|
||||||
|
elapsedTimerText,
|
||||||
|
timerMode: countdownActive ? 'countdown' : 'elapsed',
|
||||||
mileageText: formatDistanceText(this.state.distanceMeters),
|
mileageText: formatDistanceText(this.state.distanceMeters),
|
||||||
distanceToTargetValueText: targetDistance.valueText,
|
distanceToTargetValueText: targetDistance.valueText,
|
||||||
distanceToTargetUnitText: targetDistance.unitText,
|
distanceToTargetUnitText: targetDistance.unitText,
|
||||||
@@ -716,4 +832,8 @@ export class TelemetryRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncEffectiveConfig(): void {
|
||||||
|
this.config = mergeTelemetrySources(this.activityConfig, this.playerProfile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -73,14 +73,14 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="game-content-card-layer" wx:if="{{contentCardVisible}}" bindtap="handleDismissTransientContentCard">
|
||||||
<view
|
<view
|
||||||
class="game-content-card game-content-card--{{contentCardTemplate}} {{contentCardFxClass}}"
|
class="game-content-card game-content-card--{{contentCardTemplate}} {{contentCardFxClass}}"
|
||||||
wx:if="{{contentCardVisible}}"
|
|
||||||
catchtap="handleContentCardTap"
|
catchtap="handleContentCardTap"
|
||||||
>
|
>
|
||||||
<view class="game-content-card__title">{{contentCardTitle}}</view>
|
<view class="game-content-card__title">{{contentCardTitle}}</view>
|
||||||
<view class="game-content-card__body">{{contentCardBody}}</view>
|
<view class="game-content-card__body">{{contentCardBody}}</view>
|
||||||
<view class="game-content-card__action-row {{contentCardActions.length ? 'game-content-card__action-row--split' : ''}}">
|
<view wx:if="{{contentCardActions.length}}" class="game-content-card__action-row game-content-card__action-row--split">
|
||||||
<view class="game-content-card__cta-group" wx:if="{{contentCardActions.length}}">
|
<view class="game-content-card__cta-group" wx:if="{{contentCardActions.length}}">
|
||||||
<view
|
<view
|
||||||
wx:for="{{contentCardActions}}"
|
wx:for="{{contentCardActions}}"
|
||||||
@@ -94,6 +94,7 @@
|
|||||||
<view class="game-content-card__close" catchtap="handleCloseContentCard">关闭</view>
|
<view class="game-content-card__close" catchtap="handleCloseContentCard">关闭</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="game-content-quiz" wx:if="{{contentQuizVisible}}">
|
<view class="game-content-quiz" wx:if="{{contentQuizVisible}}">
|
||||||
<view class="game-content-quiz__panel">
|
<view class="game-content-quiz__panel">
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="game-punch-hint" wx:if="{{!showResultScene && showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap">
|
<view class="game-punch-hint {{punchHintFxClass}}" wx:if="{{!showResultScene && !contentCardVisible && !contentQuizVisible && showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap">
|
||||||
<view class="game-punch-hint__text">{{punchHintText}}</view>
|
<view class="game-punch-hint__text">{{punchHintText}}</view>
|
||||||
<view class="game-punch-hint__close" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap" catchtap="handleClosePunchHint">×</view>
|
<view class="game-punch-hint__close" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap" catchtap="handleClosePunchHint">×</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -187,12 +188,15 @@
|
|||||||
|
|
||||||
<view class="race-panel__grid">
|
<view class="race-panel__grid">
|
||||||
<view class="race-panel__cell race-panel__cell--action">
|
<view class="race-panel__cell race-panel__cell--action">
|
||||||
<view class="race-panel__action-button"><!-- status only -->
|
<view class="race-panel__action-stack">
|
||||||
|
<view class="race-panel__action-button {{punchButtonEnabled ? 'race-panel__action-button--active' : ''}}"><!-- status only -->
|
||||||
<view class="race-panel__action-button-text">{{punchButtonText}}</view>
|
<view class="race-panel__action-button-text">{{punchButtonText}}</view>
|
||||||
</view>
|
</view>
|
||||||
|
<text class="race-panel__action-summary">{{panelTargetSummaryText}}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="race-panel__cell race-panel__cell--timer">
|
<view class="race-panel__cell race-panel__cell--timer">
|
||||||
<text class="race-panel__timer {{panelTimerFxClass}}">{{panelTimerText}}</text>
|
<text class="race-panel__timer {{panelTimerFxClass}} {{panelTimerMode === 'countdown' ? 'race-panel__timer--countdown' : ''}}">{{panelTimerText}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="race-panel__cell race-panel__cell--mileage">
|
<view class="race-panel__cell race-panel__cell--mileage">
|
||||||
<view class="race-panel__mileage-wrap {{panelMileageFxClass}}">
|
<view class="race-panel__mileage-wrap {{panelMileageFxClass}}">
|
||||||
@@ -244,7 +248,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="race-panel__cell race-panel__cell--timer">
|
<view class="race-panel__cell race-panel__cell--timer">
|
||||||
<text class="race-panel__timer {{panelTimerFxClass}}">{{panelTimerText}}</text>
|
<text class="race-panel__timer {{panelTimerFxClass}} {{panelTimerMode === 'countdown' ? 'race-panel__timer--countdown' : ''}}">{{panelTimerText}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="race-panel__cell race-panel__cell--mileage">
|
<view class="race-panel__cell race-panel__cell--mileage">
|
||||||
<view class="race-panel__metric-group race-panel__metric-group--right race-panel__metric-group--panel">
|
<view class="race-panel__metric-group race-panel__metric-group--right race-panel__metric-group--panel">
|
||||||
@@ -364,8 +368,8 @@
|
|||||||
<view class="debug-section__title">01. 动画性能</view>
|
<view class="debug-section__title">01. 动画性能</view>
|
||||||
<view class="debug-section__desc">根据设备性能切换动画强度,低端机建议精简</view>
|
<view class="debug-section__desc">根据设备性能切换动画强度,低端机建议精简</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockAnimationLevel ? 'debug-section__lock--active' : ''}}" data-key="lockAnimationLevel" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockAnimationLevel ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockAnimationLevel ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockAnimationLevel ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -386,8 +390,8 @@
|
|||||||
<view class="debug-section__title">02. 轨迹选项</view>
|
<view class="debug-section__title">02. 轨迹选项</view>
|
||||||
<view class="debug-section__desc">控制不显示、彗尾拖尾、全轨迹三种显示方式</view>
|
<view class="debug-section__desc">控制不显示、彗尾拖尾、全轨迹三种显示方式</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockTrackMode ? 'debug-section__lock--active' : ''}}" data-key="lockTrackMode" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockTrackMode ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockTrackMode ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockTrackMode ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -411,8 +415,8 @@
|
|||||||
<view class="debug-section__title">03. 轨迹尾巴</view>
|
<view class="debug-section__title">03. 轨迹尾巴</view>
|
||||||
<view class="debug-section__desc">拖尾模式下控制尾巴长短,跑得越快会在此基础上再拉长</view>
|
<view class="debug-section__desc">拖尾模式下控制尾巴长短,跑得越快会在此基础上再拉长</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockTrackTailLength ? 'debug-section__lock--active' : ''}}" data-key="lockTrackTailLength" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockTrackTailLength ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockTrackTailLength ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockTrackTailLength ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -436,8 +440,8 @@
|
|||||||
<view class="debug-section__title">04. 轨迹颜色</view>
|
<view class="debug-section__title">04. 轨迹颜色</view>
|
||||||
<view class="debug-section__desc">亮色轨迹调色盘,运行中会按速度和心率张力自动提亮</view>
|
<view class="debug-section__desc">亮色轨迹调色盘,运行中会按速度和心率张力自动提亮</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockTrackColor ? 'debug-section__lock--active' : ''}}" data-key="lockTrackColor" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockTrackColor ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockTrackColor ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockTrackColor ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -468,8 +472,8 @@
|
|||||||
<view class="debug-section__title">05. 轨迹风格</view>
|
<view class="debug-section__title">05. 轨迹风格</view>
|
||||||
<view class="debug-section__desc">切换经典线条和流光轨迹风格,默认推荐流光</view>
|
<view class="debug-section__desc">切换经典线条和流光轨迹风格,默认推荐流光</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockTrackStyle ? 'debug-section__lock--active' : ''}}" data-key="lockTrackStyle" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockTrackStyle ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockTrackStyle ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockTrackStyle ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -490,8 +494,8 @@
|
|||||||
<view class="debug-section__title">06. GPS点显示</view>
|
<view class="debug-section__title">06. GPS点显示</view>
|
||||||
<view class="debug-section__desc">控制地图上的 GPS 定位点显示与隐藏</view>
|
<view class="debug-section__desc">控制地图上的 GPS 定位点显示与隐藏</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockGpsMarkerVisible ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerVisible" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockGpsMarkerVisible ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockGpsMarkerVisible ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockGpsMarkerVisible ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -512,8 +516,8 @@
|
|||||||
<view class="debug-section__title">07. GPS点大小</view>
|
<view class="debug-section__title">07. GPS点大小</view>
|
||||||
<view class="debug-section__desc">控制定位点本体和朝向小三角的整体尺寸</view>
|
<view class="debug-section__desc">控制定位点本体和朝向小三角的整体尺寸</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockGpsMarkerSize ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerSize" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockGpsMarkerSize ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockGpsMarkerSize ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockGpsMarkerSize ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -535,8 +539,8 @@
|
|||||||
<view class="debug-section__title">08. GPS点颜色</view>
|
<view class="debug-section__title">08. GPS点颜色</view>
|
||||||
<view class="debug-section__desc">切换定位点主色,默认使用青绿高亮色</view>
|
<view class="debug-section__desc">切换定位点主色,默认使用青绿高亮色</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockGpsMarkerColor ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerColor" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockGpsMarkerColor ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockGpsMarkerColor ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockGpsMarkerColor ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -567,8 +571,8 @@
|
|||||||
<view class="debug-section__title">09. GPS点风格</view>
|
<view class="debug-section__title">09. GPS点风格</view>
|
||||||
<view class="debug-section__desc">切换定位点底座风格,影响本体与外圈表现</view>
|
<view class="debug-section__desc">切换定位点底座风格,影响本体与外圈表现</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockGpsMarkerStyle ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerStyle" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockGpsMarkerStyle ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockGpsMarkerStyle ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockGpsMarkerStyle ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -591,8 +595,8 @@
|
|||||||
<view class="debug-section__title">10. 按钮习惯</view>
|
<view class="debug-section__title">10. 按钮习惯</view>
|
||||||
<view class="debug-section__desc">切换功能按钮显示在左侧还是右侧,适配左手/右手操作习惯</view>
|
<view class="debug-section__desc">切换功能按钮显示在左侧还是右侧,适配左手/右手操作习惯</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockSideButtonPlacement ? 'debug-section__lock--active' : ''}}" data-key="lockSideButtonPlacement" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockSideButtonPlacement ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockSideButtonPlacement ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockSideButtonPlacement ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -613,8 +617,8 @@
|
|||||||
<view class="debug-section__title">11. 自动转图</view>
|
<view class="debug-section__title">11. 自动转图</view>
|
||||||
<view class="debug-section__desc">控制地图是否跟随朝向自动旋转,外部按钮与这里保持同步</view>
|
<view class="debug-section__desc">控制地图是否跟随朝向自动旋转,外部按钮与这里保持同步</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockAutoRotate ? 'debug-section__lock--active' : ''}}" data-key="lockAutoRotate" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockAutoRotate ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockAutoRotate ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockAutoRotate ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -635,8 +639,8 @@
|
|||||||
<view class="debug-section__title">12. 指北针响应</view>
|
<view class="debug-section__title">12. 指北针响应</view>
|
||||||
<view class="debug-section__desc">切换指针的平滑与跟手程度,影响指北针响应手感</view>
|
<view class="debug-section__desc">切换指针的平滑与跟手程度,影响指北针响应手感</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockCompassTuning ? 'debug-section__lock--active' : ''}}" data-key="lockCompassTuning" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockCompassTuning ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockCompassTuning ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockCompassTuning ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -658,8 +662,8 @@
|
|||||||
<view class="debug-section__title">13. 比例尺显示</view>
|
<view class="debug-section__title">13. 比例尺显示</view>
|
||||||
<view class="debug-section__desc">控制比例尺显示与否,默认沿用你的本地偏好</view>
|
<view class="debug-section__desc">控制比例尺显示与否,默认沿用你的本地偏好</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockScaleRulerVisible ? 'debug-section__lock--active' : ''}}" data-key="lockScaleRulerVisible" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockScaleRulerVisible ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockScaleRulerVisible ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockScaleRulerVisible ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -680,8 +684,8 @@
|
|||||||
<view class="debug-section__title">14. 比例尺基准点</view>
|
<view class="debug-section__title">14. 比例尺基准点</view>
|
||||||
<view class="debug-section__desc">设置比例尺零点锚定位置,可跟随屏幕中心或指北针圆心</view>
|
<view class="debug-section__desc">设置比例尺零点锚定位置,可跟随屏幕中心或指北针圆心</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockScaleRulerAnchor ? 'debug-section__lock--active' : ''}}" data-key="lockScaleRulerAnchor" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockScaleRulerAnchor ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockScaleRulerAnchor ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockScaleRulerAnchor ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -702,8 +706,8 @@
|
|||||||
<view class="debug-section__title">15. 北参考</view>
|
<view class="debug-section__title">15. 北参考</view>
|
||||||
<view class="debug-section__desc">切换磁北/真北作为地图与指北针参考</view>
|
<view class="debug-section__desc">切换磁北/真北作为地图与指北针参考</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockNorthReference ? 'debug-section__lock--active' : ''}}" data-key="lockNorthReference" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockNorthReference ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockNorthReference ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockNorthReference ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -724,8 +728,8 @@
|
|||||||
<view class="debug-section__title">16. 心率设备</view>
|
<view class="debug-section__title">16. 心率设备</view>
|
||||||
<view class="debug-section__desc">清除已记住的首选心率带设备,下次重新选择</view>
|
<view class="debug-section__desc">清除已记住的首选心率带设备,下次重新选择</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockHeartRateDevice ? 'debug-section__lock--active' : ''}}" data-key="lockHeartRateDevice" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockHeartRateDevice ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockHeartRateDevice ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockHeartRateDevice ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -801,6 +805,21 @@
|
|||||||
<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 class="control-chip control-chip--secondary" bindtap="handleOpenWebViewTest">测试 H5</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">模拟通道号</text>
|
||||||
|
<view class="debug-inline-stack">
|
||||||
|
<input
|
||||||
|
class="debug-input"
|
||||||
|
value="{{mockChannelIdDraft}}"
|
||||||
|
placeholder="default / runner-a"
|
||||||
|
bindinput="handleMockChannelIdInput"
|
||||||
|
/>
|
||||||
|
<view class="control-row control-row--compact">
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockChannelId">保存通道号</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="info-panel__hint">当前通道:{{mockChannelIdText}}</text>
|
||||||
|
</view>
|
||||||
<view class="debug-group-title">定位模拟</view>
|
<view class="debug-group-title">定位模拟</view>
|
||||||
<view class="info-panel__row">
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__label">GPS</text>
|
<text class="info-panel__label">GPS</text>
|
||||||
@@ -907,7 +926,7 @@
|
|||||||
<input
|
<input
|
||||||
class="debug-input"
|
class="debug-input"
|
||||||
value="{{mockHeartRateBridgeUrlDraft}}"
|
value="{{mockHeartRateBridgeUrlDraft}}"
|
||||||
placeholder="ws://192.168.x.x:17865/mock-gps"
|
placeholder="ws://192.168.x.x:17865/mock-hr"
|
||||||
bindinput="handleMockHeartRateBridgeUrlInput"
|
bindinput="handleMockHeartRateBridgeUrlInput"
|
||||||
/>
|
/>
|
||||||
<view class="control-row control-row--compact">
|
<view class="control-row control-row--compact">
|
||||||
@@ -932,7 +951,7 @@
|
|||||||
<input
|
<input
|
||||||
class="debug-input"
|
class="debug-input"
|
||||||
value="{{mockDebugLogBridgeUrlDraft}}"
|
value="{{mockDebugLogBridgeUrlDraft}}"
|
||||||
placeholder="ws://192.168.x.x:17865/mock-gps"
|
placeholder="ws://192.168.x.x:17865/debug-log"
|
||||||
bindinput="handleMockDebugLogBridgeUrlInput"
|
bindinput="handleMockDebugLogBridgeUrlInput"
|
||||||
/>
|
/>
|
||||||
<view class="control-row control-row--compact">
|
<view class="control-row control-row--compact">
|
||||||
@@ -1025,6 +1044,19 @@
|
|||||||
<text class="info-panel__label">Accuracy</text>
|
<text class="info-panel__label">Accuracy</text>
|
||||||
<text class="info-panel__value">{{panelAccuracyValueText}} {{panelAccuracyUnitText}}</text>
|
<text class="info-panel__value">{{panelAccuracyValueText}} {{panelAccuracyUnitText}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Timer</text>
|
||||||
|
<text class="info-panel__value">{{panelTimerText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Timer Mode</text>
|
||||||
|
<text class="info-panel__value">{{panelTimerMode === 'countdown' ? '倒计时' : '正计时'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-row control-row--triple">
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDebugSetSessionRemainingWarning">剩10分钟</view>
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDebugSetSessionRemainingOneMinute">剩1分钟</view>
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDebugTimeoutSession">立即超时</view>
|
||||||
|
</view>
|
||||||
<view class="control-row control-row--triple">
|
<view class="control-row control-row--triple">
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateBlue">蓝</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateBlue">蓝</view>
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRatePurple">紫</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRatePurple">紫</view>
|
||||||
|
|||||||
@@ -849,6 +849,12 @@
|
|||||||
text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.2);
|
text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.race-panel__timer--countdown {
|
||||||
|
color: #ffe082;
|
||||||
|
text-shadow: 0 0 14rpx rgba(255, 176, 32, 0.42);
|
||||||
|
animation: race-panel-timer-countdown 1.15s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.race-panel__timer--fx-tick {
|
.race-panel__timer--fx-tick {
|
||||||
animation: race-panel-timer-tick 0.32s cubic-bezier(0.24, 0.86, 0.3, 1) 1;
|
animation: race-panel-timer-tick 0.32s cubic-bezier(0.24, 0.86, 0.3, 1) 1;
|
||||||
}
|
}
|
||||||
@@ -973,6 +979,12 @@
|
|||||||
100% { transform: translateY(0) scale(1); opacity: 1; }
|
100% { transform: translateY(0) scale(1); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes race-panel-timer-countdown {
|
||||||
|
0% { opacity: 0.88; transform: scale(1); }
|
||||||
|
50% { opacity: 1; transform: scale(1.03); }
|
||||||
|
100% { opacity: 0.88; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes race-panel-mileage-update {
|
@keyframes race-panel-mileage-update {
|
||||||
0% { transform: translateX(-16rpx) scale(1); opacity: 0.94; }
|
0% { transform: translateX(-16rpx) scale(1); opacity: 0.94; }
|
||||||
40% { transform: translateX(-16rpx) scale(1.05); opacity: 1; }
|
40% { transform: translateX(-16rpx) scale(1.05); opacity: 1; }
|
||||||
@@ -1612,6 +1624,7 @@
|
|||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
background: rgba(233, 242, 228, 0.92);
|
background: rgba(233, 242, 228, 0.92);
|
||||||
box-shadow: inset 0 0 0 1rpx rgba(22, 48, 32, 0.08);
|
box-shadow: inset 0 0 0 1rpx rgba(22, 48, 32, 0.08);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.debug-section__lock--active {
|
.debug-section__lock--active {
|
||||||
@@ -1942,6 +1955,10 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-punch-hint--fx-enter {
|
||||||
|
animation: game-punch-hint-enter 0.42s cubic-bezier(0.22, 0.88, 0.28, 1) 1;
|
||||||
|
}
|
||||||
|
|
||||||
.game-punch-hint__text {
|
.game-punch-hint__text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1961,6 +1978,26 @@
|
|||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes game-punch-hint-enter {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(-16rpx) scale(0.96);
|
||||||
|
box-shadow: 0 0 0 0 rgba(120, 255, 210, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
58% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0) scale(1.02);
|
||||||
|
box-shadow: 0 0 0 12rpx rgba(120, 255, 210, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0) scale(1);
|
||||||
|
box-shadow: 0 0 0 0 rgba(120, 255, 210, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.game-punch-feedback {
|
.game-punch-feedback {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -2002,6 +2039,13 @@
|
|||||||
animation: feedback-toast-warning 0.56s ease-out;
|
animation: feedback-toast-warning 0.56s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-content-card-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 34;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.game-content-card {
|
.game-content-card {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -2014,7 +2058,7 @@
|
|||||||
background: rgba(248, 251, 244, 0.96);
|
background: rgba(248, 251, 244, 0.96);
|
||||||
box-shadow: 0 18rpx 48rpx rgba(22, 48, 32, 0.18);
|
box-shadow: 0 18rpx 48rpx rgba(22, 48, 32, 0.18);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
z-index: 33;
|
z-index: 35;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2233,6 +2277,24 @@
|
|||||||
color: rgba(236, 241, 246, 0.86);
|
color: rgba(236, 241, 246, 0.86);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.race-panel__action-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10rpx;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-panel__action-summary {
|
||||||
|
max-width: 220rpx;
|
||||||
|
font-size: 18rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: rgba(233, 241, 248, 0.68);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.race-panel__action-button--active .race-panel__action-button-text {
|
.race-panel__action-button--active .race-panel__action-button-text {
|
||||||
color: #775000;
|
color: #775000;
|
||||||
}
|
}
|
||||||
|
|||||||
209
miniprogram/utils/gameLaunch.ts
Normal file
209
miniprogram/utils/gameLaunch.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
export type DemoGamePreset = 'classic' | 'score-o'
|
||||||
|
export type BusinessLaunchSource = 'demo' | 'competition' | 'direct-event' | 'custom'
|
||||||
|
|
||||||
|
export interface GameConfigLaunchRequest {
|
||||||
|
configUrl: string
|
||||||
|
configLabel: string
|
||||||
|
configChecksumSha256?: string | null
|
||||||
|
releaseId?: string | null
|
||||||
|
routeCode?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessLaunchContext {
|
||||||
|
source: BusinessLaunchSource
|
||||||
|
competitionId?: string | null
|
||||||
|
eventId?: string | null
|
||||||
|
launchRequestId?: string | null
|
||||||
|
participantId?: string | null
|
||||||
|
sessionId?: string | null
|
||||||
|
sessionToken?: string | null
|
||||||
|
sessionTokenExpiresAt?: string | null
|
||||||
|
realtimeEndpoint?: string | null
|
||||||
|
realtimeToken?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameLaunchEnvelope {
|
||||||
|
config: GameConfigLaunchRequest
|
||||||
|
business: BusinessLaunchContext | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapPageLaunchOptions {
|
||||||
|
launchId?: string
|
||||||
|
preset?: string
|
||||||
|
configUrl?: string
|
||||||
|
configLabel?: string
|
||||||
|
configChecksumSha256?: string
|
||||||
|
releaseId?: string
|
||||||
|
routeCode?: string
|
||||||
|
launchSource?: string
|
||||||
|
competitionId?: string
|
||||||
|
eventId?: string
|
||||||
|
launchRequestId?: string
|
||||||
|
participantId?: string
|
||||||
|
sessionId?: string
|
||||||
|
sessionToken?: string
|
||||||
|
sessionTokenExpiresAt?: string
|
||||||
|
realtimeEndpoint?: string
|
||||||
|
realtimeToken?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingGameLaunchStore = Record<string, GameLaunchEnvelope>
|
||||||
|
|
||||||
|
const PENDING_GAME_LAUNCH_STORAGE_KEY = 'cmr.pendingGameLaunch.v1'
|
||||||
|
const CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json'
|
||||||
|
const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json'
|
||||||
|
|
||||||
|
function normalizeOptionalString(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = decodeURIComponent(value).trim()
|
||||||
|
return normalized ? normalized : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDemoPreset(value: string | null): DemoGamePreset {
|
||||||
|
return value === 'score-o' ? 'score-o' : 'classic'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBusinessLaunchSource(value: string | null): BusinessLaunchSource {
|
||||||
|
if (value === 'competition' || value === 'direct-event' || value === 'custom') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'demo'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDemoConfig(preset: DemoGamePreset): GameConfigLaunchRequest {
|
||||||
|
if (preset === 'score-o') {
|
||||||
|
return {
|
||||||
|
configUrl: SCORE_O_REMOTE_GAME_CONFIG_URL,
|
||||||
|
configLabel: '积分赛配置',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configUrl: CLASSIC_REMOTE_GAME_CONFIG_URL,
|
||||||
|
configLabel: '顺序赛配置',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBusinessFields(context: Omit<BusinessLaunchContext, 'source'>): boolean {
|
||||||
|
return Object.values(context).some((value) => typeof value === 'string' && value.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBusinessLaunchContext(options?: MapPageLaunchOptions | null): BusinessLaunchContext | null {
|
||||||
|
if (!options) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
competitionId: normalizeOptionalString(options.competitionId),
|
||||||
|
eventId: normalizeOptionalString(options.eventId),
|
||||||
|
launchRequestId: normalizeOptionalString(options.launchRequestId),
|
||||||
|
participantId: normalizeOptionalString(options.participantId),
|
||||||
|
sessionId: normalizeOptionalString(options.sessionId),
|
||||||
|
sessionToken: normalizeOptionalString(options.sessionToken),
|
||||||
|
sessionTokenExpiresAt: normalizeOptionalString(options.sessionTokenExpiresAt),
|
||||||
|
realtimeEndpoint: normalizeOptionalString(options.realtimeEndpoint),
|
||||||
|
realtimeToken: normalizeOptionalString(options.realtimeToken),
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchSource = normalizeOptionalString(options.launchSource)
|
||||||
|
if (!hasBusinessFields(context) && launchSource === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: resolveBusinessLaunchSource(launchSource),
|
||||||
|
...context,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPendingGameLaunchStore(): PendingGameLaunchStore {
|
||||||
|
try {
|
||||||
|
const stored = wx.getStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY)
|
||||||
|
if (!stored || typeof stored !== 'object') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored as PendingGameLaunchStore
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePendingGameLaunchStore(store: PendingGameLaunchStore): void {
|
||||||
|
try {
|
||||||
|
wx.setStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY, store)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoGameLaunchEnvelope(preset: DemoGamePreset = 'classic'): GameLaunchEnvelope {
|
||||||
|
return {
|
||||||
|
config: buildDemoConfig(preset),
|
||||||
|
business: {
|
||||||
|
source: 'demo',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stashPendingGameLaunchEnvelope(envelope: GameLaunchEnvelope): string {
|
||||||
|
const launchId = `launch_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
const store = loadPendingGameLaunchStore()
|
||||||
|
store[launchId] = envelope
|
||||||
|
savePendingGameLaunchStore(store)
|
||||||
|
return launchId
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePendingGameLaunchEnvelope(launchId: string): GameLaunchEnvelope | null {
|
||||||
|
const normalizedLaunchId = normalizeOptionalString(launchId)
|
||||||
|
if (!normalizedLaunchId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = loadPendingGameLaunchStore()
|
||||||
|
const envelope = store[normalizedLaunchId] || null
|
||||||
|
if (!envelope) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
delete store[normalizedLaunchId]
|
||||||
|
savePendingGameLaunchStore(store)
|
||||||
|
return envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMapPageUrlWithLaunchId(launchId: string): string {
|
||||||
|
return `/pages/map/map?launchId=${encodeURIComponent(launchId)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareMapPageUrlForLaunch(envelope: GameLaunchEnvelope): string {
|
||||||
|
return buildMapPageUrlWithLaunchId(stashPendingGameLaunchEnvelope(envelope))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGameLaunchEnvelope(options?: MapPageLaunchOptions | null): GameLaunchEnvelope {
|
||||||
|
const launchId = normalizeOptionalString(options ? options.launchId : undefined)
|
||||||
|
if (launchId) {
|
||||||
|
const pendingEnvelope = consumePendingGameLaunchEnvelope(launchId)
|
||||||
|
if (pendingEnvelope) {
|
||||||
|
return pendingEnvelope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configUrl = normalizeOptionalString(options ? options.configUrl : undefined)
|
||||||
|
if (configUrl) {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
configUrl,
|
||||||
|
configLabel: normalizeOptionalString(options ? options.configLabel : undefined) || '线上配置',
|
||||||
|
configChecksumSha256: normalizeOptionalString(options ? options.configChecksumSha256 : undefined),
|
||||||
|
releaseId: normalizeOptionalString(options ? options.releaseId : undefined),
|
||||||
|
routeCode: normalizeOptionalString(options ? options.routeCode : undefined),
|
||||||
|
},
|
||||||
|
business: buildBusinessLaunchContext(options),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = resolveDemoPreset(normalizeOptionalString(options ? options.preset : undefined))
|
||||||
|
return getDemoGameLaunchEnvelope(preset)
|
||||||
|
}
|
||||||
@@ -45,6 +45,16 @@ import {
|
|||||||
type GpsMarkerStyleConfig,
|
type GpsMarkerStyleConfig,
|
||||||
type GpsMarkerStyleId,
|
type GpsMarkerStyleId,
|
||||||
} from '../game/presentation/gpsMarkerStyleConfig'
|
} from '../game/presentation/gpsMarkerStyleConfig'
|
||||||
|
import {
|
||||||
|
getDefaultSkipRadiusMeters,
|
||||||
|
getGameModeDefaults,
|
||||||
|
resolveDefaultControlScore,
|
||||||
|
} from '../game/core/gameModeDefaults'
|
||||||
|
import {
|
||||||
|
type SystemSettingsConfig,
|
||||||
|
type SettingLockKey,
|
||||||
|
type StoredUserSettings,
|
||||||
|
} from '../game/core/systemSettingsState'
|
||||||
|
|
||||||
export interface TileZoomBounds {
|
export interface TileZoomBounds {
|
||||||
minX: number
|
minX: number
|
||||||
@@ -79,6 +89,9 @@ export interface RemoteMapConfig {
|
|||||||
courseStatusText: string
|
courseStatusText: string
|
||||||
cpRadiusMeters: number
|
cpRadiusMeters: number
|
||||||
gameMode: 'classic-sequential' | 'score-o'
|
gameMode: 'classic-sequential' | 'score-o'
|
||||||
|
sessionCloseAfterMs: number
|
||||||
|
sessionCloseWarningMs: number
|
||||||
|
minCompletedControlsBeforeFinish: number
|
||||||
punchPolicy: 'enter' | 'enter-confirm'
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
punchRadiusMeters: number
|
punchRadiusMeters: number
|
||||||
requiresFocusSelection: boolean
|
requiresFocusSelection: boolean
|
||||||
@@ -88,7 +101,10 @@ export interface RemoteMapConfig {
|
|||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
controlScoreOverrides: Record<string, number>
|
controlScoreOverrides: Record<string, number>
|
||||||
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
|
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
|
||||||
|
defaultControlContentOverride: GameControlDisplayContentOverride | null
|
||||||
|
defaultControlPointStyleOverride: ControlPointStyleEntry | null
|
||||||
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
|
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
|
||||||
|
defaultLegStyleOverride: CourseLegStyleEntry | null
|
||||||
legStyleOverrides: Record<number, CourseLegStyleEntry>
|
legStyleOverrides: Record<number, CourseLegStyleEntry>
|
||||||
defaultControlScore: number | null
|
defaultControlScore: number | null
|
||||||
courseStyleConfig: CourseStyleConfig
|
courseStyleConfig: CourseStyleConfig
|
||||||
@@ -98,6 +114,7 @@ export interface RemoteMapConfig {
|
|||||||
audioConfig: GameAudioConfig
|
audioConfig: GameAudioConfig
|
||||||
hapticsConfig: GameHapticsConfig
|
hapticsConfig: GameHapticsConfig
|
||||||
uiEffectsConfig: GameUiEffectsConfig
|
uiEffectsConfig: GameUiEffectsConfig
|
||||||
|
systemSettingsConfig: SystemSettingsConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedGameConfig {
|
interface ParsedGameConfig {
|
||||||
@@ -111,6 +128,9 @@ interface ParsedGameConfig {
|
|||||||
cpRadiusMeters: number
|
cpRadiusMeters: number
|
||||||
defaultZoom: number | null
|
defaultZoom: number | null
|
||||||
gameMode: 'classic-sequential' | 'score-o'
|
gameMode: 'classic-sequential' | 'score-o'
|
||||||
|
sessionCloseAfterMs: number
|
||||||
|
sessionCloseWarningMs: number
|
||||||
|
minCompletedControlsBeforeFinish: number
|
||||||
punchPolicy: 'enter' | 'enter-confirm'
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
punchRadiusMeters: number
|
punchRadiusMeters: number
|
||||||
requiresFocusSelection: boolean
|
requiresFocusSelection: boolean
|
||||||
@@ -120,7 +140,10 @@ interface ParsedGameConfig {
|
|||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
controlScoreOverrides: Record<string, number>
|
controlScoreOverrides: Record<string, number>
|
||||||
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
|
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
|
||||||
|
defaultControlContentOverride: GameControlDisplayContentOverride | null
|
||||||
|
defaultControlPointStyleOverride: ControlPointStyleEntry | null
|
||||||
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
|
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
|
||||||
|
defaultLegStyleOverride: CourseLegStyleEntry | null
|
||||||
legStyleOverrides: Record<number, CourseLegStyleEntry>
|
legStyleOverrides: Record<number, CourseLegStyleEntry>
|
||||||
defaultControlScore: number | null
|
defaultControlScore: number | null
|
||||||
courseStyleConfig: CourseStyleConfig
|
courseStyleConfig: CourseStyleConfig
|
||||||
@@ -130,6 +153,7 @@ interface ParsedGameConfig {
|
|||||||
audioConfig: GameAudioConfig
|
audioConfig: GameAudioConfig
|
||||||
hapticsConfig: GameHapticsConfig
|
hapticsConfig: GameHapticsConfig
|
||||||
uiEffectsConfig: GameUiEffectsConfig
|
uiEffectsConfig: GameUiEffectsConfig
|
||||||
|
systemSettingsConfig: SystemSettingsConfig
|
||||||
declinationDeg: number
|
declinationDeg: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +303,150 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
|
|||||||
return rawValue === 'enter' ? 'enter' : 'enter-confirm'
|
return rawValue === 'enter' ? 'enter' : 'enter-confirm'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseSettingLockKey(rawValue: string): SettingLockKey | null {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
const table: Record<string, SettingLockKey> = {
|
||||||
|
animationlevel: 'lockAnimationLevel',
|
||||||
|
trackdisplaymode: 'lockTrackMode',
|
||||||
|
trackmode: 'lockTrackMode',
|
||||||
|
tracktaillength: 'lockTrackTailLength',
|
||||||
|
trackcolorpreset: 'lockTrackColor',
|
||||||
|
trackcolor: 'lockTrackColor',
|
||||||
|
trackstyleprofile: 'lockTrackStyle',
|
||||||
|
trackstyle: 'lockTrackStyle',
|
||||||
|
gpsmarkervisible: 'lockGpsMarkerVisible',
|
||||||
|
gpsmarkerstyle: 'lockGpsMarkerStyle',
|
||||||
|
gpsmarkersize: 'lockGpsMarkerSize',
|
||||||
|
gpsmarkercolorpreset: 'lockGpsMarkerColor',
|
||||||
|
gpsmarkercolor: 'lockGpsMarkerColor',
|
||||||
|
sidebuttonplacement: 'lockSideButtonPlacement',
|
||||||
|
autorotateenabled: 'lockAutoRotate',
|
||||||
|
autorotate: 'lockAutoRotate',
|
||||||
|
compasstuningprofile: 'lockCompassTuning',
|
||||||
|
compasstuning: 'lockCompassTuning',
|
||||||
|
showcenterscaleruler: 'lockScaleRulerVisible',
|
||||||
|
centerscaleruleranchormode: 'lockScaleRulerAnchor',
|
||||||
|
centerruleranchor: 'lockScaleRulerAnchor',
|
||||||
|
northreferencemode: 'lockNorthReference',
|
||||||
|
northreference: 'lockNorthReference',
|
||||||
|
heartratedevice: 'lockHeartRateDevice',
|
||||||
|
}
|
||||||
|
return table[normalized] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignParsedSettingValue(
|
||||||
|
target: Partial<StoredUserSettings>,
|
||||||
|
key: string,
|
||||||
|
rawValue: unknown,
|
||||||
|
): void {
|
||||||
|
const normalized = key.trim().toLowerCase()
|
||||||
|
if (normalized === 'animationlevel') {
|
||||||
|
if (rawValue === 'standard' || rawValue === 'lite') {
|
||||||
|
target.animationLevel = rawValue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'trackdisplaymode' || normalized === 'trackmode') {
|
||||||
|
const parsed = parseTrackDisplayMode(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.mode)
|
||||||
|
target.trackDisplayMode = parsed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'tracktaillength') {
|
||||||
|
if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') {
|
||||||
|
target.trackTailLength = rawValue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'trackcolorpreset' || normalized === 'trackcolor') {
|
||||||
|
const parsed = parseTrackColorPreset(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.colorPreset)
|
||||||
|
target.trackColorPreset = parsed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'trackstyleprofile' || normalized === 'trackstyle') {
|
||||||
|
const parsed = parseTrackStyleProfile(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.style)
|
||||||
|
target.trackStyleProfile = parsed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'gpsmarkervisible') {
|
||||||
|
target.gpsMarkerVisible = parseBoolean(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.visible)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'gpsmarkerstyle') {
|
||||||
|
target.gpsMarkerStyle = parseGpsMarkerStyleId(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.style)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'gpsmarkersize') {
|
||||||
|
target.gpsMarkerSize = parseGpsMarkerSizePreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.size)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'gpsmarkercolorpreset' || normalized === 'gpsmarkercolor') {
|
||||||
|
target.gpsMarkerColorPreset = parseGpsMarkerColorPreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.colorPreset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'sidebuttonplacement') {
|
||||||
|
if (rawValue === 'left' || rawValue === 'right') {
|
||||||
|
target.sideButtonPlacement = rawValue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'autorotateenabled' || normalized === 'autorotate') {
|
||||||
|
target.autoRotateEnabled = parseBoolean(rawValue, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'compasstuningprofile' || normalized === 'compasstuning') {
|
||||||
|
if (rawValue === 'smooth' || rawValue === 'balanced' || rawValue === 'responsive') {
|
||||||
|
target.compassTuningProfile = rawValue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'northreferencemode' || normalized === 'northreference') {
|
||||||
|
if (rawValue === 'magnetic' || rawValue === 'true') {
|
||||||
|
target.northReferenceMode = rawValue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'showcenterscaleruler') {
|
||||||
|
target.showCenterScaleRuler = parseBoolean(rawValue, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === 'centerscaleruleranchormode' || normalized === 'centerruleranchor') {
|
||||||
|
if (rawValue === 'screen-center' || rawValue === 'compass-center') {
|
||||||
|
target.centerScaleRulerAnchorMode = rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSystemSettingsConfig(rawValue: unknown): SystemSettingsConfig {
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
if (!Object.keys(normalized).length) {
|
||||||
|
return { values: {}, locks: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
const values: Partial<StoredUserSettings> = {}
|
||||||
|
const locks: Partial<Record<SettingLockKey, boolean>> = {}
|
||||||
|
for (const [key, entry] of Object.entries(normalized)) {
|
||||||
|
const normalizedEntry = normalizeObjectRecord(entry)
|
||||||
|
if (Object.keys(normalizedEntry).length) {
|
||||||
|
const hasValue = Object.prototype.hasOwnProperty.call(normalizedEntry, 'value')
|
||||||
|
const hasLocked = Object.prototype.hasOwnProperty.call(normalizedEntry, 'islocked')
|
||||||
|
if (hasValue) {
|
||||||
|
assignParsedSettingValue(values, key, normalizedEntry.value)
|
||||||
|
}
|
||||||
|
if (hasLocked) {
|
||||||
|
const lockKey = parseSettingLockKey(key)
|
||||||
|
if (lockKey) {
|
||||||
|
locks[lockKey] = parseBoolean(normalizedEntry.islocked, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
assignParsedSettingValue(values, key, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { values, locks }
|
||||||
|
}
|
||||||
|
|
||||||
function parseContentExperienceOverride(
|
function parseContentExperienceOverride(
|
||||||
rawValue: unknown,
|
rawValue: unknown,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
@@ -325,6 +493,152 @@ function parseContentExperienceOverride(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseControlDisplayContentOverride(
|
||||||
|
rawValue: unknown,
|
||||||
|
baseUrl: string,
|
||||||
|
): GameControlDisplayContentOverride | null {
|
||||||
|
const item = normalizeObjectRecord(rawValue)
|
||||||
|
if (!Object.keys(item).length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleValue = typeof item.title === 'string' ? item.title.trim() : ''
|
||||||
|
const templateRaw = typeof item.template === 'string' ? item.template.trim().toLowerCase() : ''
|
||||||
|
const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus'
|
||||||
|
? templateRaw
|
||||||
|
: ''
|
||||||
|
const bodyValue = typeof item.body === 'string' ? item.body.trim() : ''
|
||||||
|
const clickTitleValue = typeof item.clickTitle === 'string' ? item.clickTitle.trim() : ''
|
||||||
|
const clickBodyValue = typeof item.clickBody === 'string' ? item.clickBody.trim() : ''
|
||||||
|
const autoPopupValue = item.autoPopup
|
||||||
|
const onceValue = item.once
|
||||||
|
const priorityNumeric = Number(item.priority)
|
||||||
|
const ctasValue = parseContentCardCtas(item.ctas)
|
||||||
|
const contentExperienceValue = parseContentExperienceOverride(item.contentExperience, baseUrl)
|
||||||
|
const clickExperienceValue = parseContentExperienceOverride(item.clickExperience, baseUrl)
|
||||||
|
const hasAutoPopup = typeof autoPopupValue === 'boolean'
|
||||||
|
const hasOnce = typeof onceValue === 'boolean'
|
||||||
|
const hasPriority = Number.isFinite(priorityNumeric)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!templateValue
|
||||||
|
&& !titleValue
|
||||||
|
&& !bodyValue
|
||||||
|
&& !clickTitleValue
|
||||||
|
&& !clickBodyValue
|
||||||
|
&& !hasAutoPopup
|
||||||
|
&& !hasOnce
|
||||||
|
&& !hasPriority
|
||||||
|
&& !ctasValue
|
||||||
|
&& !contentExperienceValue
|
||||||
|
&& !clickExperienceValue
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: GameControlDisplayContentOverride = {}
|
||||||
|
if (templateValue) {
|
||||||
|
parsed.template = templateValue
|
||||||
|
}
|
||||||
|
if (titleValue) {
|
||||||
|
parsed.title = titleValue
|
||||||
|
}
|
||||||
|
if (bodyValue) {
|
||||||
|
parsed.body = bodyValue
|
||||||
|
}
|
||||||
|
if (clickTitleValue) {
|
||||||
|
parsed.clickTitle = clickTitleValue
|
||||||
|
}
|
||||||
|
if (clickBodyValue) {
|
||||||
|
parsed.clickBody = clickBodyValue
|
||||||
|
}
|
||||||
|
if (hasAutoPopup) {
|
||||||
|
parsed.autoPopup = !!autoPopupValue
|
||||||
|
}
|
||||||
|
if (hasOnce) {
|
||||||
|
parsed.once = !!onceValue
|
||||||
|
}
|
||||||
|
if (hasPriority) {
|
||||||
|
parsed.priority = Math.max(0, Math.round(priorityNumeric))
|
||||||
|
}
|
||||||
|
if (ctasValue) {
|
||||||
|
parsed.ctas = ctasValue
|
||||||
|
}
|
||||||
|
if (contentExperienceValue) {
|
||||||
|
parsed.contentExperience = contentExperienceValue
|
||||||
|
}
|
||||||
|
if (clickExperienceValue) {
|
||||||
|
parsed.clickExperience = clickExperienceValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseControlPointStyleOverride(
|
||||||
|
rawValue: unknown,
|
||||||
|
fallbackPointStyle: ControlPointStyleEntry,
|
||||||
|
): ControlPointStyleEntry | null {
|
||||||
|
const item = normalizeObjectRecord(rawValue)
|
||||||
|
if (!Object.keys(item).length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPointStyle = getFirstDefined(item, ['pointstyle', 'style'])
|
||||||
|
const rawPointColor = getFirstDefined(item, ['pointcolorhex', 'pointcolor', 'color', 'colorhex'])
|
||||||
|
const rawPointSizeScale = getFirstDefined(item, ['pointsizescale', 'sizescale'])
|
||||||
|
const rawPointAccentRingScale = getFirstDefined(item, ['pointaccentringscale', 'accentringscale'])
|
||||||
|
const rawPointGlowStrength = getFirstDefined(item, ['pointglowstrength', 'glowstrength'])
|
||||||
|
const rawPointLabelScale = getFirstDefined(item, ['pointlabelscale', 'labelscale'])
|
||||||
|
const rawPointLabelColor = getFirstDefined(item, ['pointlabelcolorhex', 'pointlabelcolor', 'labelcolor', 'labelcolorhex'])
|
||||||
|
if (
|
||||||
|
rawPointStyle === undefined
|
||||||
|
&& rawPointColor === undefined
|
||||||
|
&& rawPointSizeScale === undefined
|
||||||
|
&& rawPointAccentRingScale === undefined
|
||||||
|
&& rawPointGlowStrength === undefined
|
||||||
|
&& rawPointLabelScale === undefined
|
||||||
|
&& rawPointLabelColor === undefined
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style),
|
||||||
|
colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex),
|
||||||
|
sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1),
|
||||||
|
accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0),
|
||||||
|
glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2),
|
||||||
|
labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1),
|
||||||
|
labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLegStyleOverride(
|
||||||
|
rawValue: unknown,
|
||||||
|
fallbackLegStyle: CourseLegStyleEntry,
|
||||||
|
): CourseLegStyleEntry | null {
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
const rawStyle = getFirstDefined(normalized, ['style'])
|
||||||
|
const rawColor = getFirstDefined(normalized, ['color', 'colorhex'])
|
||||||
|
const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
|
||||||
|
const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
|
||||||
|
if (
|
||||||
|
rawStyle === undefined
|
||||||
|
&& rawColor === undefined
|
||||||
|
&& rawWidthScale === undefined
|
||||||
|
&& rawGlowStrength === undefined
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: parseCourseLegStyleId(rawStyle, fallbackLegStyle.style),
|
||||||
|
colorHex: normalizeHexColor(rawColor, fallbackLegStyle.colorHex),
|
||||||
|
widthScale: parsePositiveNumber(rawWidthScale, fallbackLegStyle.widthScale || 1),
|
||||||
|
glowStrength: clamp(parseNumber(rawGlowStrength, fallbackLegStyle.glowStrength || 0), 0, 1.2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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'
|
||||||
@@ -684,6 +998,7 @@ function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
|
|||||||
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'controlcompleted:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
|
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'controlcompleted:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
|
||||||
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'punchfeedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
|
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'punchfeedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
|
||||||
{ key: 'guidance:searching', aliases: ['guidance:searching', 'guidance_searching', 'searching', 'search', 'normal_search'] },
|
{ key: 'guidance:searching', aliases: ['guidance:searching', 'guidance_searching', 'searching', 'search', 'normal_search'] },
|
||||||
|
{ key: 'guidance:distant', aliases: ['guidance:distant', 'guidance_distant', 'distant', 'far', 'far_distance'] },
|
||||||
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] },
|
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] },
|
||||||
{ key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] },
|
{ key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] },
|
||||||
]
|
]
|
||||||
@@ -705,11 +1020,29 @@ function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
|
|||||||
? parsePositiveNumber(normalized.volume, 1)
|
? parsePositiveNumber(normalized.volume, 1)
|
||||||
: undefined,
|
: undefined,
|
||||||
obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined,
|
obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined,
|
||||||
|
distantDistanceMeters: normalized.distantdistancemeters !== undefined
|
||||||
|
? parsePositiveNumber(normalized.distantdistancemeters, 80)
|
||||||
|
: normalized.distantdistance !== undefined
|
||||||
|
? parsePositiveNumber(normalized.distantdistance, 80)
|
||||||
|
: normalized.fardistancemeters !== undefined
|
||||||
|
? parsePositiveNumber(normalized.fardistancemeters, 80)
|
||||||
|
: normalized.fardistance !== undefined
|
||||||
|
? parsePositiveNumber(normalized.fardistance, 80)
|
||||||
|
: undefined,
|
||||||
approachDistanceMeters: normalized.approachdistancemeters !== undefined
|
approachDistanceMeters: normalized.approachdistancemeters !== undefined
|
||||||
? parsePositiveNumber(normalized.approachdistancemeters, 20)
|
? parsePositiveNumber(normalized.approachdistancemeters, 20)
|
||||||
: normalized.approachdistance !== undefined
|
: normalized.approachdistance !== undefined
|
||||||
? parsePositiveNumber(normalized.approachdistance, 20)
|
? parsePositiveNumber(normalized.approachdistance, 20)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
readyDistanceMeters: normalized.readydistancemeters !== undefined
|
||||||
|
? parsePositiveNumber(normalized.readydistancemeters, 5)
|
||||||
|
: normalized.readydistance !== undefined
|
||||||
|
? parsePositiveNumber(normalized.readydistance, 5)
|
||||||
|
: normalized.punchreadydistancemeters !== undefined
|
||||||
|
? parsePositiveNumber(normalized.punchreadydistancemeters, 5)
|
||||||
|
: normalized.punchreadydistance !== undefined
|
||||||
|
? parsePositiveNumber(normalized.punchreadydistance, 5)
|
||||||
|
: undefined,
|
||||||
cues,
|
cues,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1120,6 +1453,7 @@ function parseHapticsConfig(rawValue: unknown): GameHapticsConfig {
|
|||||||
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
|
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
|
||||||
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
|
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
|
||||||
{ key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
|
{ key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
|
||||||
|
{ key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] },
|
||||||
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
|
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
|
||||||
{ key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
|
{ key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
|
||||||
]
|
]
|
||||||
@@ -1153,6 +1487,7 @@ function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig {
|
|||||||
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
|
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
|
||||||
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
|
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
|
||||||
{ key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
|
{ key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
|
||||||
|
{ key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] },
|
||||||
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
|
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
|
||||||
{ key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
|
{ key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
|
||||||
]
|
]
|
||||||
@@ -1194,6 +1529,9 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map)
|
const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map)
|
||||||
? parsed.map as Record<string, unknown>
|
? parsed.map as Record<string, unknown>
|
||||||
: null
|
: null
|
||||||
|
const rawSettings = parsed.settings && typeof parsed.settings === 'object' && !Array.isArray(parsed.settings)
|
||||||
|
? parsed.settings as Record<string, unknown>
|
||||||
|
: null
|
||||||
const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield)
|
const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield)
|
||||||
? parsed.playfield as Record<string, unknown>
|
? parsed.playfield as Record<string, unknown>
|
||||||
: null
|
: null
|
||||||
@@ -1241,6 +1579,9 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring)
|
const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring)
|
||||||
? rawGame.scoring as Record<string, unknown>
|
? rawGame.scoring as Record<string, unknown>
|
||||||
: null
|
: null
|
||||||
|
const rawGameSettings = rawGame && rawGame.settings && typeof rawGame.settings === 'object' && !Array.isArray(rawGame.settings)
|
||||||
|
? rawGame.settings as Record<string, unknown>
|
||||||
|
: null
|
||||||
|
|
||||||
const mapRoot = rawMap && typeof rawMap.tiles === 'string'
|
const mapRoot = rawMap && typeof rawMap.tiles === 'string'
|
||||||
? rawMap.tiles
|
? rawMap.tiles
|
||||||
@@ -1258,9 +1599,22 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
|
|
||||||
const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
|
const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
|
||||||
const gameMode = parseGameMode(modeValue)
|
const gameMode = parseGameMode(modeValue)
|
||||||
|
const modeDefaults = getGameModeDefaults(gameMode)
|
||||||
|
const fallbackPointStyle = gameMode === 'score-o'
|
||||||
|
? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default
|
||||||
|
: DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default
|
||||||
|
const fallbackLegStyle = DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default
|
||||||
|
const rawControlDefaults = rawPlayfield && rawPlayfield.controlDefaults && typeof rawPlayfield.controlDefaults === 'object' && !Array.isArray(rawPlayfield.controlDefaults)
|
||||||
|
? rawPlayfield.controlDefaults as Record<string, unknown>
|
||||||
|
: null
|
||||||
const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides)
|
const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides)
|
||||||
? rawPlayfield.controlOverrides as Record<string, unknown>
|
? rawPlayfield.controlOverrides as Record<string, unknown>
|
||||||
: null
|
: null
|
||||||
|
const defaultControlScoreFromPlayfield = rawControlDefaults
|
||||||
|
? Number(getFirstDefined(normalizeObjectRecord(rawControlDefaults), ['score']))
|
||||||
|
: Number.NaN
|
||||||
|
const defaultControlContentOverride = parseControlDisplayContentOverride(rawControlDefaults, gameConfigUrl)
|
||||||
|
const defaultControlPointStyleOverride = parseControlPointStyleOverride(rawControlDefaults, fallbackPointStyle)
|
||||||
const controlScoreOverrides: Record<string, number> = {}
|
const controlScoreOverrides: Record<string, number> = {}
|
||||||
const controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {}
|
const controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {}
|
||||||
const controlPointStyleOverrides: Record<string, ControlPointStyleEntry> = {}
|
const controlPointStyleOverrides: Record<string, ControlPointStyleEntry> = {}
|
||||||
@@ -1275,92 +1629,21 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
if (Number.isFinite(scoreValue)) {
|
if (Number.isFinite(scoreValue)) {
|
||||||
controlScoreOverrides[key] = scoreValue
|
controlScoreOverrides[key] = scoreValue
|
||||||
}
|
}
|
||||||
const rawPointStyle = getFirstDefined(item as Record<string, unknown>, ['pointStyle'])
|
const styleOverride = parseControlPointStyleOverride(item, fallbackPointStyle)
|
||||||
const rawPointColor = getFirstDefined(item as Record<string, unknown>, ['pointColorHex'])
|
if (styleOverride) {
|
||||||
const rawPointSizeScale = getFirstDefined(item as Record<string, unknown>, ['pointSizeScale'])
|
controlPointStyleOverrides[key] = styleOverride
|
||||||
const rawPointAccentRingScale = getFirstDefined(item as Record<string, unknown>, ['pointAccentRingScale'])
|
|
||||||
const rawPointGlowStrength = getFirstDefined(item as Record<string, unknown>, ['pointGlowStrength'])
|
|
||||||
const rawPointLabelScale = getFirstDefined(item as Record<string, unknown>, ['pointLabelScale'])
|
|
||||||
const rawPointLabelColor = getFirstDefined(item as Record<string, unknown>, ['pointLabelColorHex'])
|
|
||||||
if (
|
|
||||||
rawPointStyle !== undefined
|
|
||||||
|| rawPointColor !== undefined
|
|
||||||
|| rawPointSizeScale !== undefined
|
|
||||||
|| rawPointAccentRingScale !== undefined
|
|
||||||
|| rawPointGlowStrength !== undefined
|
|
||||||
|| rawPointLabelScale !== undefined
|
|
||||||
|| rawPointLabelColor !== undefined
|
|
||||||
) {
|
|
||||||
const fallbackPointStyle = gameMode === 'score-o'
|
|
||||||
? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default
|
|
||||||
: DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default
|
|
||||||
controlPointStyleOverrides[key] = {
|
|
||||||
style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style),
|
|
||||||
colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex),
|
|
||||||
sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1),
|
|
||||||
accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0),
|
|
||||||
glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2),
|
|
||||||
labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1),
|
|
||||||
labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const titleValue = typeof (item as Record<string, unknown>).title === 'string'
|
|
||||||
? ((item as Record<string, unknown>).title as string).trim()
|
|
||||||
: ''
|
|
||||||
const templateRaw = typeof (item as Record<string, unknown>).template === 'string'
|
|
||||||
? ((item as Record<string, unknown>).template as string).trim().toLowerCase()
|
|
||||||
: ''
|
|
||||||
const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus'
|
|
||||||
? templateRaw
|
|
||||||
: ''
|
|
||||||
const bodyValue = typeof (item as Record<string, unknown>).body === 'string'
|
|
||||||
? ((item as Record<string, unknown>).body as string).trim()
|
|
||||||
: ''
|
|
||||||
const clickTitleValue = typeof (item as Record<string, unknown>).clickTitle === 'string'
|
|
||||||
? ((item as Record<string, unknown>).clickTitle as string).trim()
|
|
||||||
: ''
|
|
||||||
const clickBodyValue = typeof (item as Record<string, unknown>).clickBody === 'string'
|
|
||||||
? ((item as Record<string, unknown>).clickBody as string).trim()
|
|
||||||
: ''
|
|
||||||
const autoPopupValue = (item as Record<string, unknown>).autoPopup
|
|
||||||
const onceValue = (item as Record<string, unknown>).once
|
|
||||||
const priorityNumeric = Number((item as Record<string, unknown>).priority)
|
|
||||||
const ctasValue = parseContentCardCtas((item as Record<string, unknown>).ctas)
|
|
||||||
const contentExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).contentExperience, gameConfigUrl)
|
|
||||||
const clickExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).clickExperience, gameConfigUrl)
|
|
||||||
const hasAutoPopup = typeof autoPopupValue === 'boolean'
|
|
||||||
const hasOnce = typeof onceValue === 'boolean'
|
|
||||||
const hasPriority = Number.isFinite(priorityNumeric)
|
|
||||||
if (
|
|
||||||
templateValue
|
|
||||||
|| titleValue
|
|
||||||
|| bodyValue
|
|
||||||
|| clickTitleValue
|
|
||||||
|| clickBodyValue
|
|
||||||
|| hasAutoPopup
|
|
||||||
|| hasOnce
|
|
||||||
|| hasPriority
|
|
||||||
|| ctasValue
|
|
||||||
|| contentExperienceValue
|
|
||||||
|| clickExperienceValue
|
|
||||||
) {
|
|
||||||
controlContentOverrides[key] = {
|
|
||||||
...(templateValue ? { template: templateValue } : {}),
|
|
||||||
...(titleValue ? { title: titleValue } : {}),
|
|
||||||
...(bodyValue ? { body: bodyValue } : {}),
|
|
||||||
...(clickTitleValue ? { clickTitle: clickTitleValue } : {}),
|
|
||||||
...(clickBodyValue ? { clickBody: clickBodyValue } : {}),
|
|
||||||
...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
|
|
||||||
...(hasOnce ? { once: !!onceValue } : {}),
|
|
||||||
...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
|
|
||||||
...(ctasValue ? { ctas: ctasValue } : {}),
|
|
||||||
...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}),
|
|
||||||
...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}),
|
|
||||||
}
|
}
|
||||||
|
const contentOverride = parseControlDisplayContentOverride(item, gameConfigUrl)
|
||||||
|
if (contentOverride) {
|
||||||
|
controlContentOverrides[key] = contentOverride
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawLegDefaults = rawPlayfield && rawPlayfield.legDefaults && typeof rawPlayfield.legDefaults === 'object' && !Array.isArray(rawPlayfield.legDefaults)
|
||||||
|
? rawPlayfield.legDefaults as Record<string, unknown>
|
||||||
|
: null
|
||||||
|
const defaultLegStyleOverride = parseLegStyleOverride(rawLegDefaults, fallbackLegStyle)
|
||||||
const rawLegOverrides = rawPlayfield && rawPlayfield.legOverrides && typeof rawPlayfield.legOverrides === 'object' && !Array.isArray(rawPlayfield.legOverrides)
|
const rawLegOverrides = rawPlayfield && rawPlayfield.legOverrides && typeof rawPlayfield.legOverrides === 'object' && !Array.isArray(rawPlayfield.legOverrides)
|
||||||
? rawPlayfield.legOverrides as Record<string, unknown>
|
? rawPlayfield.legOverrides as Record<string, unknown>
|
||||||
: null
|
: null
|
||||||
@@ -1373,23 +1656,99 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
if (index === null || !item || typeof item !== 'object' || Array.isArray(item)) {
|
if (index === null || !item || typeof item !== 'object' || Array.isArray(item)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const normalized = normalizeObjectRecord(item)
|
const legOverride = parseLegStyleOverride(item, fallbackLegStyle)
|
||||||
const rawStyle = getFirstDefined(normalized, ['style'])
|
if (!legOverride) {
|
||||||
const rawColor = getFirstDefined(normalized, ['color', 'colorhex'])
|
|
||||||
const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
|
|
||||||
const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
|
|
||||||
if (rawStyle === undefined && rawColor === undefined && rawWidthScale === undefined && rawGlowStrength === undefined) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
legStyleOverrides[index] = {
|
legStyleOverrides[index] = legOverride
|
||||||
style: parseCourseLegStyleId(rawStyle, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.style),
|
|
||||||
colorHex: normalizeHexColor(rawColor, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.colorHex),
|
|
||||||
widthScale: parsePositiveNumber(rawWidthScale, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.widthScale || 1),
|
|
||||||
glowStrength: clamp(parseNumber(rawGlowStrength, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.glowStrength || 0), 0, 1.2),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const punchRadiusMeters = parsePositiveNumber(
|
||||||
|
rawPunch && rawPunch.radiusMeters !== undefined
|
||||||
|
? rawPunch.radiusMeters
|
||||||
|
: normalizedGame.punchradiusmeters !== undefined
|
||||||
|
? normalizedGame.punchradiusmeters
|
||||||
|
: normalizedGame.punchradius !== undefined
|
||||||
|
? normalizedGame.punchradius
|
||||||
|
: normalized.punchradiusmeters !== undefined
|
||||||
|
? normalized.punchradiusmeters
|
||||||
|
: normalized.punchradius,
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
const sessionCloseAfterMs = parsePositiveNumber(
|
||||||
|
rawSession && rawSession.closeAfterMs !== undefined
|
||||||
|
? rawSession.closeAfterMs
|
||||||
|
: rawSession && rawSession.sessionCloseAfterMs !== undefined
|
||||||
|
? rawSession.sessionCloseAfterMs
|
||||||
|
: normalizedGame.sessioncloseafterms !== undefined
|
||||||
|
? normalizedGame.sessioncloseafterms
|
||||||
|
: normalized.sessioncloseafterms,
|
||||||
|
modeDefaults.sessionCloseAfterMs,
|
||||||
|
)
|
||||||
|
const sessionCloseWarningMs = parsePositiveNumber(
|
||||||
|
rawSession && rawSession.closeWarningMs !== undefined
|
||||||
|
? rawSession.closeWarningMs
|
||||||
|
: rawSession && rawSession.sessionCloseWarningMs !== undefined
|
||||||
|
? rawSession.sessionCloseWarningMs
|
||||||
|
: normalizedGame.sessionclosewarningms !== undefined
|
||||||
|
? normalizedGame.sessionclosewarningms
|
||||||
|
: normalized.sessionclosewarningms,
|
||||||
|
modeDefaults.sessionCloseWarningMs,
|
||||||
|
)
|
||||||
|
const minCompletedControlsBeforeFinish = Math.max(0, Math.floor(parseNumber(
|
||||||
|
rawSession && rawSession.minCompletedControlsBeforeFinish !== undefined
|
||||||
|
? rawSession.minCompletedControlsBeforeFinish
|
||||||
|
: rawSession && rawSession.minControlsBeforeFinish !== undefined
|
||||||
|
? rawSession.minControlsBeforeFinish
|
||||||
|
: normalizedGame.mincompletedcontrolsbeforefinish !== undefined
|
||||||
|
? normalizedGame.mincompletedcontrolsbeforefinish
|
||||||
|
: normalizedGame.mincontrolsbeforefinish !== undefined
|
||||||
|
? normalizedGame.mincontrolsbeforefinish
|
||||||
|
: normalized.mincompletedcontrolsbeforefinish !== undefined
|
||||||
|
? normalized.mincompletedcontrolsbeforefinish
|
||||||
|
: normalized.mincontrolsbeforefinish,
|
||||||
|
modeDefaults.minCompletedControlsBeforeFinish,
|
||||||
|
)))
|
||||||
|
const requiresFocusSelection = parseBoolean(
|
||||||
|
rawPunch && rawPunch.requiresFocusSelection !== undefined
|
||||||
|
? rawPunch.requiresFocusSelection
|
||||||
|
: normalizedGame.requiresfocusselection !== undefined
|
||||||
|
? normalizedGame.requiresfocusselection
|
||||||
|
: rawPunch && (rawPunch as Record<string, unknown>).requiresfocusselection !== undefined
|
||||||
|
? (rawPunch as Record<string, unknown>).requiresfocusselection
|
||||||
|
: normalized.requiresfocusselection,
|
||||||
|
modeDefaults.requiresFocusSelection,
|
||||||
|
)
|
||||||
|
const skipEnabled = parseBoolean(
|
||||||
|
rawSkip && rawSkip.enabled !== undefined
|
||||||
|
? rawSkip.enabled
|
||||||
|
: normalizedGame.skipenabled !== undefined
|
||||||
|
? normalizedGame.skipenabled
|
||||||
|
: normalized.skipenabled,
|
||||||
|
modeDefaults.skipEnabled,
|
||||||
|
)
|
||||||
|
const skipRadiusMeters = parsePositiveNumber(
|
||||||
|
rawSkip && rawSkip.radiusMeters !== undefined
|
||||||
|
? rawSkip.radiusMeters
|
||||||
|
: normalizedGame.skipradiusmeters !== undefined
|
||||||
|
? normalizedGame.skipradiusmeters
|
||||||
|
: normalizedGame.skipradius !== undefined
|
||||||
|
? normalizedGame.skipradius
|
||||||
|
: normalized.skipradiusmeters !== undefined
|
||||||
|
? normalized.skipradiusmeters
|
||||||
|
: normalized.skipradius,
|
||||||
|
getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters),
|
||||||
|
)
|
||||||
|
const autoFinishOnLastControl = parseBoolean(
|
||||||
|
rawSession && rawSession.autoFinishOnLastControl !== undefined
|
||||||
|
? rawSession.autoFinishOnLastControl
|
||||||
|
: normalizedGame.autofinishonlastcontrol !== undefined
|
||||||
|
? normalizedGame.autofinishonlastcontrol
|
||||||
|
: normalized.autofinishonlastcontrol,
|
||||||
|
modeDefaults.autoFinishOnLastControl,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: rawApp && typeof rawApp.title === 'string' ? rawApp.title : '',
|
title: rawApp && typeof rawApp.title === 'string' ? rawApp.title : '',
|
||||||
appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '',
|
appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '',
|
||||||
@@ -1410,6 +1769,9 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
? parsePositiveNumber((rawMap.initialView as Record<string, unknown>).zoom, 17)
|
? parsePositiveNumber((rawMap.initialView as Record<string, unknown>).zoom, 17)
|
||||||
: null,
|
: null,
|
||||||
gameMode,
|
gameMode,
|
||||||
|
sessionCloseAfterMs,
|
||||||
|
sessionCloseWarningMs,
|
||||||
|
minCompletedControlsBeforeFinish,
|
||||||
punchPolicy: parsePunchPolicy(
|
punchPolicy: parsePunchPolicy(
|
||||||
rawPunch && rawPunch.policy !== undefined
|
rawPunch && rawPunch.policy !== undefined
|
||||||
? rawPunch.policy
|
? rawPunch.policy
|
||||||
@@ -1417,71 +1779,34 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
? normalizedGame.punchpolicy
|
? normalizedGame.punchpolicy
|
||||||
: normalized.punchpolicy,
|
: normalized.punchpolicy,
|
||||||
),
|
),
|
||||||
punchRadiusMeters: parsePositiveNumber(
|
punchRadiusMeters,
|
||||||
rawPunch && rawPunch.radiusMeters !== undefined
|
requiresFocusSelection,
|
||||||
? rawPunch.radiusMeters
|
skipEnabled,
|
||||||
: normalizedGame.punchradiusmeters !== undefined
|
skipRadiusMeters,
|
||||||
? normalizedGame.punchradiusmeters
|
|
||||||
: normalizedGame.punchradius !== undefined
|
|
||||||
? normalizedGame.punchradius
|
|
||||||
: normalized.punchradiusmeters !== undefined
|
|
||||||
? normalized.punchradiusmeters
|
|
||||||
: normalized.punchradius,
|
|
||||||
5,
|
|
||||||
),
|
|
||||||
requiresFocusSelection: parseBoolean(
|
|
||||||
rawPunch && rawPunch.requiresFocusSelection !== undefined
|
|
||||||
? rawPunch.requiresFocusSelection
|
|
||||||
: normalizedGame.requiresfocusselection !== undefined
|
|
||||||
? normalizedGame.requiresfocusselection
|
|
||||||
: rawPunch && (rawPunch as Record<string, unknown>).requiresfocusselection !== undefined
|
|
||||||
? (rawPunch as Record<string, unknown>).requiresfocusselection
|
|
||||||
: normalized.requiresfocusselection,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
skipEnabled: parseBoolean(
|
|
||||||
rawSkip && rawSkip.enabled !== undefined
|
|
||||||
? rawSkip.enabled
|
|
||||||
: normalizedGame.skipenabled !== undefined
|
|
||||||
? normalizedGame.skipenabled
|
|
||||||
: normalized.skipenabled,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
skipRadiusMeters: parsePositiveNumber(
|
|
||||||
rawSkip && rawSkip.radiusMeters !== undefined
|
|
||||||
? rawSkip.radiusMeters
|
|
||||||
: normalizedGame.skipradiusmeters !== undefined
|
|
||||||
? normalizedGame.skipradiusmeters
|
|
||||||
: normalizedGame.skipradius !== undefined
|
|
||||||
? normalizedGame.skipradius
|
|
||||||
: normalized.skipradiusmeters !== undefined
|
|
||||||
? normalized.skipradiusmeters
|
|
||||||
: normalized.skipradius,
|
|
||||||
30,
|
|
||||||
),
|
|
||||||
skipRequiresConfirm: parseBoolean(
|
skipRequiresConfirm: parseBoolean(
|
||||||
rawSkip && rawSkip.requiresConfirm !== undefined
|
rawSkip && rawSkip.requiresConfirm !== undefined
|
||||||
? rawSkip.requiresConfirm
|
? rawSkip.requiresConfirm
|
||||||
: normalizedGame.skiprequiresconfirm !== undefined
|
: normalizedGame.skiprequiresconfirm !== undefined
|
||||||
? normalizedGame.skiprequiresconfirm
|
? normalizedGame.skiprequiresconfirm
|
||||||
: normalized.skiprequiresconfirm,
|
: normalized.skiprequiresconfirm,
|
||||||
true,
|
modeDefaults.skipRequiresConfirm,
|
||||||
),
|
|
||||||
autoFinishOnLastControl: parseBoolean(
|
|
||||||
rawSession && rawSession.autoFinishOnLastControl !== undefined
|
|
||||||
? rawSession.autoFinishOnLastControl
|
|
||||||
: normalizedGame.autofinishonlastcontrol !== undefined
|
|
||||||
? normalizedGame.autofinishonlastcontrol
|
|
||||||
: normalized.autofinishonlastcontrol,
|
|
||||||
true,
|
|
||||||
),
|
),
|
||||||
|
autoFinishOnLastControl,
|
||||||
controlScoreOverrides,
|
controlScoreOverrides,
|
||||||
controlContentOverrides,
|
controlContentOverrides,
|
||||||
|
defaultControlContentOverride,
|
||||||
|
defaultControlPointStyleOverride,
|
||||||
controlPointStyleOverrides,
|
controlPointStyleOverrides,
|
||||||
|
defaultLegStyleOverride,
|
||||||
legStyleOverrides,
|
legStyleOverrides,
|
||||||
defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined
|
defaultControlScore: resolveDefaultControlScore(
|
||||||
? parsePositiveNumber(rawScoring.defaultControlScore, 10)
|
gameMode,
|
||||||
|
Number.isFinite(defaultControlScoreFromPlayfield)
|
||||||
|
? defaultControlScoreFromPlayfield
|
||||||
|
: rawScoring && rawScoring.defaultControlScore !== undefined
|
||||||
|
? parsePositiveNumber(rawScoring.defaultControlScore, modeDefaults.defaultControlScore)
|
||||||
: null,
|
: null,
|
||||||
|
),
|
||||||
courseStyleConfig: parseCourseStyleConfig(rawGamePresentation),
|
courseStyleConfig: parseCourseStyleConfig(rawGamePresentation),
|
||||||
trackStyleConfig: parseTrackVisualizationConfig(getFirstDefined(normalizedGamePresentation, ['track'])),
|
trackStyleConfig: parseTrackVisualizationConfig(getFirstDefined(normalizedGamePresentation, ['track'])),
|
||||||
gpsMarkerStyleConfig: parseGpsMarkerStyleConfig(getFirstDefined(normalizedGamePresentation, ['gpsmarker', 'gps'])),
|
gpsMarkerStyleConfig: parseGpsMarkerStyleConfig(getFirstDefined(normalizedGamePresentation, ['gpsmarker', 'gps'])),
|
||||||
@@ -1489,6 +1814,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
|
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
|
||||||
hapticsConfig: parseHapticsConfig(rawHaptics),
|
hapticsConfig: parseHapticsConfig(rawHaptics),
|
||||||
uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
|
uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
|
||||||
|
systemSettingsConfig: parseSystemSettingsConfig(rawGameSettings || rawSettings),
|
||||||
declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination),
|
declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1518,6 +1844,11 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gameMode = parseGameMode(config.gamemode)
|
const gameMode = parseGameMode(config.gamemode)
|
||||||
|
const modeDefaults = getGameModeDefaults(gameMode)
|
||||||
|
const punchRadiusMeters = parsePositiveNumber(
|
||||||
|
config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: '',
|
title: '',
|
||||||
@@ -1530,24 +1861,27 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
|
cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
|
||||||
defaultZoom: null,
|
defaultZoom: null,
|
||||||
gameMode,
|
gameMode,
|
||||||
|
sessionCloseAfterMs: modeDefaults.sessionCloseAfterMs,
|
||||||
|
sessionCloseWarningMs: modeDefaults.sessionCloseWarningMs,
|
||||||
|
minCompletedControlsBeforeFinish: modeDefaults.minCompletedControlsBeforeFinish,
|
||||||
punchPolicy: parsePunchPolicy(config.punchpolicy),
|
punchPolicy: parsePunchPolicy(config.punchpolicy),
|
||||||
punchRadiusMeters: parsePositiveNumber(
|
punchRadiusMeters,
|
||||||
config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
|
requiresFocusSelection: parseBoolean(config.requiresfocusselection, modeDefaults.requiresFocusSelection),
|
||||||
5,
|
skipEnabled: parseBoolean(config.skipenabled, modeDefaults.skipEnabled),
|
||||||
),
|
|
||||||
requiresFocusSelection: parseBoolean(config.requiresfocusselection, false),
|
|
||||||
skipEnabled: parseBoolean(config.skipenabled, false),
|
|
||||||
skipRadiusMeters: parsePositiveNumber(
|
skipRadiusMeters: parsePositiveNumber(
|
||||||
config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius,
|
config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius,
|
||||||
30,
|
getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters),
|
||||||
),
|
),
|
||||||
skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, true),
|
skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, modeDefaults.skipRequiresConfirm),
|
||||||
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
|
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, modeDefaults.autoFinishOnLastControl),
|
||||||
controlScoreOverrides: {},
|
controlScoreOverrides: {},
|
||||||
controlContentOverrides: {},
|
controlContentOverrides: {},
|
||||||
|
defaultControlContentOverride: null,
|
||||||
|
defaultControlPointStyleOverride: null,
|
||||||
controlPointStyleOverrides: {},
|
controlPointStyleOverrides: {},
|
||||||
|
defaultLegStyleOverride: null,
|
||||||
legStyleOverrides: {},
|
legStyleOverrides: {},
|
||||||
defaultControlScore: null,
|
defaultControlScore: modeDefaults.defaultControlScore,
|
||||||
courseStyleConfig: DEFAULT_COURSE_STYLE_CONFIG,
|
courseStyleConfig: DEFAULT_COURSE_STYLE_CONFIG,
|
||||||
trackStyleConfig: DEFAULT_TRACK_VISUALIZATION_CONFIG,
|
trackStyleConfig: DEFAULT_TRACK_VISUALIZATION_CONFIG,
|
||||||
gpsMarkerStyleConfig: DEFAULT_GPS_MARKER_STYLE_CONFIG,
|
gpsMarkerStyleConfig: DEFAULT_GPS_MARKER_STYLE_CONFIG,
|
||||||
@@ -1567,7 +1901,21 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
enabled: config.audioenabled,
|
enabled: config.audioenabled,
|
||||||
masterVolume: config.audiomastervolume,
|
masterVolume: config.audiomastervolume,
|
||||||
obeyMuteSwitch: config.audioobeymuteswitch,
|
obeyMuteSwitch: config.audioobeymuteswitch,
|
||||||
|
distantDistanceMeters: config.audiodistantdistancemeters !== undefined
|
||||||
|
? config.audiodistantdistancemeters
|
||||||
|
: config.audiodistantdistance !== undefined
|
||||||
|
? config.audiodistantdistance
|
||||||
|
: config.audiofardistancemeters !== undefined
|
||||||
|
? config.audiofardistancemeters
|
||||||
|
: config.audiofardistance,
|
||||||
approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance,
|
approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance,
|
||||||
|
readyDistanceMeters: config.audioreadydistancemeters !== undefined
|
||||||
|
? config.audioreadydistancemeters
|
||||||
|
: config.audioreadydistance !== undefined
|
||||||
|
? config.audioreadydistance
|
||||||
|
: config.audiopunchreadydistancemeters !== undefined
|
||||||
|
? config.audiopunchreadydistancemeters
|
||||||
|
: config.audiopunchreadydistance,
|
||||||
cues: {
|
cues: {
|
||||||
session_started: config.audiosessionstarted,
|
session_started: config.audiosessionstarted,
|
||||||
'control_completed:start': config.audiostartcomplete,
|
'control_completed:start': config.audiostartcomplete,
|
||||||
@@ -1575,6 +1923,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
'control_completed:finish': config.audiofinishcomplete,
|
'control_completed:finish': config.audiofinishcomplete,
|
||||||
'punch_feedback:warning': config.audiowarning,
|
'punch_feedback:warning': config.audiowarning,
|
||||||
'guidance:searching': config.audiosearching,
|
'guidance:searching': config.audiosearching,
|
||||||
|
'guidance:distant': config.audiodistant,
|
||||||
'guidance:approaching': config.audioapproaching,
|
'guidance:approaching': config.audioapproaching,
|
||||||
'guidance:ready': config.audioready,
|
'guidance:ready': config.audioready,
|
||||||
},
|
},
|
||||||
@@ -1589,6 +1938,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
'control_completed:finish': config.hapticsfinishcomplete,
|
'control_completed:finish': config.hapticsfinishcomplete,
|
||||||
'punch_feedback:warning': config.hapticswarning,
|
'punch_feedback:warning': config.hapticswarning,
|
||||||
'guidance:searching': config.hapticssearching,
|
'guidance:searching': config.hapticssearching,
|
||||||
|
'guidance:distant': config.hapticsdistant,
|
||||||
'guidance:approaching': config.hapticsapproaching,
|
'guidance:approaching': config.hapticsapproaching,
|
||||||
'guidance:ready': config.hapticsready,
|
'guidance:ready': config.hapticsready,
|
||||||
},
|
},
|
||||||
@@ -1605,6 +1955,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms },
|
'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
systemSettingsConfig: { values: {}, locks: {} },
|
||||||
declinationDeg: parseDeclinationValue(config.declination),
|
declinationDeg: parseDeclinationValue(config.declination),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1827,6 +2178,9 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
|
|||||||
courseStatusText,
|
courseStatusText,
|
||||||
cpRadiusMeters: gameConfig.cpRadiusMeters,
|
cpRadiusMeters: gameConfig.cpRadiusMeters,
|
||||||
gameMode: gameConfig.gameMode,
|
gameMode: gameConfig.gameMode,
|
||||||
|
sessionCloseAfterMs: gameConfig.sessionCloseAfterMs,
|
||||||
|
sessionCloseWarningMs: gameConfig.sessionCloseWarningMs,
|
||||||
|
minCompletedControlsBeforeFinish: gameConfig.minCompletedControlsBeforeFinish,
|
||||||
punchPolicy: gameConfig.punchPolicy,
|
punchPolicy: gameConfig.punchPolicy,
|
||||||
punchRadiusMeters: gameConfig.punchRadiusMeters,
|
punchRadiusMeters: gameConfig.punchRadiusMeters,
|
||||||
requiresFocusSelection: gameConfig.requiresFocusSelection,
|
requiresFocusSelection: gameConfig.requiresFocusSelection,
|
||||||
@@ -1836,7 +2190,10 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
|
|||||||
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
|
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
|
||||||
controlScoreOverrides: gameConfig.controlScoreOverrides,
|
controlScoreOverrides: gameConfig.controlScoreOverrides,
|
||||||
controlContentOverrides: gameConfig.controlContentOverrides,
|
controlContentOverrides: gameConfig.controlContentOverrides,
|
||||||
|
defaultControlContentOverride: gameConfig.defaultControlContentOverride,
|
||||||
|
defaultControlPointStyleOverride: gameConfig.defaultControlPointStyleOverride,
|
||||||
controlPointStyleOverrides: gameConfig.controlPointStyleOverrides,
|
controlPointStyleOverrides: gameConfig.controlPointStyleOverrides,
|
||||||
|
defaultLegStyleOverride: gameConfig.defaultLegStyleOverride,
|
||||||
legStyleOverrides: gameConfig.legStyleOverrides,
|
legStyleOverrides: gameConfig.legStyleOverrides,
|
||||||
defaultControlScore: gameConfig.defaultControlScore,
|
defaultControlScore: gameConfig.defaultControlScore,
|
||||||
courseStyleConfig: gameConfig.courseStyleConfig,
|
courseStyleConfig: gameConfig.courseStyleConfig,
|
||||||
@@ -1846,6 +2203,7 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
|
|||||||
audioConfig: gameConfig.audioConfig,
|
audioConfig: gameConfig.audioConfig,
|
||||||
hapticsConfig: gameConfig.hapticsConfig,
|
hapticsConfig: gameConfig.hapticsConfig,
|
||||||
uiEffectsConfig: gameConfig.uiEffectsConfig,
|
uiEffectsConfig: gameConfig.uiEffectsConfig,
|
||||||
|
systemSettingsConfig: gameConfig.systemSettingsConfig,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"typecheck:watch": "tsc --noEmit -p tsconfig.json --watch",
|
"typecheck:watch": "tsc --noEmit -p tsconfig.json --watch",
|
||||||
"mock-gps-sim": "node tools/mock-gps-sim/server.js"
|
"test:runtime-smoke": "tsc -p tsconfig.runtime-smoke.json && node .tmp-runtime-smoke/tools/runtime-smoke-test.js",
|
||||||
|
"mock-gps-sim": "node tools/mock-gps-sim/server.js",
|
||||||
|
"publish:config": "powershell -ExecutionPolicy Bypass -File ./publish-event-config.ps1 all",
|
||||||
|
"publish:config:classic": "powershell -ExecutionPolicy Bypass -File ./publish-event-config.ps1 classic-sequential",
|
||||||
|
"publish:config:score-o": "powershell -ExecutionPolicy Bypass -File ./publish-event-config.ps1 score-o",
|
||||||
|
"publish:config:dry-run": "powershell -ExecutionPolicy Bypass -File ./publish-event-config.ps1 all -DryRun"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
103
publish-event-config.ps1
Normal file
103
publish-event-config.ps1
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Position = 0)]
|
||||||
|
[string]$Target = "all",
|
||||||
|
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$projectRoot = $PSScriptRoot
|
||||||
|
$uploadScript = Join-Path $projectRoot "oss-html.ps1"
|
||||||
|
|
||||||
|
if (-not (Test-Path $uploadScript)) {
|
||||||
|
throw ("Upload script not found: {0}" -f $uploadScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
$publishTargets = @{
|
||||||
|
"classic-sequential" = @{
|
||||||
|
LocalPath = "event/classic-sequential.json"
|
||||||
|
RemotePath = "gotomars/event/classic-sequential.json"
|
||||||
|
PublicUrl = "https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json"
|
||||||
|
}
|
||||||
|
"score-o" = @{
|
||||||
|
LocalPath = "event/score-o.json"
|
||||||
|
RemotePath = "gotomars/event/score-o.json"
|
||||||
|
PublicUrl = "https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-JsonConfig {
|
||||||
|
param(
|
||||||
|
[string]$ConfigPath,
|
||||||
|
[string]$ConfigName
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path $ConfigPath)) {
|
||||||
|
throw ("Config file not found: {0}" -f $ConfigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8
|
||||||
|
try {
|
||||||
|
$parsed = $raw | ConvertFrom-Json
|
||||||
|
} catch {
|
||||||
|
throw ("JSON parse failed: {0}`n{1}" -f $ConfigPath, $_.Exception.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $parsed.schemaVersion) {
|
||||||
|
throw ("Missing schemaVersion: {0}" -f $ConfigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $parsed.game) {
|
||||||
|
throw ("Missing game block: {0}" -f $ConfigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $parsed.game.mode) {
|
||||||
|
throw ("Missing game.mode: {0}" -f $ConfigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ("[OK] {0} schemaVersion={1} game.mode={2}" -f $ConfigName, $parsed.schemaVersion, $parsed.game.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Publish-ConfigTarget {
|
||||||
|
param(
|
||||||
|
[string]$ConfigName,
|
||||||
|
[hashtable]$Config
|
||||||
|
)
|
||||||
|
|
||||||
|
$localPath = Join-Path $projectRoot $Config.LocalPath
|
||||||
|
$remotePath = $Config.RemotePath
|
||||||
|
$publicUrl = $Config.PublicUrl
|
||||||
|
|
||||||
|
Assert-JsonConfig -ConfigPath $localPath -ConfigName $ConfigName
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host ("[DRY-RUN] Skip upload: {0} -> {1}" -f $localPath, $remotePath)
|
||||||
|
Write-Host ("[DRY-RUN] Public URL: {0}" -f $publicUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ("[UPLOAD] {0} -> {1}" -f $localPath, $remotePath)
|
||||||
|
& $uploadScript cp-up $localPath $remotePath
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw ("Upload failed: {0}" -f $ConfigName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ("[DONE] {0} published" -f $ConfigName)
|
||||||
|
Write-Host ("[URL] {0}" -f $publicUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedTargets = if ($Target -eq "all") {
|
||||||
|
$publishTargets.Keys | Sort-Object
|
||||||
|
} else {
|
||||||
|
@($Target)
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($selectedTarget in $selectedTargets) {
|
||||||
|
if (-not $publishTargets.ContainsKey($selectedTarget)) {
|
||||||
|
$supported = ($publishTargets.Keys + "all" | Sort-Object) -join ", "
|
||||||
|
throw ("Unsupported publish target: {0}. Allowed: {1}" -f $selectedTarget, $supported)
|
||||||
|
}
|
||||||
|
|
||||||
|
Publish-ConfigTarget -ConfigName $selectedTarget -Config $publishTargets[$selectedTarget]
|
||||||
|
}
|
||||||
311
tools/runtime-smoke-test.ts
Normal file
311
tools/runtime-smoke-test.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
declare const console: {
|
||||||
|
log: (...args: unknown[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
import { buildGameDefinitionFromCourse } from '../miniprogram/game/content/courseToGameDefinition'
|
||||||
|
import { getGameModeDefaults } from '../miniprogram/game/core/gameModeDefaults'
|
||||||
|
import { GameRuntime } from '../miniprogram/game/core/gameRuntime'
|
||||||
|
import { ScoreORule } from '../miniprogram/game/rules/scoreORule'
|
||||||
|
import { resolveSystemSettingsState } from '../miniprogram/game/core/systemSettingsState'
|
||||||
|
import { type GameDefinition } from '../miniprogram/game/core/gameDefinition'
|
||||||
|
import { type OrienteeringCourseData } from '../miniprogram/utils/orienteeringCourse'
|
||||||
|
|
||||||
|
type StorageMap = Record<string, unknown>
|
||||||
|
|
||||||
|
function assert(condition: boolean, message: string): void {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWxStorage(storage: StorageMap): void {
|
||||||
|
;(globalThis as { wx?: unknown }).wx = {
|
||||||
|
getStorageSync(key: string): unknown {
|
||||||
|
return storage[key]
|
||||||
|
},
|
||||||
|
setStorageSync(key: string, value: unknown): void {
|
||||||
|
storage[key] = value
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCourse(): OrienteeringCourseData {
|
||||||
|
return {
|
||||||
|
title: 'Smoke Test Course',
|
||||||
|
layers: {
|
||||||
|
starts: [
|
||||||
|
{ label: 'Start', point: { lon: 120.0, lat: 30.0 }, headingDeg: 90 },
|
||||||
|
],
|
||||||
|
controls: [
|
||||||
|
{ label: '1', sequence: 1, point: { lon: 120.0001, lat: 30.0 } },
|
||||||
|
{ label: '2', sequence: 2, point: { lon: 120.0002, lat: 30.0 } },
|
||||||
|
],
|
||||||
|
finishes: [
|
||||||
|
{ label: 'Finish', point: { lon: 120.0003, lat: 30.0 } },
|
||||||
|
],
|
||||||
|
legs: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getControl(definition: GameDefinition, id: string) {
|
||||||
|
return definition.controls.find((control) => control.id === id) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function testControlInheritance(): void {
|
||||||
|
const definition = buildGameDefinitionFromCourse(
|
||||||
|
buildCourse(),
|
||||||
|
5,
|
||||||
|
'score-o',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'enter-confirm',
|
||||||
|
5,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ 'control-2': 80 },
|
||||||
|
{
|
||||||
|
title: '默认说明',
|
||||||
|
body: '所有点默认正文',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'control-2': {
|
||||||
|
body: '2号点单点覆盖正文',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
|
||||||
|
const control1 = getControl(definition, 'control-1')
|
||||||
|
const control2 = getControl(definition, 'control-2')
|
||||||
|
assert(!!control1 && !!control2, '应生成普通检查点')
|
||||||
|
assert(control1!.score === 30, 'controlDefaults 默认分值应继承到普通点')
|
||||||
|
assert(control2!.score === 80, '单点 score override 应覆盖默认分值')
|
||||||
|
assert(!!control1!.displayContent && control1!.displayContent.title === '默认说明', '默认内容标题应继承到普通点')
|
||||||
|
assert(!!control2!.displayContent && control2!.displayContent.body === '2号点单点覆盖正文', '单点内容 override 应覆盖默认正文')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testScoreOFreePunchAndFinishGate(): void {
|
||||||
|
const definition = buildGameDefinitionFromCourse(
|
||||||
|
buildCourse(),
|
||||||
|
5,
|
||||||
|
'score-o',
|
||||||
|
2 * 60 * 60 * 1000,
|
||||||
|
10 * 60 * 1000,
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
'enter-confirm',
|
||||||
|
5,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
const rule = new ScoreORule()
|
||||||
|
let state = rule.initialize(definition)
|
||||||
|
|
||||||
|
let result = rule.reduce(definition, state, { type: 'session_started', at: 1 })
|
||||||
|
state = result.nextState
|
||||||
|
|
||||||
|
result = rule.reduce(definition, state, {
|
||||||
|
type: 'gps_updated',
|
||||||
|
at: 2,
|
||||||
|
lon: 120.0,
|
||||||
|
lat: 30.0,
|
||||||
|
accuracyMeters: null,
|
||||||
|
})
|
||||||
|
state = result.nextState
|
||||||
|
result = rule.reduce(definition, state, {
|
||||||
|
type: 'punch_requested',
|
||||||
|
at: 3,
|
||||||
|
lon: 120.0,
|
||||||
|
lat: 30.0,
|
||||||
|
})
|
||||||
|
state = result.nextState
|
||||||
|
assert(state.completedControlIds.includes('start-1'), '积分赛应能完成开始点')
|
||||||
|
|
||||||
|
result = rule.reduce(definition, state, {
|
||||||
|
type: 'gps_updated',
|
||||||
|
at: 4,
|
||||||
|
lon: 120.0001,
|
||||||
|
lat: 30.0,
|
||||||
|
accuracyMeters: null,
|
||||||
|
})
|
||||||
|
state = result.nextState
|
||||||
|
assert(result.presentation.hud.punchButtonEnabled, '自由打点时进入普通点范围应可直接打点')
|
||||||
|
result = rule.reduce(definition, state, {
|
||||||
|
type: 'punch_requested',
|
||||||
|
at: 5,
|
||||||
|
lon: 120.0001,
|
||||||
|
lat: 30.0,
|
||||||
|
})
|
||||||
|
state = result.nextState
|
||||||
|
assert(state.completedControlIds.includes('control-1'), '积分赛默认无需先选中也应可打普通点')
|
||||||
|
|
||||||
|
const preFinishState = rule.initialize(definition)
|
||||||
|
let preFinishResult = rule.reduce(definition, preFinishState, { type: 'session_started', at: 10 })
|
||||||
|
let runningState = preFinishResult.nextState
|
||||||
|
preFinishResult = rule.reduce(definition, runningState, {
|
||||||
|
type: 'gps_updated',
|
||||||
|
at: 11,
|
||||||
|
lon: 120.0,
|
||||||
|
lat: 30.0,
|
||||||
|
accuracyMeters: null,
|
||||||
|
})
|
||||||
|
runningState = preFinishResult.nextState
|
||||||
|
preFinishResult = rule.reduce(definition, runningState, {
|
||||||
|
type: 'punch_requested',
|
||||||
|
at: 12,
|
||||||
|
lon: 120.0,
|
||||||
|
lat: 30.0,
|
||||||
|
})
|
||||||
|
runningState = preFinishResult.nextState
|
||||||
|
preFinishResult = rule.reduce(definition, runningState, {
|
||||||
|
type: 'punch_requested',
|
||||||
|
at: 13,
|
||||||
|
lon: 120.0003,
|
||||||
|
lat: 30.0,
|
||||||
|
})
|
||||||
|
assert(preFinishResult.effects.some((effect) => effect.type === 'punch_feedback'), '未完成最低点数前打终点应被拦截')
|
||||||
|
|
||||||
|
result = rule.reduce(definition, state, {
|
||||||
|
type: 'gps_updated',
|
||||||
|
at: 6,
|
||||||
|
lon: 120.0003,
|
||||||
|
lat: 30.0,
|
||||||
|
accuracyMeters: null,
|
||||||
|
})
|
||||||
|
state = result.nextState
|
||||||
|
assert(result.presentation.hud.punchButtonText === '结束打卡', '终点进入范围后按钮文案应切为结束打卡')
|
||||||
|
result = rule.reduce(definition, state, {
|
||||||
|
type: 'punch_requested',
|
||||||
|
at: 7,
|
||||||
|
lon: 120.0003,
|
||||||
|
lat: 30.0,
|
||||||
|
})
|
||||||
|
state = result.nextState
|
||||||
|
assert(state.status === 'finished' && state.endReason === 'completed', '达到终点解锁条件后应可正常结束')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSettingsLockLifecycle(): void {
|
||||||
|
const storage: StorageMap = {
|
||||||
|
cmr_user_settings_v1: {
|
||||||
|
gpsMarkerStyle: 'dot',
|
||||||
|
trackDisplayMode: 'full',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
createWxStorage(storage)
|
||||||
|
|
||||||
|
const runtimeLocked = resolveSystemSettingsState(
|
||||||
|
{
|
||||||
|
values: {
|
||||||
|
gpsMarkerStyle: 'beacon',
|
||||||
|
},
|
||||||
|
locks: {
|
||||||
|
lockGpsMarkerStyle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'cmr_user_settings_v1',
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
assert(runtimeLocked.values.gpsMarkerStyle === 'beacon', '本局锁定时应以配置值为准')
|
||||||
|
assert(runtimeLocked.locks.lockGpsMarkerStyle, '本局内锁态应生效')
|
||||||
|
|
||||||
|
const runtimeReleased = resolveSystemSettingsState(
|
||||||
|
{
|
||||||
|
values: {
|
||||||
|
gpsMarkerStyle: 'beacon',
|
||||||
|
},
|
||||||
|
locks: {
|
||||||
|
lockGpsMarkerStyle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'cmr_user_settings_v1',
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assert(runtimeReleased.values.gpsMarkerStyle === 'dot', '脱离本局后应回落到玩家持久化设置')
|
||||||
|
assert(!runtimeReleased.locks.lockGpsMarkerStyle, '脱离本局后锁态应自动解除')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testTimeoutEndReason(): void {
|
||||||
|
const definition = buildGameDefinitionFromCourse(buildCourse(), 5, 'classic-sequential')
|
||||||
|
const rule = new ScoreORule()
|
||||||
|
const state = rule.initialize(definition)
|
||||||
|
const result = rule.reduce(definition, state, { type: 'session_timed_out', at: 99 })
|
||||||
|
assert(result.nextState.status === 'failed', '超时应进入 failed 状态')
|
||||||
|
assert(result.nextState.endReason === 'timed_out', '超时结束原因应为 timed_out')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testClassicSequentialSkipConfirmDefault(): void {
|
||||||
|
const defaults = getGameModeDefaults('classic-sequential')
|
||||||
|
assert(defaults.skipEnabled, '顺序打点默认应开启跳点')
|
||||||
|
assert(defaults.skipRequiresConfirm, '顺序打点默认跳点应弹出确认')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testRuntimeRestoreDefinition(): void {
|
||||||
|
const definition = buildGameDefinitionFromCourse(
|
||||||
|
buildCourse(),
|
||||||
|
5,
|
||||||
|
'score-o',
|
||||||
|
2 * 60 * 60 * 1000,
|
||||||
|
10 * 60 * 1000,
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
'enter-confirm',
|
||||||
|
5,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
const runtime = new GameRuntime()
|
||||||
|
runtime.loadDefinition(definition)
|
||||||
|
runtime.startSession(1)
|
||||||
|
runtime.dispatch({
|
||||||
|
type: 'gps_updated',
|
||||||
|
at: 2,
|
||||||
|
lon: 120.0,
|
||||||
|
lat: 30.0,
|
||||||
|
accuracyMeters: null,
|
||||||
|
})
|
||||||
|
runtime.dispatch({
|
||||||
|
type: 'punch_requested',
|
||||||
|
at: 3,
|
||||||
|
lon: 120.0,
|
||||||
|
lat: 30.0,
|
||||||
|
})
|
||||||
|
runtime.dispatch({
|
||||||
|
type: 'gps_updated',
|
||||||
|
at: 4,
|
||||||
|
lon: 120.0001,
|
||||||
|
lat: 30.0,
|
||||||
|
accuracyMeters: null,
|
||||||
|
})
|
||||||
|
runtime.dispatch({
|
||||||
|
type: 'punch_requested',
|
||||||
|
at: 5,
|
||||||
|
lon: 120.0001,
|
||||||
|
lat: 30.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const savedState = runtime.state
|
||||||
|
assert(!!savedState, '恢复测试前应存在对局状态')
|
||||||
|
|
||||||
|
const restoredRuntime = new GameRuntime()
|
||||||
|
const restoreResult = restoredRuntime.restoreDefinition(definition, savedState!)
|
||||||
|
assert(restoredRuntime.state !== null, '恢复后应保留对局状态')
|
||||||
|
assert(restoredRuntime.state!.completedControlIds.includes('control-1'), '恢复后应保留已完成检查点')
|
||||||
|
assert(restoredRuntime.state!.status === 'running', '恢复后对局应继续保持 running')
|
||||||
|
assert(restoreResult.presentation.hud.punchButtonText === runtime.presentation.hud.punchButtonText, '恢复后 HUD 关键按钮文案应可重建')
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(): void {
|
||||||
|
createWxStorage({})
|
||||||
|
testControlInheritance()
|
||||||
|
testScoreOFreePunchAndFinishGate()
|
||||||
|
testSettingsLockLifecycle()
|
||||||
|
testTimeoutEndReason()
|
||||||
|
testClassicSequentialSkipConfirmDefault()
|
||||||
|
testRuntimeRestoreDefinition()
|
||||||
|
console.log('runtime smoke tests passed')
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
19
tsconfig.runtime-smoke.json
Normal file
19
tsconfig.runtime-smoke.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "./.tmp-runtime-smoke",
|
||||||
|
"rootDir": ".",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"target": "ES2020"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./miniprogram/**/*.ts",
|
||||||
|
"./tools/runtime-smoke-test.ts",
|
||||||
|
"./typings/**/*.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
".tmp-runtime-smoke"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
typings/index.d.ts
vendored
1
typings/index.d.ts
vendored
@@ -3,6 +3,7 @@
|
|||||||
interface IAppOption {
|
interface IAppOption {
|
||||||
globalData: {
|
globalData: {
|
||||||
userInfo?: WechatMiniprogram.UserInfo,
|
userInfo?: WechatMiniprogram.UserInfo,
|
||||||
|
telemetryPlayerProfile?: import('../miniprogram/game/telemetry/playerTelemetryProfile').PlayerTelemetryProfile | null,
|
||||||
}
|
}
|
||||||
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback,
|
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback,
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user