feat: 收敛玩法运行时配置并加入故障恢复

This commit is contained in:
2026-04-01 13:04:26 +08:00
parent 1635a11780
commit 3ef841ecc7
73 changed files with 8820 additions and 2122 deletions

View 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. 至少一个玩法的配置样例
这样可以保证:
- 玩法设计有统一骨架
- 配置字段有统一归档
- 后台配置管理有明确输入目标
- 后续扩展不会只长代码、不长文档

View File

@@ -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)

View File

@@ -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)

View 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 类:高级实验项
如果无法明确归类,默认先归入高级实验项,不急着开放到后台常规表单。

View 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

View File

@@ -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)

View File

@@ -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 @@
- 服务端可对照 - 服务端可对照
- 后台可录入 - 后台可录入
- 客户端联调时有统一参考 - 客户端联调时有统一参考

View File

@@ -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)

View 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. 一句话结论
当前故障恢复机制的定位是:
**保证玩家在异常退出后可以继续当前对局,但不承担恢复所有临时界面状态。**

View 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` 管可运行样例,客户端解析配置后交给规则引擎执行,并由轻量恢复层处理异常退出后的续局。**

View 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 配置文档建议通过专门后台管理,但要注意分工:
- 玩法文档:
负责定义规则、默认值、配置落点、最小样例
- 配置字典:
负责定义字段含义、可选项和默认值
- 后台方案:
负责对象、版本、校验、装配、发布
也就是说:
**玩法文档是“设计源头”,后台系统是“管理和发布工具”。**
不要让后台方案反过来决定玩法规则结构。

View 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 结构和距离反馈定死,再决定哪些内容值得进入配置层。

View 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. 当前结论
后续推荐统一按这条链走:
`系统默认值 -> 玩法默认值 -> 活动配置 -> 玩家设置 -> 运行时编译层 -> 引擎 / 页面 / 规则`
这样配置越多,系统越不容易乱;后续后台做复杂了,也还是有一层中间结构兜住。

View 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 只提示“请选择目标点”

View 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)

View 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)

View 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)

View 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)

View 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`
其余流程默认按本文档执行。
也就是说,本文档定义的是:
- 最小模板下的系统默认局流程
- 后续配置化扩展前的默认产品行为
- 积分赛样例配置和实现验收时的基准口径

View 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 秒题卡
- 答题时比赛继续计时

View 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)

View 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)

View 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)

View 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)

View 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`
其余流程默认按本文档执行。
也就是说,本文档定义的是:
- 最小模板下的系统默认局流程
- 后续配置化扩展前的默认产品行为
- 顺序赛样例配置和实现验收时的基准口径

View File

@@ -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)。
- 正式阅读建议优先从本页和配置索引进入,不再直接平铺浏览全部文档。

View File

@@ -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
} }
} }

View File

@@ -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
} }
} }

View File

@@ -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

View File

@@ -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)

View File

@@ -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) }
} }

View File

@@ -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

View File

@@ -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') {

View File

@@ -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('模拟定位源已连接')

View File

@@ -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
} }

View File

@@ -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
} }

View File

@@ -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,
} }
} }

View File

@@ -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,15 +90,19 @@ 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
} }
this.startGuidanceLoop('guidance:ready') if (effect.guidanceState === 'ready') {
this.startGuidanceLoop('guidance:ready')
continue
}
this.stopGuidanceLoop()
continue continue
} }
@@ -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'
} }

View File

@@ -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(
template: 'story', applyDisplayContentOverride({
title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`, template: 'story',
body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}` : buildDisplayBody(label, control.sequence), title: score !== null ? `收集 ${label} (+${score})` : `收集 ${label}`,
autoPopup: true, body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}` : buildDisplayBody(label, control.sequence),
once: false, autoPopup: false,
priority: 1, once: false,
clickTitle: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`, priority: 1,
clickBody: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}` : buildDisplayBody(label, control.sequence), clickTitle: null,
ctas: [], clickBody: null,
contentExperience: null, ctas: [],
clickExperience: null, contentExperience: null,
}, controlContentOverrides[controlId]), clickExperience: null,
}, 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,
} }
} }

View File

@@ -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

View File

@@ -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 }

View 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
}

View File

@@ -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

View File

@@ -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: [],

View File

@@ -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[]

View 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,
},
}
}

View 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 {}
}

View 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),
},
)
}

View File

@@ -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,

View File

@@ -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),
} }

View File

@@ -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()
} }
} }

View File

@@ -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

View File

@@ -258,17 +258,19 @@ export class UiEffectDirector {
'success', 'success',
cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '', cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '',
) )
this.host.showContentCard( if (effect.controlKind !== 'finish' && effect.displayAutoPopup) {
effect.displayTitle, this.host.showContentCard(
effect.displayBody, effect.displayTitle,
cue ? this.getContentCardMotionClass(cue.contentCardMotion) : '', effect.displayBody,
{ cue ? this.getContentCardMotionClass(cue.contentCardMotion) : '',
contentKey: effect.controlId, {
autoPopup: effect.displayAutoPopup, contentKey: effect.controlId,
once: effect.displayOnce, autoPopup: effect.displayAutoPopup,
priority: effect.displayPriority, once: effect.displayOnce,
}, 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))
} }

View File

@@ -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' },
], ],
}, },
}, },

View File

@@ -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,

View File

@@ -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,
},
} }

View File

@@ -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 },
],
} }
} }

View File

@@ -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,

View File

@@ -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,41 +528,48 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
const hudPresentation: HudPresentationState = { const hudPresentation: HudPresentationState = {
actionTagText: modeState.phase === 'start' actionTagText: modeState.phase === 'start'
? '目标' ? '目标'
: modeState.phase === 'finish'
? '终点'
: focusedTarget && focusedTarget.kind === 'finish' : focusedTarget && focusedTarget.kind === 'finish'
? '终点' ? '终点'
: modeState.phase === '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'
? '开始打卡' ? '开始打卡'
: (punchableControl && punchableControl.kind === 'finish')
? '结束打卡'
: modeState.phase === 'finish'
? '结束打卡'
: focusedTarget && focusedTarget.kind === 'finish' : focusedTarget && focusedTarget.kind === 'finish'
? '结束打卡' ? '结束打卡'
: modeState.phase === 'finish' : '打点',
? '结束打卡'
: '打点',
punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget), punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget),
} }
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 {

View 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),
})
}

View File

@@ -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: '',

View File

@@ -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

View File

@@ -73,25 +73,26 @@
</view> </view>
</view> </view>
<view <view class="game-content-card-layer" wx:if="{{contentCardVisible}}" bindtap="handleDismissTransientContentCard">
class="game-content-card game-content-card--{{contentCardTemplate}} {{contentCardFxClass}}" <view
wx:if="{{contentCardVisible}}" class="game-content-card game-content-card--{{contentCardTemplate}} {{contentCardFxClass}}"
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}}"
wx:key="key" wx:key="key"
class="game-content-card__action" class="game-content-card__action"
data-type="{{item.type}}" data-type="{{item.type}}"
data-key="{{item.key}}" data-key="{{item.key}}"
catchtap="handleOpenContentCardAction" catchtap="handleOpenContentCardAction"
>{{item.label}}</view> >{{item.label}}</view>
</view>
<view class="game-content-card__close" catchtap="handleCloseContentCard">关闭</view>
</view> </view>
<view class="game-content-card__close" catchtap="handleCloseContentCard">关闭</view>
</view> </view>
</view> </view>
@@ -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-text">{{punchButtonText}}</view> <view class="race-panel__action-button {{punchButtonEnabled ? 'race-panel__action-button--active' : ''}}"><!-- status only -->
<view class="race-panel__action-button-text">{{punchButtonText}}</view>
</view>
<text class="race-panel__action-summary">{{panelTargetSummaryText}}</text>
</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__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>

View File

@@ -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;
} }

View 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)
}

View File

@@ -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 contentOverride = parseControlDisplayContentOverride(item, gameConfigUrl)
const rawPointLabelScale = getFirstDefined(item as Record<string, unknown>, ['pointLabelScale']) if (contentOverride) {
const rawPointLabelColor = getFirstDefined(item as Record<string, unknown>, ['pointLabelColorHex']) controlContentOverrides[key] = contentOverride
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 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']) continue
const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
if (rawStyle === undefined && rawColor === undefined && rawWidthScale === undefined && rawGlowStrength === undefined) {
continue
}
legStyleOverrides[index] = {
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),
}
} }
legStyleOverrides[index] = legOverride
}
} }
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,
: null, Number.isFinite(defaultControlScoreFromPlayfield)
? defaultControlScoreFromPlayfield
: rawScoring && rawScoring.defaultControlScore !== undefined
? parsePositiveNumber(rawScoring.defaultControlScore, modeDefaults.defaultControlScore)
: 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,
} }
} }

View File

@@ -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
View 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
View 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()

View 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
View File

@@ -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,
} }