diff --git a/GameConfigSample/classic-sequential.json b/GameConfigSample/classic-sequential.json new file mode 100644 index 0000000..5a059af --- /dev/null +++ b/GameConfigSample/classic-sequential.json @@ -0,0 +1,84 @@ +{ + "schemaVersion": "1", + "version": "2026.03.25", + "app": { + "id": "sample-classic-001", + "title": "顺序赛示例", + "locale": "zh-CN" + }, + "map": { + "tiles": "sample-map/tiles/", + "mapmeta": "sample-map/tiles/meta.json", + "declination": 6.91, + "initialView": { + "zoom": 17 + } + }, + "playfield": { + "kind": "course", + "source": { + "type": "kml", + "url": "sample-course/course.kml" + }, + "CPRadius": 6, + "metadata": { + "title": "顺序赛路线示例", + "code": "classic-001" + } + }, + "game": { + "mode": "classic-sequential", + "rulesVersion": "1", + "session": { + "startManually": true, + "requiresStartPunch": true, + "requiresFinishPunch": true, + "autoFinishOnLastControl": false, + "maxDurationSec": 5400 + }, + "punch": { + "policy": "enter-confirm", + "radiusMeters": 10 + }, + "sequence": { + "skip": { + "enabled": false, + "radiusMeters": 30, + "requiresConfirm": true + } + }, + "guidance": { + "showLegs": true, + "legAnimation": true, + "allowFocusSelection": false + }, + "visibility": { + "revealFullPlayfieldAfterStartPunch": true + }, + "finish": { + "finishControlAlwaysSelectable": false + }, + "telemetry": { + "heartRate": { + "age": 30, + "restingHeartRateBpm": 62, + "userWeightKg": 65 + } + }, + "feedback": { + "audioProfile": "default", + "hapticsProfile": "default", + "uiEffectsProfile": "default" + } + }, + "resources": { + "audioProfile": "default", + "contentProfile": "default", + "themeProfile": "default-race" + }, + "debug": { + "allowModeSwitch": false, + "allowMockInput": false, + "allowSimulator": false + } +} diff --git a/GameConfigSample/score-o.json b/GameConfigSample/score-o.json new file mode 100644 index 0000000..d2088ac --- /dev/null +++ b/GameConfigSample/score-o.json @@ -0,0 +1,108 @@ +{ + "schemaVersion": "1", + "version": "2026.03.25", + "app": { + "id": "sample-score-o-001", + "title": "积分赛示例", + "locale": "zh-CN" + }, + "map": { + "tiles": "sample-map/tiles/", + "mapmeta": "sample-map/tiles/meta.json", + "declination": 6.91, + "initialView": { + "zoom": 17 + } + }, + "playfield": { + "kind": "control-set", + "source": { + "type": "kml", + "url": "sample-course/course.kml" + }, + "CPRadius": 6, + "controlOverrides": { + "control-1": { + "score": 10 + }, + "control-2": { + "score": 20 + }, + "control-3": { + "score": 30 + }, + "control-4": { + "score": 40 + }, + "control-5": { + "score": 50 + }, + "control-6": { + "score": 60 + }, + "control-7": { + "score": 70 + }, + "control-8": { + "score": 80 + } + }, + "metadata": { + "title": "积分赛控制点示例(2 起终点 + 8 积分点)", + "code": "score-o-001" + } + }, + "game": { + "mode": "score-o", + "rulesVersion": "1", + "session": { + "startManually": true, + "requiresStartPunch": true, + "requiresFinishPunch": false, + "autoFinishOnLastControl": false, + "maxDurationSec": 5400 + }, + "punch": { + "policy": "enter-confirm", + "radiusMeters": 10, + "requiresFocusSelection": true + }, + "scoring": { + "type": "score", + "defaultControlScore": 10 + }, + "guidance": { + "showLegs": false, + "legAnimation": false, + "allowFocusSelection": true + }, + "visibility": { + "revealFullPlayfieldAfterStartPunch": true + }, + "finish": { + "finishControlAlwaysSelectable": true + }, + "telemetry": { + "heartRate": { + "age": 30, + "restingHeartRateBpm": 62, + "userWeightKg": 65 + } + }, + "feedback": { + "audioProfile": "default", + "hapticsProfile": "default", + "uiEffectsProfile": "default" + } + }, + "resources": { + "audioProfile": "default", + "contentProfile": "default", + "themeProfile": "default-race" + }, + "debug": { + "allowModeSwitch": false, + "allowMockInput": false, + "allowSimulator": false + } +} diff --git a/config-design-proposal.md b/config-design-proposal.md new file mode 100644 index 0000000..42101bb --- /dev/null +++ b/config-design-proposal.md @@ -0,0 +1,587 @@ +# 游戏配置文件设计方案(阶段讨论稿) + +本文档用于整理当前阶段推荐的配置文件设计方案,供后端、客户端和后台管理设计参考。 +目标是让配置真正成为游戏的驱动入口,同时兼顾后续多玩法、多资源、多活动复用。 + +--- + +## 1. 设计目标 + +配置文件系统需要解决以下问题: + +- 驱动地图、玩法、资源、调试开关 +- 支持顺序赛、积分赛以及后续更多玩法 +- 支持将来后台管理系统的内容编排 +- 保证地图空间信息与玩法语义分层 +- 保证当前阶段可平滑迁移,不推翻已有实现 + +当前推荐原则: + +- 配置只描述,不执行逻辑 +- 地图、空间对象、玩法规则、资源包分层 +- KML 负责空间底稿,不负责复杂玩法语义 +- 主配置先保持单文件,后续再升级为 manifest 组合 + +--- + +## 2. 顶层配置结构 + +当前推荐主入口配置结构如下: + +```json +{ + "schemaVersion": "1", + "version": "2026.03.25", + "app": {}, + "map": {}, + "playfield": {}, + "game": {}, + "resources": {}, + "debug": {} +} +``` + +各层职责如下: + +- `app` + 活动级或应用级基础信息 +- `map` + 地图底图和空间底座 +- `playfield` + 当前玩法使用的空间对象定义 +- `game` + 当前玩法规则配置 +- `resources` + 资源包与 profile +- `debug` + 调试与开发开关 + +--- + +## 3. 为什么不再以 course 作为总抽象 + +在定向语义里,`course` 是准确术语,表示路线。 +但从系统长期扩展看,`course` 并不是所有玩法的上位概念。 + +例如: + +- 顺序赛有明显的 `course` +- 积分赛更像一组控制点与分数 +- 金币赛更像可收集点集合 +- 幽灵赛可能包含危险区、隐身点、追逐者 +- 迷雾赛可能包含 reveal 点、扫描点、区域 + +因此推荐: + +- 将上位内容模型提升为 `playfield` +- `course` 只作为 `playfield.kind` 的一种 + +例如: + +```json +{ + "playfield": { + "kind": "course" + } +} +``` + +或: + +```json +{ + "playfield": { + "kind": "control-set" + } +} +``` + +--- + +## 4. KML 与配置的边界 + +当前推荐边界非常明确: + +### 4.1 KML 负责空间底稿 + +KML 适合描述: + +- 点坐标 +- 起点 / 检查点 / 终点 +- 顺序号 +- 点位名称 +- 腿线几何 + +### 4.2 配置负责玩法解释 + +配置负责描述: + +- 点位分值 +- 打点规则 +- 显隐规则 +- 动态积分 +- 道具能力 +- 迷雾规则 +- 占领规则 +- 特殊玩法语义 + +一句话总结: + +**KML 描述空间事实,配置描述玩法解释。** + +--- + +## 5. 推荐的字段结构 + +### 5.1 `app` + +用于活动级基础信息。 + +示例: + +```json +{ + "app": { + "id": "lxcb-001", + "title": "雪熊领秀城区定向赛", + "locale": "zh-CN" + } +} +``` + +### 5.2 `map` + +用于地图底图与空间底座。 + +示例: + +```json +{ + "map": { + "tiles": "lxcb-001/tiles/", + "mapmeta": "lxcb-001/tiles/meta.json", + "declination": 6.91, + "initialView": { + "zoom": 17 + } + } +} +``` + +### 5.3 `playfield` + +用于描述当前玩法使用的空间对象及其来源。 + +示例: + +```json +{ + "playfield": { + "kind": "course", + "source": { + "type": "kml", + "url": "lxcb-001/course/c01.kml" + }, + "CPRadius": 6, + "controlOverrides": {}, + "metadata": {} + } +} +``` + +建议后续逐步支持的对象包括: + +- `controls` +- `collectibles` +- `zones` +- `hazards` +- `links` +- `spawnPoints` + +### 5.4 `game` + +用于描述玩法规则。 + +推荐统一结构如下: + +```json +{ + "game": { + "mode": "", + "rulesVersion": "1", + "session": {}, + "punch": {}, + "scoring": {}, + "guidance": {}, + "visibility": {}, + "finish": {}, + "telemetry": {}, + "feedback": {} + } +} +``` + +#### `session` + +控制一局游戏的流程参数: + +- 是否手动开始 +- 是否必须打开始点 +- 是否必须打结束点 +- 是否允许自动结束 +- 最大时长 + +#### `punch` + +控制打点规则: + +- 打点策略 +- 打点半径 +- 是否必须选中后打卡 + +#### `scoring` + +控制积分与结算: + +- 完成型 +- 固定分 +- 动态分 + +#### `guidance` + +控制引导方式: + +- 是否显示腿线 +- 是否显示腿线动画 +- 是否允许 focus 选择 + +#### `visibility` + +控制显隐逻辑: + +- 是否开始后显示全图 +- 是否采用迷雾 + +#### `finish` + +控制结束规则: + +- 是否必须打终点 +- 是否允许随时结束 + +#### `telemetry` + +控制通用运动信息参数: + +- 年龄 +- 静息心率 +- 体重 + +#### `feedback` + +控制反馈 profile: + +- 音频 +- 震动 +- UI 动效 + +### 5.5 `resources` + +用于描述资源 profile。 + +示例: + +```json +{ + "resources": { + "audioProfile": "default", + "contentProfile": "default", + "themeProfile": "default-race" + } +} +``` + +当前阶段建议先保持轻量,后续再逐步拆成资源包 manifest。 + +### 5.6 `debug` + +用于开发和调试相关开关。 + +示例: + +```json +{ + "debug": { + "allowModeSwitch": false, + "allowMockInput": false, + "allowSimulator": false + } +} +``` + +--- + +## 6. 顺序赛示例配置 + +```json +{ + "schemaVersion": "1", + "version": "2026.03.25", + "app": { + "id": "lxcb-001", + "title": "雪熊领秀城区顺序赛" + }, + "map": { + "tiles": "lxcb-001/tiles/", + "mapmeta": "lxcb-001/tiles/meta.json", + "declination": 6.91 + }, + "playfield": { + "kind": "course", + "source": { + "type": "kml", + "url": "lxcb-001/course/c01.kml" + }, + "CPRadius": 6 + }, + "game": { + "mode": "classic-sequential", + "rulesVersion": "1", + "session": { + "requiresStartPunch": true, + "requiresFinishPunch": true, + "autoFinishOnLastControl": false, + "startManually": true + }, + "punch": { + "policy": "enter-confirm", + "radiusMeters": 10 + }, + "guidance": { + "showLegs": true, + "legAnimation": true, + "allowFocusSelection": false + }, + "visibility": { + "revealFullPlayfieldAfterStartPunch": true + }, + "telemetry": { + "heartRate": { + "age": 30, + "restingHeartRateBpm": 62, + "userWeightKg": 65 + } + }, + "feedback": { + "audioProfile": "default", + "hapticsProfile": "default", + "uiEffectsProfile": "default" + } + }, + "resources": { + "audioProfile": "default", + "contentProfile": "default" + }, + "debug": { + "allowModeSwitch": false, + "allowMockInput": false + } +} +``` + +--- + +## 7. 积分赛示例配置 + +```json +{ + "schemaVersion": "1", + "version": "2026.03.25", + "app": { + "id": "lxcb-001", + "title": "雪熊领秀城区积分赛" + }, + "map": { + "tiles": "lxcb-001/tiles/", + "mapmeta": "lxcb-001/tiles/meta.json", + "declination": 6.91 + }, + "playfield": { + "kind": "control-set", + "source": { + "type": "kml", + "url": "lxcb-001/course/c01.kml" + }, + "CPRadius": 6, + "controlOverrides": { + "control-1": { "score": 10 }, + "control-2": { "score": 20 }, + "control-3": { "score": 30 } + } + }, + "game": { + "mode": "score-o", + "rulesVersion": "1", + "session": { + "requiresStartPunch": true, + "requiresFinishPunch": false, + "startManually": true + }, + "punch": { + "policy": "enter-confirm", + "radiusMeters": 10, + "requiresFocusSelection": true + }, + "guidance": { + "showLegs": false, + "legAnimation": false, + "allowFocusSelection": true + }, + "scoring": { + "type": "score" + }, + "finish": { + "finishControlAlwaysSelectable": true + }, + "telemetry": { + "heartRate": { + "age": 30, + "restingHeartRateBpm": 62, + "userWeightKg": 65 + } + }, + "feedback": { + "audioProfile": "default", + "hapticsProfile": "default", + "uiEffectsProfile": "default" + } + }, + "resources": { + "audioProfile": "default", + "contentProfile": "default" + }, + "debug": { + "allowModeSwitch": false, + "allowMockInput": false + } +} +``` + +--- + +## 8. 当前老字段到新结构的迁移建议 + +### 地图层 + +- `map` -> `map.tiles` +- `mapmeta` -> `map.mapmeta` +- `declination` -> `map.declination` + +### 路线层 + +- `course` -> `playfield.source.url` +- `CPRadius` -> `playfield.CPRadius` + +### 玩法层 + +- `game.mode` -> `game.mode` +- `game.punchPolicy` -> `game.punch.policy` +- `PunchRadius` -> `game.punch.radiusMeters` +- `game.autoFinishOnLastControl` -> `game.session.autoFinishOnLastControl` + +### telemetry 层 + +- `game.telemetry.age` -> `game.telemetry.heartRate.age` +- `game.telemetry.restingHeartRateBpm` -> `game.telemetry.heartRate.restingHeartRateBpm` +- `game.telemetry.userWeightKg` -> `game.telemetry.heartRate.userWeightKg` + +### feedback 层 + +- `game.audio` -> `game.feedback.audio` 或 `resources.audioProfiles` +- `game.haptics` -> `game.feedback.haptics` 或 `resources.hapticsProfiles` +- `game.uiEffects` -> `game.feedback.uiEffects` 或 `resources.uiEffectsProfiles` + +当前建议迁移策略: + +- 第一阶段:代码同时兼容老字段和新结构 +- 第二阶段:线上配置逐步切换 +- 第三阶段:再清理旧字段兼容逻辑 + +--- + +## 9. 未来推荐的 manifest 方向 + +当前阶段主配置建议先保持单文件。 +但未来配置规模变大时,推荐升级成多 manifest 组合: + +```json +{ + "schemaVersion": "1", + "version": "2026.03.25", + "map": { + "manifest": "maps/lxcb-001/map.json" + }, + "playfield": { + "manifest": "playfields/lxcb-001/c01.json" + }, + "game": { + "manifest": "modes/score-o/default.json" + }, + "resources": { + "manifest": "packs/spring-2026/resources.json" + }, + "debug": {} +} +``` + +这样可以支持: + +- 一张地图挂多种玩法 +- 一条 playfield 挂多种规则 +- 一种玩法切换不同资源包 +- 后台管理做拼装式发布 + +--- + +## 10. 服务端和后台管理的推荐核心对象 + +后续从服务端和后台管理的复用角度,建议围绕以下核心对象建模: + +- `Map` +- `Playfield` +- `GameMode` +- `ResourcePack` +- `Event` + +其中: + +- `Map` + 地图底图与空间底座 +- `Playfield` + 当前玩法场景中的空间对象定义 +- `GameMode` + 玩法规则模板 +- `ResourcePack` + 资源包与 profile +- `Event` + 一次实际发布的活动实例 + +推荐关系可以理解为: + +`Event = Map + Playfield + GameMode + ResourcePack + 发布参数` + +--- + +## 11. 当前阶段推荐结论 + +当前阶段最推荐的方案是: + +- 先保留单个 `game.json` +- 结构升级为 `app / map / playfield / game / resources / debug` +- 保留 KML 作为空间底稿来源 +- 不再让 `course` 成为总抽象,而是提升为更通用的 `playfield` +- 让代码先双兼容,再逐步迁移线上配置 + +一句话总结: + +**KML 描述空间事实,配置描述玩法解释;主配置按 `map / playfield / game / resources / debug` 分层,后续再升级成 manifest 组合。** diff --git a/config-template-classic-sequential.md b/config-template-classic-sequential.md new file mode 100644 index 0000000..bc3a558 --- /dev/null +++ b/config-template-classic-sequential.md @@ -0,0 +1,313 @@ +# 顺序赛配置文档(基础版) + +本文档用于给服务端和后台配置设计提供一份可直接落地的顺序赛基础模板。 +目标是先把入口配置结构定稳,后续程序功能再逐步细化。 + +--- + +## 1. 适用玩法 + +适用于最基础的顺序打点玩法: + +- 手动开始 +- 先打开始点 +- 按顺序打检查点 +- 最后打终点 +- 支持正常打点半径 +- 预留后续扩展规则空间 + +--- + +## 2. 顶层结构 + +推荐主配置结构如下: + +```json +{ + "schemaVersion": "1", + "version": "2026.03.25", + "app": {}, + "map": {}, + "playfield": {}, + "game": {}, + "resources": {}, + "debug": {} +} +``` + +--- + +## 3. 完整示例 + +```json +{ + "schemaVersion": "1", + "version": "2026.03.25", + "app": { + "id": "lxcb-001", + "title": "雪熊领秀城区顺序赛", + "locale": "zh-CN" + }, + "map": { + "tiles": "lxcb-001/tiles/", + "mapmeta": "lxcb-001/tiles/meta.json", + "declination": 6.91, + "initialView": { + "zoom": 17 + } + }, + "playfield": { + "kind": "course", + "source": { + "type": "kml", + "url": "lxcb-001/course/c01.kml" + }, + "CPRadius": 6, + "metadata": { + "title": "校园经典路线", + "code": "c01" + } + }, + "game": { + "mode": "classic-sequential", + "rulesVersion": "1", + "session": { + "startManually": true, + "requiresStartPunch": true, + "requiresFinishPunch": true, + "autoFinishOnLastControl": false, + "maxDurationSec": 5400 + }, + "punch": { + "policy": "enter-confirm", + "radiusMeters": 10 + }, + "sequence": { + "skip": { + "enabled": false, + "radiusMeters": 30, + "requiresConfirm": true + } + }, + "guidance": { + "showLegs": true, + "legAnimation": true, + "allowFocusSelection": false + }, + "visibility": { + "revealFullPlayfieldAfterStartPunch": true + }, + "finish": { + "finishControlAlwaysSelectable": false + }, + "telemetry": { + "heartRate": { + "age": 30, + "restingHeartRateBpm": 62, + "userWeightKg": 65 + } + }, + "feedback": { + "audioProfile": "default", + "hapticsProfile": "default", + "uiEffectsProfile": "default" + } + }, + "resources": { + "audioProfile": "default", + "contentProfile": "default", + "themeProfile": "default-race" + }, + "debug": { + "allowModeSwitch": false, + "allowMockInput": false, + "allowSimulator": false + } +} +``` + +--- + +## 4. 字段说明 + +### `app` + +- `id` + 活动或配置实例 id +- `title` + 活动标题 +- `locale` + 语言环境 + +### `map` + +- `tiles` + 瓦片根路径 +- `mapmeta` + 地图 meta 地址 +- `declination` + 磁偏角 +- `initialView.zoom` + 初始缩放级别 + +### `playfield` + +- `kind` + 当前为 `course` +- `source.type` + 当前推荐为 `kml` +- `source.url` + KML 地址 +- `CPRadius` + 检查点绘制半径,单位米 +- `metadata` + 路线元数据 + +### `game.session` + +- `startManually` + 是否需要先点击开始按钮 +- `requiresStartPunch` + 是否必须先打开始点 +- `requiresFinishPunch` + 是否必须打终点 +- `autoFinishOnLastControl` + 是否打完最后一个检查点自动结束 +- `maxDurationSec` + 最大比赛时长 + +### `game.punch` + +- `policy` + 当前推荐 `enter-confirm` +- `radiusMeters` + 正常打点半径 + +### `game.sequence` + +- `skip` + 顺序赛跳点规则 +- `enabled` + 是否允许跳点 +- `radiusMeters` + 跳点半径,必须大于打点半径 +- `requiresConfirm` + 是否必须用户确认后跳点 + +当前基础版建议先关闭: + +```json +"enabled": false +``` + +### `game.guidance` + +- `showLegs` + 是否显示腿线 +- `legAnimation` + 是否显示当前腿动画 +- `allowFocusSelection` + 顺序赛一般为 `false` + +### `game.visibility` + +- `revealFullPlayfieldAfterStartPunch` + 开始点打卡后是否显示完整路线 + +### `game.finish` + +- `finishControlAlwaysSelectable` + 顺序赛一般为 `false` + +### `game.telemetry` + +通用体能参数。 + +### `game.feedback` + +反馈 profile 绑定。 + +### `resources` + +资源 profile 绑定。 + +### `debug` + +调试相关开关。 + +--- + +## 5. 当前阶段推荐必填字段 + +顺序赛当前阶段建议至少保证以下字段存在: + +- `map.tiles` +- `map.mapmeta` +- `map.declination` +- `playfield.kind` +- `playfield.source.type` +- `playfield.source.url` +- `playfield.CPRadius` +- `game.mode` +- `game.punch.policy` +- `game.punch.radiusMeters` +- `game.session.requiresStartPunch` +- `game.session.requiresFinishPunch` + +--- + +## 6. 当前阶段建议默认值 + +如果服务端还没有全部配置细项,建议先采用以下默认值: + +```json +{ + "game": { + "session": { + "startManually": true, + "requiresStartPunch": true, + "requiresFinishPunch": true, + "autoFinishOnLastControl": false + }, + "punch": { + "policy": "enter-confirm", + "radiusMeters": 10 + }, + "sequence": { + "skip": { + "enabled": false, + "radiusMeters": 30, + "requiresConfirm": true + } + }, + "guidance": { + "showLegs": true, + "legAnimation": true, + "allowFocusSelection": false + } + } +} +``` + +--- + +## 7. 适合当前客户端的迁移原则 + +当前客户端迁移时,建议服务端先完成: + +1. 将老字段逐步迁入 `map / playfield / game / resources / debug` +2. 保持基础字段完整 +3. 不急着一次性把所有高级规则上线 + +优先把“入口结构”夯实,再逐步扩玩法参数。 + +--- + +## 8. 一句话结论 + +顺序赛配置当前阶段建议: + +- 用 `playfield.kind = course` +- 用 KML 承载空间底稿 +- 用 `game.session / game.punch / game.sequence / game.guidance` 承载玩法规则 +- 先把基础入口结构定稳,后续再细化跳点、惩罚、特殊引导等高级规则 diff --git a/config-template-score-o.md b/config-template-score-o.md new file mode 100644 index 0000000..436ce88 --- /dev/null +++ b/config-template-score-o.md @@ -0,0 +1,355 @@ +# 积分赛配置文档(基础版) + +本文档用于给服务端和后台配置设计提供一份可直接落地的积分赛基础模板。 +目标是先把积分赛入口结构定稳,后续程序功能再逐步细化。 + +--- + +## 1. 适用玩法 + +适用于最基础的积分赛玩法: + +- 手动开始 +- 先打开始点 +- 多个检查点自由收集 +- 控制点有固定分值 +- 可选终点 +- 支持选中目标点后打卡 + +--- + +## 2. 顶层结构 + +推荐主配置结构如下: + +```json +{ + "schemaVersion": "1", + "version": "2026.03.25", + "app": {}, + "map": {}, + "playfield": {}, + "game": {}, + "resources": {}, + "debug": {} +} +``` + +--- + +## 3. 完整示例 + +```json +{ + "schemaVersion": "1", + "version": "2026.03.25", + "app": { + "id": "lxcb-001", + "title": "雪熊领秀城区积分赛", + "locale": "zh-CN" + }, + "map": { + "tiles": "lxcb-001/tiles/", + "mapmeta": "lxcb-001/tiles/meta.json", + "declination": 6.91, + "initialView": { + "zoom": 17 + } + }, + "playfield": { + "kind": "control-set", + "source": { + "type": "kml", + "url": "lxcb-001/course/c01.kml" + }, + "CPRadius": 6, + "controlOverrides": { + "control-1": { + "score": 10 + }, + "control-2": { + "score": 20 + }, + "control-3": { + "score": 30 + }, + "control-4": { + "score": 40 + } + }, + "metadata": { + "title": "校园积分赛控制点集", + "code": "score-o-c01" + } + }, + "game": { + "mode": "score-o", + "rulesVersion": "1", + "session": { + "startManually": true, + "requiresStartPunch": true, + "requiresFinishPunch": false, + "autoFinishOnLastControl": false, + "maxDurationSec": 5400 + }, + "punch": { + "policy": "enter-confirm", + "radiusMeters": 10, + "requiresFocusSelection": true + }, + "scoring": { + "type": "score", + "defaultControlScore": 10 + }, + "guidance": { + "showLegs": false, + "legAnimation": false, + "allowFocusSelection": true + }, + "visibility": { + "revealFullPlayfieldAfterStartPunch": true + }, + "finish": { + "finishControlAlwaysSelectable": true + }, + "telemetry": { + "heartRate": { + "age": 30, + "restingHeartRateBpm": 62, + "userWeightKg": 65 + } + }, + "feedback": { + "audioProfile": "default", + "hapticsProfile": "default", + "uiEffectsProfile": "default" + } + }, + "resources": { + "audioProfile": "default", + "contentProfile": "default", + "themeProfile": "default-race" + }, + "debug": { + "allowModeSwitch": false, + "allowMockInput": false, + "allowSimulator": false + } +} +``` + +--- + +## 4. 字段说明 + +### `app` + +- `id` + 活动或配置实例 id +- `title` + 活动标题 +- `locale` + 语言环境 + +### `map` + +- `tiles` + 瓦片根路径 +- `mapmeta` + 地图 meta 地址 +- `declination` + 磁偏角 +- `initialView.zoom` + 初始缩放级别 + +### `playfield` + +- `kind` + 当前推荐 `control-set` +- `source.type` + 当前推荐 `kml` +- `source.url` + KML 地址 +- `CPRadius` + 检查点绘制半径 +- `controlOverrides` + 每个控制点的积分和后续扩展元数据 + +### `playfield.controlOverrides` + +当前阶段最推荐至少放: + +- `score` + +示例: + +```json +"control-1": { + "score": 10 +} +``` + +这样可以保证: + +- KML 只提供点位与空间结构 +- 分值由配置控制 + +### `game.session` + +- `startManually` + 是否手动开始 +- `requiresStartPunch` + 是否必须先打开始点 +- `requiresFinishPunch` + 是否必须打终点 +- `autoFinishOnLastControl` + 积分赛通常为 `false` +- `maxDurationSec` + 最大时长 + +### `game.punch` + +- `policy` + 当前推荐 `enter-confirm` +- `radiusMeters` + 打点半径 +- `requiresFocusSelection` + 是否必须先选中目标点后才能打卡 + +### `game.scoring` + +- `type` + 当前推荐 `score` +- `defaultControlScore` + 如果某个点没单独配置分数时的默认值 + +### `game.guidance` + +- `showLegs` + 积分赛基础版建议 `false` +- `legAnimation` + 积分赛基础版建议 `false` +- `allowFocusSelection` + 建议 `true` + +### `game.visibility` + +- `revealFullPlayfieldAfterStartPunch` + 开始点打卡后是否显示完整控制点集合 + +### `game.finish` + +- `finishControlAlwaysSelectable` + 积分赛建议支持随时选终点结束时,设为 `true` + +### `game.telemetry` + +通用体能参数。 + +### `game.feedback` + +反馈 profile 绑定。 + +### `resources` + +资源 profile 绑定。 + +### `debug` + +调试相关开关。 + +--- + +## 5. 当前阶段推荐必填字段 + +积分赛当前阶段建议至少保证以下字段存在: + +- `map.tiles` +- `map.mapmeta` +- `map.declination` +- `playfield.kind` +- `playfield.source.type` +- `playfield.source.url` +- `playfield.CPRadius` +- `game.mode` +- `game.punch.policy` +- `game.punch.radiusMeters` +- `game.punch.requiresFocusSelection` +- `game.scoring.type` + +--- + +## 6. 当前阶段建议默认值 + +如果服务端还没有全部细项,建议先采用以下默认值: + +```json +{ + "game": { + "session": { + "startManually": true, + "requiresStartPunch": true, + "requiresFinishPunch": false, + "autoFinishOnLastControl": false + }, + "punch": { + "policy": "enter-confirm", + "radiusMeters": 10, + "requiresFocusSelection": true + }, + "scoring": { + "type": "score", + "defaultControlScore": 10 + }, + "guidance": { + "showLegs": false, + "legAnimation": false, + "allowFocusSelection": true + }, + "finish": { + "finishControlAlwaysSelectable": true + } + } +} +``` + +--- + +## 7. 积分赛当前阶段推荐的设计边界 + +当前阶段建议坚持以下边界: + +- KML 提供点位和几何底稿 +- 配置提供分值和玩法解释 +- 不把积分逻辑写进 KML +- 不把自由收集逻辑写成固定路线逻辑 + +也就是说: + +- `playfield.kind = control-set` +- `controlOverrides.score` 负责分值 +- `game.guidance.allowFocusSelection = true` 负责选中目标逻辑 + +--- + +## 8. 适合当前客户端的迁移原则 + +当前客户端迁移时,建议服务端先完成: + +1. 将积分赛点位分值迁入 `playfield.controlOverrides` +2. 将玩法规则迁入 `game.session / game.punch / game.scoring / game.guidance / game.finish` +3. 不急着一次性接入动态积分和复杂规则 + +先把静态积分赛入口结构定稳,再逐步扩展动态积分和高级玩法能力。 + +--- + +## 9. 一句话结论 + +积分赛配置当前阶段建议: + +- 用 `playfield.kind = control-set` +- 用 KML 承载控制点空间底稿 +- 用 `playfield.controlOverrides` 承载点位分值 +- 用 `game.scoring / game.punch / game.guidance / game.finish` 承载玩法规则 +- 先把静态积分赛入口结构定稳,后续再扩动态积分与更复杂玩法 diff --git a/miniprogram/assets/btn_exit.png b/miniprogram/assets/btn_exit.png new file mode 100644 index 0000000..6c15567 Binary files /dev/null and b/miniprogram/assets/btn_exit.png differ diff --git a/miniprogram/assets/btn_info.png b/miniprogram/assets/btn_info.png new file mode 100644 index 0000000..d519124 Binary files /dev/null and b/miniprogram/assets/btn_info.png differ diff --git a/miniprogram/assets/btn_skip_cp.png b/miniprogram/assets/btn_skip_cp.png new file mode 100644 index 0000000..6af0213 Binary files /dev/null and b/miniprogram/assets/btn_skip_cp.png differ diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index 5d3b86e..6ac0af3 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/miniprogram/engine/map/mapEngine.ts @@ -9,7 +9,7 @@ import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibra import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig' import { GameRuntime } from '../../game/core/gameRuntime' -import { type GameEffect } from '../../game/core/gameResult' +import { type GameEffect, type GameResult } from '../../game/core/gameResult' import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition' import { FeedbackDirector } from '../../game/feedback/feedbackDirector' import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState' @@ -170,6 +170,7 @@ export interface MapEngineViewState { panelAccuracyUnitText: string punchButtonText: string punchButtonEnabled: boolean + skipButtonEnabled: boolean punchHintText: string punchFeedbackVisible: boolean punchFeedbackText: string @@ -194,6 +195,18 @@ export interface MapEngineCallbacks { onData: (patch: Partial) => void } +export interface MapEngineGameInfoRow { + label: string + value: string +} + +export interface MapEngineGameInfoSnapshot { + title: string + subtitle: string + localRows: MapEngineGameInfoRow[] + globalRows: MapEngineGameInfoRow[] +} + const VIEW_SYNC_KEYS: Array = [ 'buildVersion', 'renderMode', @@ -273,6 +286,7 @@ const VIEW_SYNC_KEYS: Array = [ 'panelAccuracyUnitText', 'punchButtonText', 'punchButtonEnabled', + 'skipButtonEnabled', 'punchHintText', 'punchFeedbackVisible', 'punchFeedbackText', @@ -338,6 +352,19 @@ function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: numb return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor) } +function formatGameSessionStatusText(status: 'idle' | 'running' | 'finished' | 'failed'): string { + if (status === 'running') { + return '进行中' + } + if (status === 'finished') { + return '已结束' + } + if (status === 'failed') { + return '已失败' + } + return '未开始' +} + function formatRotationText(rotationDeg: number): string { return `${Math.round(normalizeRotationDeg(rotationDeg))}deg` } @@ -577,12 +604,21 @@ export class MapEngine { courseData: OrienteeringCourseData | null courseOverlayVisible: boolean cpRadiusMeters: number + configAppId: string + configSchemaVersion: string + configVersion: string + controlScoreOverrides: Record + defaultControlScore: number | null gameRuntime: GameRuntime telemetryRuntime: TelemetryRuntime gamePresentation: GamePresentationState gameMode: 'classic-sequential' | 'score-o' punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number + requiresFocusSelection: boolean + skipEnabled: boolean + skipRadiusMeters: number + skipRequiresConfirm: boolean autoFinishOnLastControl: boolean punchFeedbackTimer: number contentCardTimer: number @@ -734,6 +770,11 @@ export class MapEngine { this.courseData = null this.courseOverlayVisible = false this.cpRadiusMeters = 5 + this.configAppId = '' + this.configSchemaVersion = '1' + this.configVersion = '' + this.controlScoreOverrides = {} + this.defaultControlScore = null this.gameRuntime = new GameRuntime() this.telemetryRuntime = new TelemetryRuntime() this.telemetryRuntime.configure() @@ -741,6 +782,10 @@ export class MapEngine { this.gameMode = 'classic-sequential' this.punchPolicy = 'enter-confirm' this.punchRadiusMeters = 5 + this.requiresFocusSelection = false + this.skipEnabled = false + this.skipRadiusMeters = 30 + this.skipRequiresConfirm = true this.autoFinishOnLastControl = true this.punchFeedbackTimer = 0 this.contentCardTimer = 0 @@ -754,7 +799,7 @@ export class MapEngine { projectionMode: PROJECTION_MODE, mapReady: false, mapReadyText: 'BOOTING', - mapName: 'LCX 测试地图', + mapName: '未命名配置', configStatusText: '远程配置待加载', zoom: DEFAULT_ZOOM, rotationDeg: 0, @@ -836,6 +881,7 @@ export class MapEngine { gameSessionStatus: 'idle', gameModeText: '顺序赛', punchButtonEnabled: false, + skipButtonEnabled: false, punchHintText: '等待进入检查点范围', punchFeedbackVisible: false, punchFeedbackText: '', @@ -895,6 +941,68 @@ export class MapEngine { return { ...this.state } } + getGameInfoSnapshot(): MapEngineGameInfoSnapshot { + const definition = this.gameRuntime.definition + const sessionState = this.gameRuntime.state + const telemetryState = this.telemetryRuntime.state + const telemetryPresentation = this.telemetryRuntime.getPresentation() + const currentTarget = definition && sessionState + ? definition.controls.find((control) => control.id === sessionState.currentTargetControlId) || null + : null + const currentTargetText = currentTarget + ? `${currentTarget.label} / ${currentTarget.kind === 'start' + ? '开始点' + : currentTarget.kind === 'finish' + ? '结束点' + : '检查点'}` + : '--' + const title = this.state.mapName || (definition ? definition.title : '当前游戏') + const subtitle = `${this.getGameModeText()} / ${formatGameSessionStatusText(this.state.gameSessionStatus)}` + const localRows: MapEngineGameInfoRow[] = [ + { label: '比赛名称', value: title || '--' }, + { label: '配置版本', value: this.configVersion || '--' }, + { label: 'Schema版本', value: this.configSchemaVersion || '--' }, + { label: '活动ID', value: this.configAppId || '--' }, + { label: '地图', value: this.state.mapName || '--' }, + { label: '模式', value: this.getGameModeText() }, + { label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) }, + { label: '当前目标', value: currentTargetText }, + { label: '进度', value: this.gamePresentation.hud.progressText || '--' }, + { label: '当前积分', value: sessionState ? String(sessionState.score) : '0' }, + { label: '已完成点', value: sessionState ? String(sessionState.completedControlIds.length) : '0' }, + { label: '已跳过点', value: sessionState ? String(sessionState.skippedControlIds.length) : '0' }, + { label: '打点规则', value: `${this.punchPolicy} / ${this.punchRadiusMeters}m` }, + { label: '跳点规则', value: this.skipEnabled ? `${this.skipRadiusMeters}m / ${this.skipRequiresConfirm ? '确认跳过' : '直接跳过'}` : '关闭' }, + { label: '定位源', value: this.state.locationSourceText || '--' }, + { label: '当前位置', value: this.state.gpsCoordText || '--' }, + { label: 'GPS精度', value: telemetryState.lastGpsAccuracyMeters == null ? '--' : `${telemetryState.lastGpsAccuracyMeters.toFixed(1)}m` }, + { label: '目标距离', value: `${telemetryPresentation.distanceToTargetValueText}${telemetryPresentation.distanceToTargetUnitText}` || '--' }, + { label: '当前速度', value: `${telemetryPresentation.speedText} km/h` }, + { label: '心率源', value: this.state.heartRateSourceText || '--' }, + { label: '当前心率', value: this.state.panelHeartRateValueText === '--' ? '--' : `${this.state.panelHeartRateValueText}${this.state.panelHeartRateUnitText}` }, + { label: '心率设备', value: this.state.heartRateDeviceText || '--' }, + { label: '心率分区', value: this.state.panelHeartRateZoneNameText === '--' ? '--' : `${this.state.panelHeartRateZoneNameText} ${this.state.panelHeartRateZoneRangeText}` }, + { label: '本局用时', value: telemetryPresentation.timerText }, + { label: '累计里程', value: telemetryPresentation.mileageText }, + { label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` }, + { label: '提示状态', value: this.state.punchHintText || '--' }, + ] + const globalRows: MapEngineGameInfoRow[] = [ + { label: '全球积分', value: '未接入' }, + { label: '全球排名', value: '未接入' }, + { label: '在线人数', value: '未接入' }, + { label: '队伍状态', value: '未接入' }, + { label: '实时广播', value: '未接入' }, + ] + + return { + title, + subtitle, + localRows, + globalRows, + } + } + destroy(): void { this.clearInertiaTimer() this.clearPreviewResetTimer() @@ -948,6 +1056,12 @@ export class MapEngine { this.setCourseHeading(null) } + clearStartSessionResidue(): void { + this.currentGpsTrack = [] + this.courseOverlayVisible = false + this.setCourseHeading(null) + } + handleClearMapTestArtifacts(): void { this.clearFinishedTestOverlay() this.setState({ @@ -963,6 +1077,29 @@ export class MapEngine { return this.gamePresentation.hud.hudTargetControlId } + isSkipAvailable(): boolean { + const definition = this.gameRuntime.definition + const state = this.gameRuntime.state + if (!definition || !state || state.status !== 'running' || !definition.skipEnabled) { + return false + } + + const currentTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null + if (!currentTarget || currentTarget.kind !== 'control' || !this.currentGpsPoint) { + return false + } + + const avgLatRad = ((currentTarget.point.lat + this.currentGpsPoint.lat) / 2) * Math.PI / 180 + const dx = (this.currentGpsPoint.lon - currentTarget.point.lon) * 111320 * Math.cos(avgLatRad) + const dy = (this.currentGpsPoint.lat - currentTarget.point.lat) * 110540 + const distanceMeters = Math.sqrt(dx * dx + dy * dy) + return distanceMeters <= definition.skipRadiusMeters + } + + shouldConfirmSkipAction(): boolean { + return !!(this.gameRuntime.definition && this.gameRuntime.definition.skipRequiresConfirm) + } + getLocationControllerViewPatch(): Partial { const debugState = this.locationController.getDebugState() return { @@ -993,10 +1130,10 @@ export class MapEngine { return this.gameMode === 'score-o' ? '积分赛' : '顺序赛' } - loadGameDefinitionFromCourse(): GameEffect[] { + loadGameDefinitionFromCourse(): GameResult | null { if (!this.courseData) { this.clearGameRuntime() - return [] + return null } const definition = buildGameDefinitionFromCourse( @@ -1006,18 +1143,20 @@ export class MapEngine { this.autoFinishOnLastControl, this.punchPolicy, this.punchRadiusMeters, + this.requiresFocusSelection, + this.skipEnabled, + this.skipRadiusMeters, + this.skipRequiresConfirm, + this.controlScoreOverrides, + this.defaultControlScore, ) const result = this.gameRuntime.loadDefinition(definition) this.telemetryRuntime.loadDefinition(definition) - this.gamePresentation = result.presentation this.courseOverlayVisible = true - this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, this.getHudTargetControlId()) - this.refreshCourseHeadingFromPresentation() + this.syncGameResultState(result) + this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, result.presentation.hud.hudTargetControlId) this.updateSessionTimerLoop() - this.setState({ - gameModeText: this.getGameModeText(), - }) - return result.effects + return result } refreshCourseHeadingFromPresentation(): void { @@ -1083,6 +1222,7 @@ export class MapEngine { panelProgressText: this.gamePresentation.hud.progressText, punchButtonText: this.gamePresentation.hud.punchButtonText, punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled, + skipButtonEnabled: this.isSkipAvailable(), punchHintText: this.gamePresentation.hud.punchHintText, } @@ -1121,6 +1261,28 @@ export class MapEngine { } } + resetTransientGameUiState(): void { + this.clearPunchFeedbackTimer() + this.clearContentCardTimer() + this.clearMapPulseTimer() + this.clearStageFxTimer() + this.setState({ + punchFeedbackVisible: false, + punchFeedbackText: '', + punchFeedbackTone: 'neutral', + punchFeedbackFxClass: '', + contentCardVisible: false, + contentCardTitle: '', + contentCardBody: '', + contentCardFxClass: '', + mapPulseVisible: false, + mapPulseFxClass: '', + stageFxVisible: false, + stageFxClass: '', + punchButtonFxClass: '', + }, true) + } + clearSessionTimerInterval(): void { if (this.sessionTimerInterval) { clearInterval(this.sessionTimerInterval) @@ -1300,6 +1462,33 @@ export class MapEngine { return this.resolveGameStatusText(effects) } + syncGameResultState(result: GameResult): void { + this.gamePresentation = result.presentation + this.refreshCourseHeadingFromPresentation() + } + + resolveAppliedGameStatusText(result: GameResult, fallbackStatusText?: string | null): string | null { + return this.applyGameEffects(result.effects) || fallbackStatusText || this.resolveGameStatusText(result.effects) + } + + commitGameResult( + result: GameResult, + fallbackStatusText?: string | null, + extraPatch: Partial = {}, + syncRenderer = true, + ): string | null { + this.syncGameResultState(result) + const gameStatusText = this.resolveAppliedGameStatusText(result, fallbackStatusText) + this.setState({ + ...this.getGameViewPatch(gameStatusText), + ...extraPatch, + }, true) + if (syncRenderer) { + this.syncRenderer() + } + return gameStatusText + } + handleStartGame(): void { if (!this.gameRuntime.definition || !this.gameRuntime.state) { this.setState({ @@ -1312,6 +1501,10 @@ export class MapEngine { return } + this.feedbackDirector.reset() + this.resetTransientGameUiState() + this.clearStartSessionResidue() + if (!this.locationController.listening) { this.locationController.start() } @@ -1328,15 +1521,30 @@ export class MapEngine { }) } - this.gamePresentation = this.gameRuntime.getPresentation() this.courseOverlayVisible = true - this.refreshCourseHeadingFromPresentation() const defaultStatusText = this.currentGpsPoint ? `顺序打点已开始 (${this.buildVersion})` : `顺序打点已开始,GPS定位启动中 (${this.buildVersion})` - const gameStatusText = this.applyGameEffects(gameResult.effects) || defaultStatusText + this.commitGameResult(gameResult, defaultStatusText) + } + + handleForceExitGame(): void { + this.feedbackDirector.reset() + + if (!this.courseData) { + this.clearGameRuntime() + this.resetTransientGameUiState() + this.setState({ + ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`), + }, true) + this.syncRenderer() + return + } + + this.loadGameDefinitionFromCourse() + this.resetTransientGameUiState() this.setState({ - ...this.getGameViewPatch(gameStatusText), + ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`), }, true) this.syncRenderer() } @@ -1347,13 +1555,7 @@ export class MapEngine { type: 'punch_requested', at: Date.now(), }) - this.gamePresentation = gameResult.presentation - this.refreshCourseHeadingFromPresentation() - const gameStatusText = this.applyGameEffects(gameResult.effects) - this.setState({ - ...this.getGameViewPatch(gameStatusText), - }, true) - this.syncRenderer() + this.commitGameResult(gameResult) } handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void { @@ -1388,9 +1590,8 @@ export class MapEngine { lat: latitude, accuracyMeters, }) - this.gamePresentation = gameResult.presentation - this.refreshCourseHeadingFromPresentation() - gameStatusText = this.applyGameEffects(gameResult.effects) + this.syncGameResultState(gameResult) + gameStatusText = this.resolveAppliedGameStatusText(gameResult) } if (gpsInsideMap && !this.hasGpsCenteredOnce) { @@ -1462,14 +1663,24 @@ export class MapEngine { } this.gameMode = nextMode - const effects = this.loadGameDefinitionFromCourse() + const result = this.loadGameDefinitionFromCourse() const modeText = this.getGameModeText() - const statusText = this.applyGameEffects(effects) || `已切换到${modeText} (${this.buildVersion})` - this.setState({ - ...this.getGameViewPatch(statusText), + if (!result) { + return + } + this.commitGameResult(result, `已切换到${modeText} (${this.buildVersion})`, { gameModeText: modeText, - }, true) - this.syncRenderer() + }) + } + + handleSkipAction(): void { + const gameResult = this.gameRuntime.dispatch({ + type: 'skip_requested', + at: Date.now(), + lon: this.currentGpsPoint ? this.currentGpsPoint.lon : null, + lat: this.currentGpsPoint ? this.currentGpsPoint.lat : null, + }) + this.commitGameResult(gameResult) } handleConnectHeartRate(): void { @@ -1625,9 +1836,18 @@ export class MapEngine { this.tileBoundsByZoom = config.tileBoundsByZoom this.courseData = config.course this.cpRadiusMeters = config.cpRadiusMeters + this.configAppId = config.configAppId + this.configSchemaVersion = config.configSchemaVersion + this.configVersion = config.configVersion + this.controlScoreOverrides = config.controlScoreOverrides + this.defaultControlScore = config.defaultControlScore this.gameMode = config.gameMode this.punchPolicy = config.punchPolicy this.punchRadiusMeters = config.punchRadiusMeters + this.requiresFocusSelection = config.requiresFocusSelection + this.skipEnabled = config.skipEnabled + this.skipRadiusMeters = config.skipRadiusMeters + this.skipRequiresConfirm = config.skipRequiresConfirm this.autoFinishOnLastControl = config.autoFinishOnLastControl this.telemetryRuntime.configure(config.telemetryConfig) this.feedbackDirector.configure({ @@ -1636,10 +1856,11 @@ export class MapEngine { uiEffectsConfig: config.uiEffectsConfig, }) - const gameEffects = this.loadGameDefinitionFromCourse() - const gameStatusText = this.applyGameEffects(gameEffects) + const gameResult = this.loadGameDefinitionFromCourse() + const gameStatusText = gameResult ? this.resolveAppliedGameStatusText(gameResult) : null const statePatch: Partial = { - configStatusText: `远程配置已载入 / ${config.courseStatusText}`, + mapName: config.configTitle, + configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`, projectionMode: config.projectionModeText, tileSource: config.tileSource, sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)), @@ -1647,7 +1868,7 @@ export class MapEngine { northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), northReferenceText: formatNorthReferenceText(this.northReferenceMode), compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg), - ...this.getGameViewPatch(), + ...this.getGameViewPatch(gameStatusText), } if (!this.state.stageWidth || !this.state.stageHeight) { @@ -1869,12 +2090,10 @@ export class MapEngine { at: Date.now(), controlId: focusedControlId, }) - this.gamePresentation = gameResult.presentation - this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId()) - this.setState({ - ...this.getGameViewPatch(focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`), - }, true) - this.syncRenderer() + this.commitGameResult( + gameResult, + focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`, + ) } findFocusableControlAt(stageX: number, stageY: number): string | null | undefined { @@ -2472,6 +2691,8 @@ export class MapEngine { activeLegIndices: this.gamePresentation.map.activeLegIndices, completedLegIndices: this.gamePresentation.map.completedLegIndices, completedControlSequences: this.gamePresentation.map.completedControlSequences, + skippedControlIds: this.gamePresentation.map.skippedControlIds, + skippedControlSequences: this.gamePresentation.map.skippedControlSequences, osmReferenceEnabled: this.state.osmReferenceEnabled, overlayOpacity: MAP_OVERLAY_OPACITY, } diff --git a/miniprogram/engine/renderer/courseLabelRenderer.ts b/miniprogram/engine/renderer/courseLabelRenderer.ts index 4d9ec73..e883949 100644 --- a/miniprogram/engine/renderer/courseLabelRenderer.ts +++ b/miniprogram/engine/renderer/courseLabelRenderer.ts @@ -115,6 +115,10 @@ export class CourseLabelRenderer { return COMPLETED_LABEL_COLOR } + if (scene.skippedControlSequences.includes(sequence)) { + return COMPLETED_LABEL_COLOR + } + return DEFAULT_LABEL_COLOR } @@ -127,6 +131,10 @@ export class CourseLabelRenderer { return SCORE_COMPLETED_LABEL_COLOR } + if (scene.skippedControlSequences.includes(sequence)) { + return SCORE_COMPLETED_LABEL_COLOR + } + return SCORE_LABEL_COLOR } diff --git a/miniprogram/engine/renderer/mapRenderer.ts b/miniprogram/engine/renderer/mapRenderer.ts index 6afdba8..3bb4a89 100644 --- a/miniprogram/engine/renderer/mapRenderer.ts +++ b/miniprogram/engine/renderer/mapRenderer.ts @@ -45,6 +45,8 @@ export interface MapScene { activeLegIndices: number[] completedLegIndices: number[] completedControlSequences: number[] + skippedControlIds: string[] + skippedControlSequences: number[] osmReferenceEnabled: boolean overlayOpacity: number } diff --git a/miniprogram/engine/renderer/webglVectorRenderer.ts b/miniprogram/engine/renderer/webglVectorRenderer.ts index d77ee0a..b3ceabb 100644 --- a/miniprogram/engine/renderer/webglVectorRenderer.ts +++ b/miniprogram/engine/renderer/webglVectorRenderer.ts @@ -346,6 +346,10 @@ export class WebGLVectorRenderer { return scene.completedLegIndices.includes(index) } + isSkippedControl(scene: MapScene, sequence: number): boolean { + return scene.skippedControlSequences.includes(sequence) + } + pushCourseLeg( positions: number[], colors: number[], @@ -462,7 +466,7 @@ export class WebGLVectorRenderer { return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR } - if (scene.completedControlSequences.includes(sequence)) { + if (scene.completedControlSequences.includes(sequence) || this.isSkippedControl(scene, sequence)) { return COMPLETED_ROUTE_COLOR } diff --git a/miniprogram/game/content/courseToGameDefinition.ts b/miniprogram/game/content/courseToGameDefinition.ts index abb415e..856ccc1 100644 --- a/miniprogram/game/content/courseToGameDefinition.ts +++ b/miniprogram/game/content/courseToGameDefinition.ts @@ -20,6 +20,12 @@ export function buildGameDefinitionFromCourse( autoFinishOnLastControl = true, punchPolicy: PunchPolicyType = 'enter-confirm', punchRadiusMeters = 5, + requiresFocusSelection = false, + skipEnabled = false, + skipRadiusMeters = 30, + skipRequiresConfirm = true, + controlScoreOverrides: Record = {}, + defaultControlScore: number | null = null, ): GameDefinition { const controls: GameControl[] = [] @@ -31,22 +37,28 @@ export function buildGameDefinitionFromCourse( kind: 'start', point: start.point, sequence: null, + score: null, displayContent: null, }) } for (const control of sortBySequence(course.layers.controls)) { const label = control.label || String(control.sequence) + const controlId = `control-${control.sequence}` + const score = controlId in controlScoreOverrides + ? controlScoreOverrides[controlId] + : defaultControlScore controls.push({ - id: `control-${control.sequence}`, + id: controlId, code: label, label, kind: 'control', point: control.point, sequence: control.sequence, + score, displayContent: { - title: `收集 ${label}`, - body: buildDisplayBody(label, control.sequence), + title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`, + body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence), }, }) } @@ -59,6 +71,7 @@ export function buildGameDefinitionFromCourse( kind: 'finish', point: finish.point, sequence: null, + score: null, displayContent: null, }) } @@ -70,6 +83,10 @@ export function buildGameDefinitionFromCourse( controlRadiusMeters, punchRadiusMeters, punchPolicy, + requiresFocusSelection, + skipEnabled, + skipRadiusMeters, + skipRequiresConfirm, controls, autoFinishOnLastControl, } diff --git a/miniprogram/game/core/gameDefinition.ts b/miniprogram/game/core/gameDefinition.ts index 746b21d..fcdf1cb 100644 --- a/miniprogram/game/core/gameDefinition.ts +++ b/miniprogram/game/core/gameDefinition.ts @@ -17,6 +17,7 @@ export interface GameControl { kind: GameControlKind point: LonLatPoint sequence: number | null + score: number | null displayContent: GameControlDisplayContent | null } @@ -27,6 +28,10 @@ export interface GameDefinition { controlRadiusMeters: number punchRadiusMeters: number punchPolicy: PunchPolicyType + requiresFocusSelection: boolean + skipEnabled: boolean + skipRadiusMeters: number + skipRequiresConfirm: boolean controls: GameControl[] autoFinishOnLastControl: boolean audioConfig?: GameAudioConfig diff --git a/miniprogram/game/core/gameEvent.ts b/miniprogram/game/core/gameEvent.ts index 9db47b2..e1ccab9 100644 --- a/miniprogram/game/core/gameEvent.ts +++ b/miniprogram/game/core/gameEvent.ts @@ -2,5 +2,6 @@ export type GameEvent = | { type: 'session_started'; at: number } | { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null } | { type: 'punch_requested'; at: number } + | { type: 'skip_requested'; at: number; lon: number | null; lat: number | null } | { type: 'control_focused'; at: number; controlId: string | null } | { type: 'session_ended'; at: number } diff --git a/miniprogram/game/core/gameRuntime.ts b/miniprogram/game/core/gameRuntime.ts index 5600b9b..d54d2d6 100644 --- a/miniprogram/game/core/gameRuntime.ts +++ b/miniprogram/game/core/gameRuntime.ts @@ -65,6 +65,7 @@ export class GameRuntime { startedAt: null, endedAt: null, completedControlIds: [], + skippedControlIds: [], currentTargetControlId: null, inRangeControlId: null, score: 0, diff --git a/miniprogram/game/core/gameSessionState.ts b/miniprogram/game/core/gameSessionState.ts index 03e5f74..8430799 100644 --- a/miniprogram/game/core/gameSessionState.ts +++ b/miniprogram/game/core/gameSessionState.ts @@ -7,6 +7,7 @@ export interface GameSessionState { startedAt: number | null endedAt: number | null completedControlIds: string[] + skippedControlIds: string[] currentTargetControlId: string | null inRangeControlId: string | null score: number diff --git a/miniprogram/game/feedback/feedbackDirector.ts b/miniprogram/game/feedback/feedbackDirector.ts index 9cd502f..c3d4b31 100644 --- a/miniprogram/game/feedback/feedbackDirector.ts +++ b/miniprogram/game/feedback/feedbackDirector.ts @@ -39,6 +39,10 @@ export class FeedbackDirector { this.uiEffectDirector.configure(config.uiEffectsConfig || DEFAULT_GAME_UI_EFFECTS_CONFIG) } + reset(): void { + this.soundDirector.resetContexts() + } + destroy(): void { this.soundDirector.destroy() this.hapticsDirector.destroy() diff --git a/miniprogram/game/presentation/mapPresentationState.ts b/miniprogram/game/presentation/mapPresentationState.ts index 1a90409..c4975d9 100644 --- a/miniprogram/game/presentation/mapPresentationState.ts +++ b/miniprogram/game/presentation/mapPresentationState.ts @@ -17,6 +17,8 @@ export interface MapPresentationState { completedLegIndices: number[] completedControlIds: string[] completedControlSequences: number[] + skippedControlIds: string[] + skippedControlSequences: number[] } export const EMPTY_MAP_PRESENTATION_STATE: MapPresentationState = { @@ -38,4 +40,6 @@ export const EMPTY_MAP_PRESENTATION_STATE: MapPresentationState = { completedLegIndices: [], completedControlIds: [], completedControlSequences: [], + skippedControlIds: [], + skippedControlSequences: [], } diff --git a/miniprogram/game/rules/classicSequentialRule.ts b/miniprogram/game/rules/classicSequentialRule.ts index f17d5eb..cd1f88d 100644 --- a/miniprogram/game/rules/classicSequentialRule.ts +++ b/miniprogram/game/rules/classicSequentialRule.ts @@ -35,10 +35,24 @@ function getCompletedControlSequences(definition: GameDefinition, state: GameSes .map((control) => control.sequence as number) } +function getSkippedControlSequences(definition: GameDefinition, state: GameSessionState): number[] { + return getScoringControls(definition) + .filter((control) => state.skippedControlIds.includes(control.id) && typeof control.sequence === 'number') + .map((control) => control.sequence as number) +} + function getCurrentTarget(definition: GameDefinition, state: GameSessionState): GameControl | null { return getSequentialTargets(definition).find((control) => control.id === state.currentTargetControlId) || null } +function getNextTarget(definition: GameDefinition, currentTarget: GameControl): GameControl | null { + const targets = getSequentialTargets(definition) + const currentIndex = targets.findIndex((control) => control.id === currentTarget.id) + return currentIndex >= 0 && currentIndex < targets.length - 1 + ? targets[currentIndex + 1] + : null +} + function getCompletedLegIndices(definition: GameDefinition, state: GameSessionState): number[] { const targets = getSequentialTargets(definition) const completedLegIndices: number[] = [] @@ -115,6 +129,43 @@ function buildPunchHintText(definition: GameDefinition, state: GameSessionState, : `${targetText}内,可点击打点` } +function buildSkipFeedbackText(currentTarget: GameControl): string { + if (currentTarget.kind === 'start') { + return '开始点不可跳过' + } + + if (currentTarget.kind === 'finish') { + return '终点不可跳过' + } + + return `已跳过检查点 ${typeof currentTarget.sequence === 'number' ? currentTarget.sequence : currentTarget.label}` +} + +function resolveGuidanceForTarget( + definition: GameDefinition, + previousState: GameSessionState, + target: GameControl | null, + location: LonLatPoint | null, +): { guidanceState: GameSessionState['guidanceState']; inRangeControlId: string | null; effects: GameEffect[] } { + if (!target || !location) { + const guidanceState: GameSessionState['guidanceState'] = 'searching' + return { + guidanceState, + inRangeControlId: null, + effects: getGuidanceEffects(previousState.guidanceState, guidanceState, target ? target.id : null), + } + } + + const distanceMeters = getApproxDistanceMeters(target.point, location) + const guidanceState = getGuidanceState(definition, distanceMeters) + const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? target.id : null + return { + guidanceState, + inRangeControlId, + effects: getGuidanceEffects(previousState.guidanceState, guidanceState, target.id), + } +} + function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState { const scoringControls = getScoringControls(definition) const sequentialTargets = getSequentialTargets(definition) @@ -168,6 +219,8 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState): revealFullCourse, activeLegIndices, completedLegIndices, + skippedControlIds: [], + skippedControlSequences: [], }, hud: hudPresentation, } @@ -192,6 +245,8 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState): completedLegIndices, completedControlIds: completedControls.map((control) => control.id), completedControlSequences: getCompletedControlSequences(definition, state), + skippedControlIds: state.skippedControlIds, + skippedControlSequences: getSkippedControlSequences(definition, state), } return { @@ -265,20 +320,19 @@ function buildCompletedEffect(control: GameControl): GameEffect { } function applyCompletion(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl, at: number): GameResult { - const targets = getSequentialTargets(definition) - const currentIndex = targets.findIndex((control) => control.id === currentTarget.id) const completedControlIds = state.completedControlIds.includes(currentTarget.id) ? state.completedControlIds : [...state.completedControlIds, currentTarget.id] - const nextTarget = currentIndex >= 0 && currentIndex < targets.length - 1 - ? targets[currentIndex + 1] - : null + const nextTarget = getNextTarget(definition, currentTarget) const completedFinish = currentTarget.kind === 'finish' const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl) const nextState: GameSessionState = { ...state, startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt, completedControlIds, + skippedControlIds: currentTarget.id === state.currentTargetControlId + ? state.skippedControlIds.filter((controlId) => controlId !== currentTarget.id) + : state.skippedControlIds, currentTargetControlId: nextTarget ? nextTarget.id : null, inRangeControlId: null, score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length, @@ -303,6 +357,39 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu } } +function applySkip( + definition: GameDefinition, + state: GameSessionState, + currentTarget: GameControl, + location: LonLatPoint | null, +): GameResult { + const nextTarget = getNextTarget(definition, currentTarget) + const nextPhase = nextTarget && nextTarget.kind === 'finish' ? 'finish' : 'course' + const nextGuidance = resolveGuidanceForTarget(definition, state, nextTarget, location) + const nextState: GameSessionState = { + ...state, + skippedControlIds: state.skippedControlIds.includes(currentTarget.id) + ? state.skippedControlIds + : [...state.skippedControlIds, currentTarget.id], + currentTargetControlId: nextTarget ? nextTarget.id : null, + inRangeControlId: nextGuidance.inRangeControlId, + guidanceState: nextGuidance.guidanceState, + modeState: { + mode: 'classic-sequential', + phase: nextTarget ? nextPhase : 'done', + }, + } + + return { + nextState, + presentation: buildPresentation(definition, nextState), + effects: [ + ...nextGuidance.effects, + { type: 'punch_feedback', text: buildSkipFeedbackText(currentTarget), tone: 'neutral' }, + ], + } +} + export class ClassicSequentialRule implements RulePlugin { get mode(): 'classic-sequential' { return 'classic-sequential' @@ -314,6 +401,7 @@ export class ClassicSequentialRule implements RulePlugin { startedAt: null, endedAt: null, completedControlIds: [], + skippedControlIds: [], currentTargetControlId: getInitialTargetId(definition), inRangeControlId: null, score: 0, @@ -423,6 +511,48 @@ export class ClassicSequentialRule implements RulePlugin { return applyCompletion(definition, state, currentTarget, event.at) } + if (event.type === 'skip_requested') { + if (!definition.skipEnabled) { + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [{ type: 'punch_feedback', text: '当前配置未开启跳点', tone: 'warning' }], + } + } + + if (currentTarget.kind !== 'control') { + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [{ type: 'punch_feedback', text: currentTarget.kind === 'start' ? '开始点不可跳过' : '终点不可跳过', tone: 'warning' }], + } + } + + if (event.lon === null || event.lat === null) { + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [{ type: 'punch_feedback', text: '当前无定位,无法跳点', tone: 'warning' }], + } + } + + const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat }) + if (distanceMeters > definition.skipRadiusMeters) { + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [{ type: 'punch_feedback', text: `未进入跳点范围 (${Math.round(definition.skipRadiusMeters)}m)`, tone: 'warning' }], + } + } + + return applySkip( + definition, + state, + currentTarget, + { lon: event.lon, lat: event.lat }, + ) + } + return { nextState: state, presentation: buildPresentation(definition, state), diff --git a/miniprogram/game/rules/scoreORule.ts b/miniprogram/game/rules/scoreORule.ts index eb5e8a3..ee095e9 100644 --- a/miniprogram/game/rules/scoreORule.ts +++ b/miniprogram/game/rules/scoreORule.ts @@ -33,6 +33,10 @@ function getScoreControls(definition: GameDefinition): GameControl[] { return definition.controls.filter((control) => control.kind === 'control') } +function getControlScore(control: GameControl): number { + return control.kind === 'control' && typeof control.score === 'number' ? control.score : 0 +} + function getRemainingScoreControls(definition: GameDefinition, state: GameSessionState): GameControl[] { return getScoreControls(definition).filter((control) => !state.completedControlIds.includes(control.id)) } @@ -112,6 +116,46 @@ function getFocusedTarget( return null } +function resolveInteractiveTarget( + definition: GameDefinition, + modeState: ScoreOModeState, + primaryTarget: GameControl | null, + focusedTarget: GameControl | null, +): GameControl | null { + if (modeState.phase === 'start') { + return primaryTarget + } + + if (definition.requiresFocusSelection) { + return focusedTarget + } + + return focusedTarget || primaryTarget +} + +function getNearestInRangeControl( + controls: GameControl[], + referencePoint: LonLatPoint, + radiusMeters: number, +): GameControl | null { + let nearest: GameControl | null = null + let nearestDistance = Number.POSITIVE_INFINITY + + for (const control of controls) { + const distance = getApproxDistanceMeters(control.point, referencePoint) + if (distance > radiusMeters) { + continue + } + + if (!nearest || distance < nearestDistance) { + nearest = control + nearestDistance = distance + } + } + + return nearest +} + function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] { if (distanceMeters <= definition.punchRadiusMeters) { return 'ready' @@ -166,14 +210,15 @@ function buildPunchHintText( const modeState = getModeState(state) if (modeState.phase === 'controls' || modeState.phase === 'finish') { - if (!focusedTarget) { + if (definition.requiresFocusSelection && !focusedTarget) { return modeState.phase === 'finish' ? '点击地图选中终点后结束比赛' : '点击地图选中一个目标点' } - const targetLabel = getDisplayTargetLabel(focusedTarget) - if (state.inRangeControlId === focusedTarget.id) { + const displayTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget) + const targetLabel = getDisplayTargetLabel(displayTarget) + if (displayTarget && state.inRangeControlId === displayTarget.id) { return definition.punchPolicy === 'enter' ? `${targetLabel}内,自动打点中` : `${targetLabel}内,可点击打点` @@ -258,7 +303,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState): .filter((control) => typeof control.sequence === 'number') .map((control) => control.sequence as number) const revealFullCourse = completedStart - const interactiveTarget = modeState.phase === 'start' ? primaryTarget : focusedTarget + const interactiveTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget) const punchButtonEnabled = running && definition.punchPolicy === 'enter-confirm' && !!interactiveTarget @@ -287,6 +332,8 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState): completedLegIndices: [], completedControlIds: completedControls.map((control) => control.id), completedControlSequences, + skippedControlIds: [], + skippedControlSequences: [], } const hudPresentation: HudPresentationState = { @@ -348,7 +395,9 @@ function applyCompletion( completedControlIds, currentTargetControlId: null, inRangeControlId: null, - score: getScoreControls(definition).filter((item) => completedControlIds.includes(item.id)).length, + score: getScoreControls(definition) + .filter((item) => completedControlIds.includes(item.id)) + .reduce((sum, item) => sum + getControlScore(item), 0), status: control.kind === 'finish' ? 'finished' : state.status, guidanceState: 'searching', } @@ -401,6 +450,7 @@ export class ScoreORule implements RulePlugin { startedAt: null, endedAt: null, completedControlIds: [], + skippedControlIds: [], currentTargetControlId: startControl ? startControl.id : null, inRangeControlId: null, score: 0, @@ -481,12 +531,16 @@ export class ScoreORule implements RulePlugin { guidanceTarget = focusedTarget || nextPrimaryTarget if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) { punchTarget = focusedTarget + } else if (!definition.requiresFocusSelection) { + punchTarget = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters) } } else if (modeState.phase === 'finish') { nextPrimaryTarget = getFinishControl(definition) guidanceTarget = focusedTarget || nextPrimaryTarget if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) { punchTarget = focusedTarget + } else if (!definition.requiresFocusSelection && nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) { + punchTarget = nextPrimaryTarget } } else if (targetControl) { guidanceTarget = targetControl @@ -556,7 +610,7 @@ export class ScoreORule implements RulePlugin { if (event.type === 'punch_requested') { const focusedTarget = getFocusedTarget(definition, state) - if ((modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) { + if (definition.requiresFocusSelection && (modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) { return { nextState: state, presentation: buildPresentation(definition, state), @@ -569,7 +623,7 @@ export class ScoreORule implements RulePlugin { controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null } - if (!controlToPunch || (focusedTarget && controlToPunch.id !== focusedTarget.id)) { + if (!controlToPunch || (definition.requiresFocusSelection && focusedTarget && controlToPunch.id !== focusedTarget.id)) { return { nextState: state, presentation: buildPresentation(definition, state), diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 52a3a30..fb06cf8 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -1,4 +1,10 @@ -import { MapEngine, type MapEngineStageRect, type MapEngineViewState } from '../../engine/map/mapEngine' +import { + MapEngine, + type MapEngineGameInfoRow, + type MapEngineGameInfoSnapshot, + type MapEngineStageRect, + type MapEngineViewState, +} from '../../engine/map/mapEngine' import { loadRemoteMapConfig } from '../../utils/remoteMapConfig' type CompassTickData = { angle: number @@ -13,13 +19,20 @@ type CompassLabelData = { className: string } type SideButtonMode = 'all' | 'left' | 'right' | 'hidden' +type SideActionButtonState = 'muted' | 'default' | 'active' type MapPageData = MapEngineViewState & { showDebugPanel: boolean + showGameInfoPanel: boolean statusBarHeight: number topInsetHeight: number hudPanelIndex: number + configSourceText: string mockBridgeUrlDraft: string mockHeartRateBridgeUrlDraft: string + gameInfoTitle: string + gameInfoSubtitle: string + gameInfoLocalRows: MapEngineGameInfoRow[] + gameInfoGlobalRows: MapEngineGameInfoRow[] panelTimerText: string panelMileageText: string panelDistanceValueText: string @@ -28,12 +41,17 @@ type MapPageData = MapEngineViewState & { compassTicks: CompassTickData[] compassLabels: CompassLabelData[] sideButtonMode: SideButtonMode + sideToggleIconSrc: string + sideButton4Class: string + sideButton11Class: string + sideButton16Class: string showLeftButtonGroup: boolean showRightButtonGroups: boolean showBottomDebugButton: boolean } -const INTERNAL_BUILD_VERSION = 'map-build-196' -const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json' +const INTERNAL_BUILD_VERSION = 'map-build-207' +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' let mapEngine: MapEngine | null = null let stageCanvasAttached = false function buildSideButtonVisibility(mode: SideButtonMode) { @@ -93,12 +111,66 @@ function getFallbackStageRect(): MapEngineStageRect { } } +function getSideToggleIconSrc(mode: SideButtonMode): string { + if (mode === 'left') { + return '../../assets/btn_more2.png' + } + if (mode === 'hidden') { + return '../../assets/btn_more1.png' + } + return '../../assets/btn_more3.png' +} + +function getSideActionButtonClass(state: SideActionButtonState): string { + if (state === 'muted') { + return 'map-side-button map-side-button--muted' + } + if (state === 'active') { + return 'map-side-button map-side-button--active' + } + return 'map-side-button map-side-button--default' +} + +function buildSideButtonState(data: Pick) { + const sideButton4State: SideActionButtonState = data.gameSessionStatus === 'idle' ? 'default' : 'active' + const sideButton11State: SideActionButtonState = data.showGameInfoPanel ? 'active' : 'default' + const sideButton16State: SideActionButtonState = data.skipButtonEnabled ? 'default' : 'muted' + + return { + sideToggleIconSrc: getSideToggleIconSrc(data.sideButtonMode), + sideButton4Class: getSideActionButtonClass(sideButton4State), + sideButton11Class: getSideActionButtonClass(sideButton11State), + sideButton16Class: getSideActionButtonClass(sideButton16State), + } +} + +function buildEmptyGameInfoSnapshot(): MapEngineGameInfoSnapshot { + return { + title: '当前游戏', + subtitle: '未开始', + localRows: [], + globalRows: [ + { label: '全球积分', value: '未接入' }, + { label: '全球排名', value: '未接入' }, + { label: '在线人数', value: '未接入' }, + { label: '队伍状态', value: '未接入' }, + { label: '实时广播', value: '未接入' }, + ], + } +} + Page({ data: { showDebugPanel: false, + showGameInfoPanel: false, statusBarHeight: 0, topInsetHeight: 12, hudPanelIndex: 0, + configSourceText: '顺序赛配置', + gameInfoTitle: '当前游戏', + gameInfoSubtitle: '未开始', + gameInfoLocalRows: [], + gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows, panelTimerText: '00:00:00', panelMileageText: '0m', panelActionTagText: '目标', @@ -142,6 +214,7 @@ Page({ panelAccuracyUnitText: '', punchButtonText: '打点', punchButtonEnabled: false, + skipButtonEnabled: false, punchHintText: '等待进入检查点范围', punchFeedbackVisible: false, punchFeedbackText: '', @@ -161,6 +234,12 @@ Page({ compassTicks: buildCompassTicks(), compassLabels: buildCompassLabels(), ...buildSideButtonVisibility('left'), + ...buildSideButtonState({ + sideButtonMode: 'left', + showGameInfoPanel: false, + skipButtonEnabled: false, + gameSessionStatus: 'idle', + }), } as unknown as MapPageData, onLoad() { @@ -190,16 +269,34 @@ Page({ nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText } - this.setData(nextData) + const mergedData = { + ...this.data, + ...nextData, + } as MapPageData + + this.setData({ + ...nextData, + ...buildSideButtonState(mergedData), + }) + + if (this.data.showGameInfoPanel) { + this.syncGameInfoPanelSnapshot() + } }, }) this.setData({ ...mapEngine.getInitialData(), showDebugPanel: false, + showGameInfoPanel: false, statusBarHeight, topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), hudPanelIndex: 0, + configSourceText: '顺序赛配置', + gameInfoTitle: '当前游戏', + gameInfoSubtitle: '未开始', + gameInfoLocalRows: [], + gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows, panelTimerText: '00:00:00', panelMileageText: '0m', panelActionTagText: '目标', @@ -241,6 +338,7 @@ Page({ panelAccuracyUnitText: '', punchButtonText: '打点', punchButtonEnabled: false, + skipButtonEnabled: false, punchHintText: '等待进入检查点范围', punchFeedbackVisible: false, punchFeedbackText: '', @@ -260,13 +358,19 @@ Page({ compassTicks: buildCompassTicks(), compassLabels: buildCompassLabels(), ...buildSideButtonVisibility('left'), + ...buildSideButtonState({ + sideButtonMode: 'left', + showGameInfoPanel: false, + skipButtonEnabled: false, + gameSessionStatus: 'idle', + }), }) }, onReady() { stageCanvasAttached = false this.measureStageAndCanvas() - this.loadMapConfigFromRemote() + this.loadMapConfigFromRemote(CLASSIC_REMOTE_GAME_CONFIG_URL, '顺序赛配置') }, onShow() { @@ -289,13 +393,18 @@ Page({ stageCanvasAttached = false }, - loadMapConfigFromRemote() { + loadMapConfigFromRemote(configUrl: string, configLabel: string) { const currentEngine = mapEngine if (!currentEngine) { return } - loadRemoteMapConfig(REMOTE_GAME_CONFIG_URL) + this.setData({ + configSourceText: configLabel, + configStatusText: `加载中: ${configLabel}`, + }) + + loadRemoteMapConfig(configUrl) .then((config) => { if (mapEngine !== currentEngine) { return @@ -605,16 +714,41 @@ Page({ } }, - handleSetClassicMode() { + handleLoadClassicConfig() { + this.loadMapConfigFromRemote(CLASSIC_REMOTE_GAME_CONFIG_URL, '顺序赛配置') + }, + + handleLoadScoreOConfig() { + this.loadMapConfigFromRemote(SCORE_O_REMOTE_GAME_CONFIG_URL, '积分赛配置') + }, + + handleForceExitGame() { if (mapEngine) { - mapEngine.handleSetGameMode('classic-sequential') + mapEngine.handleForceExitGame() } }, - handleSetScoreOMode() { - if (mapEngine) { - mapEngine.handleSetGameMode('score-o') + handleSkipAction() { + if (!mapEngine || !this.data.skipButtonEnabled) { + return } + + if (!mapEngine.shouldConfirmSkipAction()) { + mapEngine.handleSkipAction() + return + } + + wx.showModal({ + title: '确认跳点', + content: '确认跳过当前检查点并切换到下一个目标点?', + confirmText: '确认跳过', + cancelText: '取消', + success: (result) => { + if (result.confirm && mapEngine) { + mapEngine.handleSkipAction() + } + }, + }) }, handleClearMapTestArtifacts() { @@ -623,6 +757,48 @@ Page({ } }, + syncGameInfoPanelSnapshot() { + if (!mapEngine) { + return + } + + const snapshot = mapEngine.getGameInfoSnapshot() + this.setData({ + gameInfoTitle: snapshot.title, + gameInfoSubtitle: snapshot.subtitle, + gameInfoLocalRows: snapshot.localRows, + gameInfoGlobalRows: snapshot.globalRows, + }) + }, + + handleOpenGameInfoPanel() { + this.syncGameInfoPanelSnapshot() + this.setData({ + showDebugPanel: false, + showGameInfoPanel: true, + ...buildSideButtonState({ + sideButtonMode: this.data.sideButtonMode, + showGameInfoPanel: true, + skipButtonEnabled: this.data.skipButtonEnabled, + gameSessionStatus: this.data.gameSessionStatus, + }), + }) + }, + + handleCloseGameInfoPanel() { + this.setData({ + showGameInfoPanel: false, + ...buildSideButtonState({ + sideButtonMode: this.data.sideButtonMode, + showGameInfoPanel: false, + skipButtonEnabled: this.data.skipButtonEnabled, + gameSessionStatus: this.data.gameSessionStatus, + }), + }) + }, + + handleGameInfoPanelTap() {}, + handleOverlayTouch() {}, handlePunchAction() { @@ -648,7 +824,16 @@ Page({ }, handleCycleSideButtons() { - this.setData(buildSideButtonVisibility(getNextSideButtonMode(this.data.sideButtonMode))) + const nextMode = getNextSideButtonMode(this.data.sideButtonMode) + this.setData({ + ...buildSideButtonVisibility(nextMode), + ...buildSideButtonState({ + sideButtonMode: nextMode, + showGameInfoPanel: this.data.showGameInfoPanel, + skipButtonEnabled: this.data.skipButtonEnabled, + gameSessionStatus: this.data.gameSessionStatus, + }), + }) }, handleToggleMapRotateMode() { if (!mapEngine) { @@ -665,12 +850,25 @@ Page({ handleToggleDebugPanel() { this.setData({ showDebugPanel: !this.data.showDebugPanel, + showGameInfoPanel: false, + ...buildSideButtonState({ + sideButtonMode: this.data.sideButtonMode, + showGameInfoPanel: false, + skipButtonEnabled: this.data.skipButtonEnabled, + gameSessionStatus: this.data.gameSessionStatus, + }), }) }, handleCloseDebugPanel() { this.setData({ showDebugPanel: false, + ...buildSideButtonState({ + sideButtonMode: this.data.sideButtonMode, + showGameInfoPanel: this.data.showGameInfoPanel, + skipButtonEnabled: this.data.skipButtonEnabled, + gameSessionStatus: this.data.gameSessionStatus, + }), }) }, diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 92a5120..50c01b5 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -68,23 +68,21 @@ - + - - - + - + 1 2 3 - 4 + - + 5 6 7 @@ -93,24 +91,24 @@ 10 - - 11 + + 12 13 14 15 - 16 + - + {{punchButtonText}} - + 开始 - + @@ -118,7 +116,7 @@ 调试 - + {{panelActionTagText}} @@ -223,11 +221,50 @@ - + + + + + + GAME INFO + {{gameInfoTitle}} + {{gameInfoSubtitle}} + + + 关闭 + + + + + + + + + + + @@ -250,6 +287,10 @@ Mode {{gameModeText}} + + Config + {{configSourceText}} + Game {{gameSessionStatus}} @@ -267,8 +308,8 @@ {{punchHintText}} - 顺序赛 - 积分赛 + 顺序赛配置 + 积分赛配置 回到首屏 diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index 18c2587..6d0df4a 100644 --- a/miniprogram/pages/map/map.wxss +++ b/miniprogram/pages/map/map.wxss @@ -299,6 +299,10 @@ box-sizing: border-box; } +.map-side-button--default { + background: rgba(248, 251, 244, 0.96); +} + .map-side-button--icon { width: 90rpx; height: 90rpx; @@ -327,6 +331,16 @@ background: rgba(229, 233, 230, 0.92); } +.map-side-button--muted .map-side-button__action-image { + opacity: 0.46; + filter: grayscale(1); +} + +.map-side-button--active { + background: rgba(255, 226, 88, 0.98); + box-shadow: 0 0 0 4rpx rgba(255, 241, 158, 0.18), 0 12rpx 28rpx rgba(120, 89, 0, 0.2); +} + .map-side-button__text { font-size: 18rpx; line-height: 1.1; @@ -336,6 +350,16 @@ letter-spacing: 1rpx; } +.map-side-button__action-image { + width: 100%; + height: 100%; + border-radius: 22rpx; +} + +.map-side-button--active .map-side-button__action-image { + opacity: 1; +} + .compass-widget { display: flex; flex-direction: column; @@ -1019,6 +1043,92 @@ .race-panel__chevron--offset { right: 0; } +.game-info-modal { + position: absolute; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 0 20rpx 28rpx; + box-sizing: border-box; + background: rgba(7, 18, 12, 0.34); + z-index: 31; +} + +.game-info-modal__dialog { + width: 100%; + max-height: 72vh; + border-radius: 36rpx; + background: rgba(248, 251, 244, 0.98); + box-shadow: 0 20rpx 60rpx rgba(7, 18, 12, 0.24); + overflow: hidden; +} + +.game-info-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24rpx; + padding: 22rpx 28rpx 18rpx; + border-bottom: 1rpx solid rgba(22, 48, 32, 0.08); +} + +.game-info-modal__header-main { + flex: 1; + min-width: 0; +} + +.game-info-modal__header-actions { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.game-info-modal__eyebrow { + font-size: 22rpx; + font-weight: 800; + letter-spacing: 4rpx; + color: #5f7a65; + line-height: 1; +} + +.game-info-modal__title { + margin-top: 12rpx; + font-size: 42rpx; + line-height: 1.08; + font-weight: 700; + color: #163020; +} + +.game-info-modal__subtitle { + margin-top: 10rpx; + font-size: 22rpx; + line-height: 1.3; + color: #5f7a65; +} + +.game-info-modal__close { + flex-shrink: 0; + min-width: 108rpx; + padding: 14rpx 22rpx; + border-radius: 999rpx; + background: #163020; + color: #f7fbf2; + font-size: 24rpx; + text-align: center; +} + +.game-info-modal__content { + max-height: calc(72vh - 132rpx); + padding: 12rpx 24rpx 30rpx; + box-sizing: border-box; +} + +.debug-section--info { + margin-top: 14rpx; +} + .debug-modal { position: absolute; inset: 0; diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts index 256c3c9..45478c5 100644 --- a/miniprogram/utils/remoteMapConfig.ts +++ b/miniprogram/utils/remoteMapConfig.ts @@ -22,6 +22,10 @@ export interface TileZoomBounds { } export interface RemoteMapConfig { + configTitle: string + configAppId: string + configSchemaVersion: string + configVersion: string tileSource: string minZoom: number maxZoom: number @@ -45,7 +49,13 @@ export interface RemoteMapConfig { gameMode: 'classic-sequential' | 'score-o' punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number + requiresFocusSelection: boolean + skipEnabled: boolean + skipRadiusMeters: number + skipRequiresConfirm: boolean autoFinishOnLastControl: boolean + controlScoreOverrides: Record + defaultControlScore: number | null telemetryConfig: TelemetryConfig audioConfig: GameAudioConfig hapticsConfig: GameHapticsConfig @@ -53,14 +63,25 @@ export interface RemoteMapConfig { } interface ParsedGameConfig { + title: string + appId: string + schemaVersion: string + version: string mapRoot: string mapMeta: string course: string | null cpRadiusMeters: number + defaultZoom: number | null gameMode: 'classic-sequential' | 'score-o' punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number + requiresFocusSelection: boolean + skipEnabled: boolean + skipRadiusMeters: number + skipRequiresConfirm: boolean autoFinishOnLastControl: boolean + controlScoreOverrides: Record + defaultControlScore: number | null telemetryConfig: TelemetryConfig audioConfig: GameAudioConfig hapticsConfig: GameHapticsConfig @@ -668,6 +689,18 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam const rawGame = parsed.game && typeof parsed.game === 'object' && !Array.isArray(parsed.game) ? parsed.game as Record : null + const rawApp = parsed.app && typeof parsed.app === 'object' && !Array.isArray(parsed.app) + ? parsed.app as Record + : null + const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map) + ? parsed.map as Record + : null + const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield) + ? parsed.playfield as Record + : null + const rawPlayfieldSource = rawPlayfield && rawPlayfield.source && typeof rawPlayfield.source === 'object' && !Array.isArray(rawPlayfield.source) + ? rawPlayfield.source as Record + : null const normalizedGame: Record = {} if (rawGame) { const gameKeys = Object.keys(rawGame) @@ -690,41 +723,150 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam ? (parsed as Record).uieffects : (parsed as Record).ui - const mapRoot = typeof normalized.map === 'string' ? normalized.map : '' - const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : '' + const rawSession = rawGame && rawGame.session && typeof rawGame.session === 'object' && !Array.isArray(rawGame.session) + ? rawGame.session as Record + : null + const rawPunch = rawGame && rawGame.punch && typeof rawGame.punch === 'object' && !Array.isArray(rawGame.punch) + ? rawGame.punch as Record + : null + const rawSequence = rawGame && rawGame.sequence && typeof rawGame.sequence === 'object' && !Array.isArray(rawGame.sequence) + ? rawGame.sequence as Record + : null + const rawSkip = rawSequence && rawSequence.skip && typeof rawSequence.skip === 'object' && !Array.isArray(rawSequence.skip) + ? rawSequence.skip as Record + : null + const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring) + ? rawGame.scoring as Record + : null + + const mapRoot = rawMap && typeof rawMap.tiles === 'string' + ? rawMap.tiles + : typeof normalized.map === 'string' + ? normalized.map + : '' + const mapMeta = rawMap && typeof rawMap.mapmeta === 'string' + ? rawMap.mapmeta + : typeof normalized.mapmeta === 'string' + ? normalized.mapmeta + : '' if (!mapRoot || !mapMeta) { throw new Error('game.json 缺少 map 或 mapmeta 字段') } const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode const gameMode = parseGameMode(modeValue) + const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides) + ? rawPlayfield.controlOverrides as Record + : null + const controlScoreOverrides: Record = {} + if (rawControlOverrides) { + const keys = Object.keys(rawControlOverrides) + for (const key of keys) { + const item = rawControlOverrides[key] + if (!item || typeof item !== 'object' || Array.isArray(item)) { + continue + } + const scoreValue = Number((item as Record).score) + if (Number.isFinite(scoreValue)) { + controlScoreOverrides[key] = scoreValue + } + } + } return { + title: rawApp && typeof rawApp.title === 'string' ? rawApp.title : '', + appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '', + schemaVersion: typeof parsed.schemaVersion === 'string' ? parsed.schemaVersion : '1', + version: typeof parsed.version === 'string' ? parsed.version : '', mapRoot, mapMeta, - course: typeof normalized.course === 'string' ? normalized.course : null, - cpRadiusMeters: parsePositiveNumber(normalized.cpradius, 5), - gameMode, - punchPolicy: parsePunchPolicy(normalizedGame.punchpolicy !== undefined ? normalizedGame.punchpolicy : normalized.punchpolicy), - punchRadiusMeters: parsePositiveNumber( - normalizedGame.punchradiusmeters !== undefined - ? normalizedGame.punchradiusmeters - : normalizedGame.punchradius !== undefined - ? normalizedGame.punchradius - : normalized.punchradiusmeters !== undefined - ? normalized.punchradiusmeters - : normalized.punchradius, + course: rawPlayfieldSource && typeof rawPlayfieldSource.url === 'string' + ? rawPlayfieldSource.url + : typeof normalized.course === 'string' + ? normalized.course + : null, + cpRadiusMeters: parsePositiveNumber( + rawPlayfield && rawPlayfield.CPRadius !== undefined ? rawPlayfield.CPRadius : normalized.cpradius, 5, ), - autoFinishOnLastControl: parseBoolean( - normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol, + defaultZoom: rawMap && rawMap.initialView && typeof rawMap.initialView === 'object' && !Array.isArray(rawMap.initialView) + ? parsePositiveNumber((rawMap.initialView as Record).zoom, 17) + : null, + gameMode, + punchPolicy: parsePunchPolicy( + rawPunch && rawPunch.policy !== undefined + ? rawPunch.policy + : normalizedGame.punchpolicy !== undefined + ? normalizedGame.punchpolicy + : normalized.punchpolicy, + ), + 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, + ), + requiresFocusSelection: parseBoolean( + rawPunch && rawPunch.requiresFocusSelection !== undefined + ? rawPunch.requiresFocusSelection + : normalizedGame.requiresfocusselection !== undefined + ? normalizedGame.requiresfocusselection + : rawPunch && (rawPunch as Record).requiresfocusselection !== undefined + ? (rawPunch as Record).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( + rawSkip && rawSkip.requiresConfirm !== undefined + ? rawSkip.requiresConfirm + : normalizedGame.skiprequiresconfirm !== undefined + ? normalizedGame.skiprequiresconfirm + : normalized.skiprequiresconfirm, true, ), + autoFinishOnLastControl: parseBoolean( + rawSession && rawSession.autoFinishOnLastControl !== undefined + ? rawSession.autoFinishOnLastControl + : normalizedGame.autofinishonlastcontrol !== undefined + ? normalizedGame.autofinishonlastcontrol + : normalized.autofinishonlastcontrol, + true, + ), + controlScoreOverrides, + defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined + ? parsePositiveNumber(rawScoring.defaultControlScore, 10) + : null, telemetryConfig: parseTelemetryConfig(rawTelemetry), audioConfig: parseAudioConfig(rawAudio, gameConfigUrl), hapticsConfig: parseHapticsConfig(rawHaptics), uiEffectsConfig: parseUiEffectsConfig(rawUiEffects), - declinationDeg: parseDeclinationValue(normalized.declination), + declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination), } } @@ -755,17 +897,31 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam const gameMode = parseGameMode(config.gamemode) return { + title: '', + appId: '', + schemaVersion: '1', + version: '', mapRoot, mapMeta, course: typeof config.course === 'string' ? config.course : null, cpRadiusMeters: parsePositiveNumber(config.cpradius, 5), + defaultZoom: null, gameMode, punchPolicy: parsePunchPolicy(config.punchpolicy), punchRadiusMeters: parsePositiveNumber( config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius, 5, ), + requiresFocusSelection: parseBoolean(config.requiresfocusselection, false), + skipEnabled: parseBoolean(config.skipenabled, false), + skipRadiusMeters: parsePositiveNumber( + config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius, + 30, + ), + skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, true), autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true), + controlScoreOverrides: {}, + defaultControlScore: null, telemetryConfig: parseTelemetryConfig({ heartRate: { age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage, @@ -1010,13 +1166,17 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise RulePlugin -> GameResult` + +今天继续强化的是: + +- `GameResult -> MapEngine.commitGameResult(...) -> 页面 / telemetry / feedback / renderer` + +这条宿主管线的目标是: + +- 不再靠某个功能里“顺手刷新一下状态” +- 而是让所有玩法动作最终都走统一提交 + +当前这条统一提交链已经被用于: + +- 开始对局 +- 打点 +- 跳点 +- 积分赛 focus 选点 +- GPS 更新后的规则推进 +- 切换配置后的定义加载 + +这一步的意义非常大,因为游戏过程中真正需要同步的状态越来越多,例如: + +- 当前目标 +- 已完成 / 已跳过状态 +- HUD 文案 +- 打点与跳点按钮可用性 +- guidance 音效状态 +- 地图高亮 +- telemetry 目标距离 +- renderer 表现层 + +只要这些更新仍然能统一收敛到 `commitGameResult(...)` 这条链上,架构就还是健康的。 + +## 21.3 游戏信息面板成为新的宿主诊断出口 + +11 号按钮现在不再是临时调试入口,而是一个正式的“游戏信息面板”。 + +当前它由 [map.ts](/D:/dev/cmr-mini/miniprogram/pages/map/map.ts) 负责开关,由 [MapEngine](/D:/dev/cmr-mini/miniprogram/engine/map/mapEngine.ts) 提供快照数据,页面层只负责展示。 + +面板当前分成两部分: + +- `Local` +- `Global` + +其中: + +- `Local` 负责展示本地已知的实时状态 +- `Global` 目前还是占位,后面联网后再接全局赛事态 + +`Local` 当前已经可以展示: + +- 比赛名称 +- 配置版本 +- Schema 版本 +- 活动 ID +- 当前玩法 +- 当前状态 +- 当前目标 +- 进度 +- 分数 +- 打点与跳点规则 +- GPS、心率、里程、速度、卡路里等本地信息 + +这块的设计原则是: + +- 页面不自己拼业务逻辑 +- 引擎只提供统一快照 +- 后面全局数据接入时,继续沿这套面板结构扩展 + +## 21.4 侧边按钮体系正式分成两类 + +今天还把地图页的侧边按钮体系收了一版,避免后面按钮越来越多后状态逻辑变乱。 + +当前已经明确分成两类: + +### A. 三态功能按钮 + +适用于: + +- `4 exit` +- `11 info` +- `16 skip` + +它们统一使用三种状态: + +- `muted` +- `default` +- `active` + +这些按钮的视觉态不再由模板零散判断,而是由页面层统一派生。 + +当前 [map.ts](/D:/dev/cmr-mini/miniprogram/pages/map/map.ts) 中已经有: + +- `SideActionButtonState` +- `buildSideButtonState(...)` + +输入的是主状态,例如: + +- `showGameInfoPanel` +- `skipButtonEnabled` +- `gameSessionStatus` + +输出的是最终按钮态,例如: + +- `sideButton4Class` +- `sideButton11Class` +- `sideButton16Class` + +这意味着: + +- 有些状态虽然是用户点击触发的 +- 有些状态虽然是程序运行中更新的 +- 但最后按钮显示都统一走同一套派生逻辑 + +### B. 循环模式按钮 + +左上角那个按钮不属于三态功能按钮,而是单独的“模式切换器”。 + +当前它根据 `sideButtonMode` 循环切换不同图标: + +- `btn_more1` +- `btn_more2` +- `btn_more3` + +它的本质是: + +- 点击后切换显示模式 +- 图标跟随当前模式变化 + +而不是普通意义上的启用/禁用/激活按钮 + +把这类按钮和普通功能按钮分开,是为了后面继续扩展侧边栏时不把状态语义搅乱。 + +## 21.5 当前阶段的判断 + +到今天这一步,可以比较明确地说: + +- 统一提交管线已经有雏形 +- 游戏信息面板已经成为宿主状态对外展示窗口 +- 侧边按钮体系已经开始统一状态派生 + +这三件事都不是只服务某个玩法的补丁,而是在继续把“通用宿主层”做稳。 + +当前最重要的不是继续为了理论纯度大拆,而是: + +- 先用这套底座承接后续配置字段和玩法细化 +- 一旦再次出现“某类状态总是漏同步”的真实问题,再继续沿统一提交链收口 diff --git a/temp-gameplay-discussion.md b/temp-gameplay-discussion.md new file mode 100644 index 0000000..815f920 --- /dev/null +++ b/temp-gameplay-discussion.md @@ -0,0 +1,209 @@ +# 临时玩法讨论记录 + +本文档用于临时记录以下讨论内容: + +- 贪吃蛇式玩法是否适配当前架构 +- 超级玛丽拾金币式玩法是否适配当前架构 +- 这些玩法是否需要大改现有系统 + +当前结论仅用于阶段讨论,不作为正式设计冻结文档。 + +--- + +## 1. 结论 + +当前这两类玩法都适合现有架构。 + +- `贪吃蛇式玩法`:适合 +- `区域拾金币玩法`:适合 +- 二者都不需要推翻现有主架构 +- 主要工作会集中在: + - 新的 `RulePlugin` + - 新的 `modeState` + - 新的 `map/hud presentation` + - 少量内容模型扩展 + +也就是说,这两类玩法更像是在现有底座上继续长新玩法,而不是重做底层。 + +--- + +## 2. 为什么适合当前架构 + +当前系统已经拆出了以下关键层: + +- 地图引擎 +- 规则引擎 +- telemetry 信息层 +- map / hud presentation +- feedback 反馈层 +- 真实 / 模拟传感输入 + +这意味着: + +- 地图只负责显示和交互能力 +- 规则层只负责玩法推进 +- telemetry 只负责通用过程信息 +- feedback 只负责声音、震动、动效等效果消费 + +因此后续新增玩法,原则上主要是“新增规则和表现”,而不是“重写地图页”。 + +--- + +## 3. 贪吃蛇式玩法分析 + +### 3.1 玩法本质 + +这类玩法通常包含: + +- 玩家位置持续更新 +- 轨迹形成蛇身 +- 尾巴按规则增长或收缩 +- 撞到自己、奖励点、危险区后触发状态变化 + +### 3.2 适配当前架构的原因 + +当前架构已经具备: + +- 持续 GPS 输入 +- 持续 telemetry 更新 +- 规则事件驱动推进 +- 地图轨迹绘制能力 +- 统一反馈系统 + +因此它天然可以承载: + +- 尾巴增长 +- 尾巴裁切 +- 自碰撞 +- 收集奖励 +- 危险区域 + +### 3.3 真正需要新增的内容 + +主要是玩法私有状态,而不是底层推翻: + +- `snakeBody` +- `tailLength` +- `tailWindow` +- `collisionState` +- `collectibleState` + +这些都应放入该玩法自己的 `modeState`。 + +### 3.4 对当前架构的压力点 + +这类玩法会推动当前系统继续增强: + +- `modeState` 承载更复杂连续状态 +- `MapPresentation` 支持蛇身/危险区/奖励点等更多图元 +- 规则层处理持续碰撞判定 + +但这些属于增强,不属于重构。 + +--- + +## 4. 区域拾金币玩法分析 + +### 4.1 玩法本质 + +这类玩法通常包含: + +- 玩家在某片区域内自由移动 +- 经过或进入范围后收集金币 +- 有时间限制、连击或区域目标 +- 可附带终点或出口点 + +### 4.2 适配当前架构的原因 + +它本质上非常接近: + +- 自由收集 +- 多目标高亮 +- 局部 HUD 提示 + +而这些当前在 `score-o` 里已经有相当基础。 + +因此它可以看作: + +- `score-o` 的泛化版 +- 或“自由收集类玩法”的一个子类 + +### 4.3 真正需要新增的内容 + +这类玩法一般需要: + +- 新点位类型:`coin / pickup / bonus` +- 新 HUD 信息:已收集数、剩余金币、区域完成度 +- 新表现:金币图标、收集动效、区域边界 + +### 4.4 对当前架构的压力点 + +这类玩法比蛇尾玩法对底座压力更小。 + +它主要会推动: + +- 内容模型从“控制点”继续泛化 +- `MapPresentation` 支持更多点位类型 +- HUD 能容纳玩法专属信息 + +但依然不需要大改主架构。 + +--- + +## 5. 需要补强的底座点 + +如果未来真的开发这两类玩法,最值得继续补强的是: + +- 更明确的 `modeState` 规范 +- 更强的 `MapPresentation` +- 更通用的内容模型 +- 更清晰的玩法事件字典 + +建议后续逐步支持的通用对象类型: + +- `control` +- `collectible` +- `bonus` +- `hazard` +- `trigger` +- `zone` +- `exit` + +建议后续逐步支持的通用事件: + +- `item_collected` +- `zone_entered` +- `zone_left` +- `self_collision` +- `combo_started` +- `combo_broken` +- `area_cleared` + +--- + +## 6. 当前判断标准 + +如果未来实现这些玩法时出现以下现象,说明架构边界可能需要重审: + +- 必须大改 `MapEngine` +- 必须大改 `TelemetryRuntime` +- 必须让渲染器自己猜玩法规则 +- 必须把玩法私有状态塞进全局 telemetry + +如果没有出现这些情况,而主要只是新增: + +- `RulePlugin` +- `modeState` +- `presentation` +- `feedback` + +那就说明当前架构是适配的。 + +--- + +## 7. 当前阶段总判断 + +结论可以总结成一句话: + +当前这套架构不仅适合传统定向和积分赛,也适合继续承载更游戏化的运动玩法。 +像贪吃蛇式玩法和区域拾金币玩法,都更像是“新增玩法插件”,而不是“推翻现有底座”。 diff --git a/todo-multi-user-simulator.md b/todo-multi-user-simulator.md new file mode 100644 index 0000000..28fbf5d --- /dev/null +++ b/todo-multi-user-simulator.md @@ -0,0 +1,330 @@ +# 多人模拟器改造待开发文档 + +本文档用于记录“公网模拟器支持多人开发/多人联调”的待开发方案。 +当前仅作为设计与排期参考,不代表已经进入实现阶段。 + +--- + +## 1. 目标 + +当前外部模拟器已经支持: + +- mock GPS +- mock heart rate +- 公网 WebSocket 接入 + +但当前模型更接近“单会话广播”。 +如果多人同时开发或联调,容易出现: + +- A 的 GPS 影响 B 的小程序 +- C 的心率影响 D 的 HUD +- 同一公网模拟器服务缺乏隔离能力 + +因此需要把模拟器体系升级成: + +- 多房间 +- 多身份 +- 按目标订阅 + +最终目标是: + +- 多人共用同一个公网模拟服务 +- 各自的数据流互不干扰 +- 为未来多人玩法联调留好底座 + +--- + +## 2. 当前问题本质 + +当前模拟器通信模型更像: + +- 一个 WebSocket 服务 +- 模拟器侧发布消息 +- 小程序侧直接接收 + +这个模型在单人开发时足够。 +但在多人开发时,缺少以下维度: + +- `room` +- `actorId` +- `channel` + +没有这些维度时,服务端无法做消息隔离与路由控制。 + +--- + +## 3. 建议的第一阶段方案 + +第一阶段不追求复杂功能,只解决“多人不串流”的核心问题。 + +### 3.1 核心模型 + +为所有模拟消息增加 3 个维度: + +- `room` +- `actorId` +- `channel` + +含义如下: + +- `room` + 表示一个独立测试空间 +- `actorId` + 表示房间中的一个具体模拟源 +- `channel` + 表示消息类型,例如 `gps`、`heart_rate` + +### 3.2 第一阶段目标 + +第一阶段完成后应满足: + +- A 和 B 可以共用同一个公网模拟器服务 +- A 的小程序只接 A 的数据 +- B 的小程序只接 B 的数据 +- GPS 与心率都能隔离 + +--- + +## 4. 推荐协议 + +### 4.1 模拟器注册 + +```json +{ + "type": "register_simulator", + "room": "team-dev", + "actorId": "sim-a" +} +``` + +### 4.2 小程序订阅 + +```json +{ + "type": "subscribe", + "room": "team-dev", + "actorId": "sim-a", + "channels": ["gps", "heart_rate"] +} +``` + +### 4.3 发布 GPS + +```json +{ + "type": "publish", + "room": "team-dev", + "actorId": "sim-a", + "channel": "gps", + "payload": { + "type": "mock_gps", + "timestamp": 1711267200000, + "lat": 31.2304, + "lon": 121.4737, + "accuracyMeters": 6, + "speedMps": 2.4, + "headingDeg": 135 + } +} +``` + +### 4.4 发布心率 + +```json +{ + "type": "publish", + "room": "team-dev", + "actorId": "sim-a", + "channel": "heart_rate", + "payload": { + "type": "mock_heart_rate", + "timestamp": 1711267200000, + "bpm": 148 + } +} +``` + +--- + +## 5. 服务端改造建议 + +### 5.1 服务端职责 + +服务端从“直接广播”升级成“按订阅路由”。 + +它需要维护每个 WebSocket 连接的元数据: + +```ts +type ClientSession = { + socketId: string + role: 'simulator' | 'app' + room: string | null + actorId: string | null + channels: Set +} +``` + +### 5.2 路由规则 + +服务端收到 `publish` 后,只转发给满足以下条件的客户端: + +- `role === 'app'` +- `room` 一致 +- `actorId` 一致 +- `channels` 包含当前 `channel` + +这一步完成后,多人使用同一个公网服务时就不会互串。 + +### 5.3 第一阶段不需要的复杂能力 + +第一阶段不建议先做: + +- 房间成员列表 +- 在线人数统计 +- 历史消息回放 +- 房间消息缓存 +- 权限控制 + +这些可以等基础隔离跑通后再扩。 + +--- + +## 6. 小程序侧改造建议 + +### 6.1 调试面板新增字段 + +建议在调试面板中新增: + +- `Mock Room` +- `Mock Actor` +- `保存房间/身份` + +当前 GPS 和心率已经都有 mock bridge,后续建议最终共用同一个逻辑目标: + +- 同一个桥接地址 +- 同一个 `room` +- 同一个 `actorId` + +### 6.2 连接流程 + +小程序连上 mock bridge 后,自动发送: + +```json +{ + "type": "subscribe", + "room": "...", + "actorId": "...", + "channels": ["gps", "heart_rate"] +} +``` + +这样: + +- GPS 模拟只接自己的 `gps` +- 心率模拟只接自己的 `heart_rate` + +### 6.3 当前架构适配性 + +这项改造与当前架构是兼容的。 + +原因: + +- 它主要发生在传感层和调试链 +- 不需要改规则层 +- 不需要改 telemetry 语义 +- 不需要改地图引擎主逻辑 + +--- + +## 7. 外部模拟器改造建议 + +### 7.1 第一阶段 UI 最小改动 + +模拟器左侧面板新增两个输入项: + +- `Room` +- `Actor ID` + +后续所有 GPS / 心率发送都自动带上它们。 + +### 7.2 推荐默认使用方式 + +多人开发时建议: + +- 大家共用同一个公网服务地址 +- `room` 用项目或阶段名 +- `actorId` 用开发者自己名字或实例名 + +示例: + +- room: `team-dev` +- actorId: `zhangsan` +- actorId: `lisi` + +### 7.3 后续可扩展能力 + +后续如果要继续增强,可以加: + +- 房间成员列表 +- 一键复制当前房间配置 +- 旁观模式 +- 同房间多个 actor 同时显示 +- 共享路径模板 + +--- + +## 8. 为什么这项改造值得做 + +这不只是为了多人开发方便。 + +它还会直接为未来这些方向打基础: + +- 多人玩法联调 +- 团队对抗玩法 +- 领地争夺玩法 +- 多角色追逐玩法 + +也就是说: + +今天为“多人模拟器”加的 `room + actorId + channel`,未来可以直接演进成多人玩法调试底座。 + +--- + +## 9. 建议实施顺序 + +### 第一阶段 + +- 服务端支持 `register_simulator / subscribe / publish` +- 消息带 `room + actorId + channel` +- 小程序支持订阅指定 `room + actorId` +- 外部模拟器增加 `room / actorId` + +### 第二阶段 + +- 增加房间成员列表 +- 增加在线状态 +- 增加多 actor 可视化 + +### 第三阶段 + +- 接多人玩法联调 +- 接角色维度 +- 接会话回放与共享调试 + +--- + +## 10. 第一阶段验收标准 + +第一阶段完成后,至少应满足: + +1. 两个人同时连同一个公网模拟器服务,不串 GPS +2. 两个人同时连同一个公网模拟器服务,不串心率 +3. 同一个房间中,不同 `actorId` 可以隔离 +4. 一个小程序实例可以只接收自己配置的目标流 + +--- + +## 11. 当前结论 + +这项改造建议先保留为待开发事项。 +当前阶段不急着实现,但应作为后续多人开发与多人玩法联调的重要底座能力。