优化地图交互与文档方案

This commit is contained in:
2026-03-26 12:20:27 +08:00
parent ce25530938
commit d695308a55
9 changed files with 2196 additions and 69 deletions

View File

@@ -0,0 +1,450 @@
# 动效系统设计方案
本文档用于整理当前项目后续的动画 / 动效建设方案,目标不是单纯“让界面更花”,而是把动画正式纳入现有架构,成为:
- 状态感知工具
- 空间注意力引导工具
- 操作反馈工具
- 节奏增强工具
当前系统已经具备:
- 地图引擎
- 规则引擎
- telemetry
- presentation
- feedback
因此动画系统最合理的做法,不是零散补丁,而是按层管理、按事件驱动、按配置扩展。
---
## 1. 设计原则
后续动画建设建议遵循以下原则:
### 1.1 动画服务于玩法,不只是装饰
动画优先回答这些问题:
- 现在发生了什么
- 用户该看哪里
- 这次操作是否成功
- 当前节奏是在紧张、平稳还是危险
### 1.2 动画要分层
不要把所有动画都堆在页面层的 class 切换里。
后续应按:
- 地图空间动画
- HUD 动画
- 反馈动画
- 页面微交互动画
分层管理。
### 1.3 动画要和事件绑定
动画应该由事件或状态变化触发,而不是页面自己猜。
例如:
- `control_completed`
- `control_skipped`
- `guidance_state_changed`
- `session_started`
- `session_finished`
- `heart_rate_zone_changed`
- `gps_lock_changed`
### 1.4 动画要支持降级
低端机和正式版都需要降级策略。
后续建议统一支持:
- `animationsEnabled`
- `animationLevel = low / medium / high`
---
## 2. 动画分层方案
## 2.1 地图空间动画
这一层最重要,也最贴玩法。
适合放在:
- `MapPresentation`
- `MapScene`
- `WebGL renderer`
典型内容:
- 当前目标点脉冲
- 可打点状态强化
- 已完成点过渡
- 已跳过点灰态过渡
- 地图 pulse
- 危险区呼吸
- 迷雾 reveal 扩散
- 金币收集爆点
- 幽灵感知圈变化
### 这一层的特点
- 与地图空间对象绑定
- 最不适合用 WXML 硬拼
- 应由渲染层持续驱动
---
## 2.2 HUD 动画
这一层用于数值和状态提示,不直接改地图对象。
适合放在:
- 页面层
- HUD 组件层
典型内容:
- 目标距离数字滑变
- 进度数字跳变
- 心率区间颜色过渡
- 计时器关键时刻闪烁
- 按钮状态点亮 / 失活过渡
- 玩法专属状态块的显隐和强调
### 这一层的特点
- 更适合 CSS / WXSS animation
- 应避免过重
- 高优先级字段可以做轻动画,避免全屏大动作
---
## 2.3 反馈动画
这一层最适合和声音、震动一起看,属于事件消费型动画。
适合放在:
- `FeedbackDirector`
- `UIEffectDirector`
典型内容:
- 打点成功 toast
- 警告 shake
- 成功 burst
- stage flash
- 局部 pulse
- 失败 / 结束反馈
### 当前已有雏形
目前系统已经有一些反馈类动效基础:
- `punchFeedbackFxClass`
- `mapPulse`
- `stageFx`
这条线后续最值得继续系统化。
---
## 2.4 页面微交互动画
这一层优先级最低。
典型内容:
- 按钮轻微过渡
- 面板弹入弹出
- 信息卡展开收起
- 调试面板展开收起
### 原则
- 可以做,但不要先重投入
- 不要让它抢过地图和玩法本身的注意力
---
## 3. 当前最值得优先打磨的动画
如果要开始投入动画,我建议先做这 4 组。
## 3.1 打点成功动画体系
这是当前项目最值得优先打磨的一组。
建议包含:
- 控制点本体状态变化
- 地图局部 pulse
- HUD 进度跳变
- 成功提示 toast
- 声音与震动协同
### 为什么优先
- 高频
- 用户感知强
- 直接决定“打点有没有爽感”
---
## 3.2 目标点状态动画体系
建议把目标点的几种状态做清晰区分:
- 未完成
- 当前目标
- 可打点
- 已完成
- 已跳过
每个状态至少应在:
- 颜色
- 脉冲
- 强弱
上有明显区别。
### 为什么优先
- 这是地图玩法的核心视觉语言
- 对理解规则和空间注意力引导都很关键
---
## 3.3 锁定 / 自动转图状态动画
建议补强以下体验:
- 开启 GPS 锁定时的吸附反馈
- 锁定关闭时的提示
- 自动转图切换时的更自然缓动
- 特殊状态下的方向感提示
### 为什么优先
- 当前地图交互已经很强
- 这块稍微打磨就很有“专业感”
---
## 3.4 危险 / 高压状态动画
这条非常适合未来玩法扩展,尤其是:
- 幽灵追逐赛
- 心率驱动玩法
- 高压任务模式
建议后续支持:
- 边缘呼吸
- 危险圈脉冲
- 压力提示颜色递进
- 节奏增强
---
## 4. 事件驱动建议
动画最好不要由页面层直接“看到状态变了就自己猜”,而应由事件或 presentation 状态明确驱动。
建议优先整理以下动画事件:
- `session_started`
- `session_finished`
- `session_cancelled`
- `control_completed:start`
- `control_completed:control`
- `control_completed:finish`
- `control_skipped`
- `guidance_state_changed`
- `gps_lock_changed`
- `heart_rate_zone_changed`
- `danger_state_changed`
这些事件后续可以统一映射到:
- sound
- haptics
- uiEffects
- map animation
---
## 5. 配置化建议
后续动画不应只写死在代码里。
建议逐步走向 profile 化。
例如:
```json
"game": {
"feedback": {
"uiEffectsProfile": "default-race",
"mapAnimationProfile": "default-map"
}
}
```
### 后续 profile 可承载的内容
- 某类事件是否启用动效
- 动效持续时间
- 动效强度
- 颜色风格
- 是否允许低端机降级
---
## 6. 建议增加统一动画配置
建议后续统一支持:
```json
"game": {
"animation": {
"enabled": true,
"level": "medium"
}
}
```
建议值:
- `enabled`
- `level = low / medium / high`
### 用途
- 低端机降级
- 调试关闭
- 正式版保守
---
## 7. 技术落地建议
## 7.1 地图动画
应继续放在地图引擎和 renderer 内处理。
不要让页面层承担:
- 点位 pulse
- 区域 reveal
- 轨迹闪动
- 目标高亮
这些都更适合:
- `MapPresentation`
- `MapScene`
- `WebGL renderer`
---
## 7.2 HUD 动画
适合继续放在页面层。
建议:
- 尽量轻量
- 尽量做过渡,不做大面积复杂动画
- 高优先级字段做细微跃迁即可
---
## 7.3 反馈动画
应继续走:
- `FeedbackDirector`
- `UIEffectDirector`
这条线后续很适合继续统一:
- 哪个事件触发什么动画
- 持续多久
- 是否叠加 sound / haptics
---
## 8. 实施顺序建议
不建议一口气铺太多动画。
推荐顺序:
1. `打点成功动画体系`
2. `目标点状态动画体系`
3. `HUD 数字与状态过渡`
4. `锁定 / 自动转图状态动画`
5. `危险 / 高压反馈动画`
6. 最后再做页面微交互动画
---
## 9. 第一阶段建议任务
如果下一步准备开始做动画,建议第一阶段先只收下面这些:
### 任务 1
整理一份动画事件字典:
- 哪些事件会触发动画
- 动画归属哪一层
- 对应目的是什么
### 任务 2
把打点成功链系统化:
- 点位变化
- HUD 跳变
- pulse
- toast
### 任务 3
统一目标点状态动画:
- 当前目标
- 可打点
- 已完成
- 已跳过
### 任务 4
补一个动画总开关:
- `animationsEnabled`
- `animationLevel`
---
## 10. 当前阶段结论
当前项目已经具备做动画体系的基础。
最正确的方向不是继续零散补动效,而是:
- 先按层组织动画
- 再按事件驱动
- 最后再做配置化和降级
一句话总结:
**后续动画建设应以“打点成功”和“目标状态”两条高频体验为起点,把动画正式纳入现有架构,而不是继续做零散样式补丁。**

View File

@@ -0,0 +1,416 @@
# 配置驱动应用的后台管理方案建议
本文用于整理当前这类“配置驱动型地图游戏应用”的后台管理建议,面向:
- PostgreSQL 数据库
- Go 中间层
- 后台管理系统
- 客户端静态配置发布
目标是解决一个核心问题:
**配置文件会越来越大,如何在后台可维护、可复用、可审核、可发布、可回滚。**
---
## 1. 总体原则
最稳的方案不是“数据库直接存一大份 `game.json` 给客户端读”,而是:
**数据库管理编辑态,发布时编译成运行态静态配置文件。**
也就是两套形态:
### 编辑态
- 存在 PostgreSQL
- 适合后台表单编辑
- 支持版本管理
- 支持对象复用
- 支持审核、比对、回滚
### 运行态
- 由 Go 中间层装配生成
- 输出为静态 JSON
- 上传到 OSS/CDN
- 客户端只读取发布后的静态配置
这条路线最适合当前项目。
---
## 2. 不建议的做法
不建议把后台做成:
- 一张表里存一个超大的 `jsonb`
- 后台直接编辑整份 `game.json`
- 客户端通过 API 动态拼装所有配置
这样后面会遇到这些问题:
- 配置复用困难
- diff 难看
- 回滚困难
- 审核困难
- 局部编辑体验差
- 客户端运行态不稳定
---
## 3. 推荐的核心对象
建议后台和数据库先固定这 5 个核心对象:
### `Map`
地图底座。
负责:
- 瓦片资源
- meta 信息
- 磁偏角
- 初始视角
### `Playfield`
玩法空间对象定义。
负责:
- KML 来源
- 控制点覆盖信息
- 区域对象
- 危险区
- 采集物
- 起终点信息
说明:
- `Playfield` 是上位概念
- `course` 只是其中一种特化形式
### `GameMode`
玩法模板。
负责:
- 顺序赛
- 积分赛
- 后续幽灵赛、迷雾赛、金币赛等
也就是:
- `game.mode`
- `session`
- `punch`
- `scoring`
- `guidance`
- `visibility`
- `finish`
- `telemetry`
- `feedback`
### `ResourcePack`
资源包。
负责:
- 音效 profile
- 文创内容
- 图标
- HUD 主题
- 动效 profile
### `Event`
最终活动实例。
负责引用:
- 一个 `Map`
- 一个 `Playfield`
- 一个 `GameMode`
- 一个 `ResourcePack`
并允许少量活动级覆盖。
一句话:
**Event = Map + Playfield + GameMode + ResourcePack + EventOverrides**
---
## 4. 数据库建模建议
建议每个核心对象都分成:
- 主表
- version 表
### 4.1 主表
主表存稳定元信息:
- `id`
- `slug`
- `name`
- `status`
- `current_version_id`
- `created_at`
- `updated_at`
### 4.2 version 表
version 表存每个版本的具体内容:
- `id`
- `parent_id`
- `version_no`
- `schema_version`
- `content_jsonb`
- `created_by`
- `created_at`
- `change_note`
### 4.3 推荐表
建议至少有:
- `maps`
- `map_versions`
- `playfields`
- `playfield_versions`
- `game_modes`
- `game_mode_versions`
- `resource_packs`
- `resource_pack_versions`
- `events`
- `event_versions`
---
## 5. 为什么要做版本表
版本表的价值非常大:
- 支持草稿
- 支持发布版
- 支持 diff
- 支持回滚
- 支持审计
- 支持多人协作
如果没有版本表,后面后台管理一定会越来越难维护。
---
## 6. JSONB 的使用建议
推荐策略是:
- 稳定字段结构化
- 变化快的配置内容放 `jsonb`
例如主表中:
- `slug`
- `name`
- `status`
放结构化列。
而玩法具体配置、资源清单、覆盖字段,放在 `content_jsonb`
这样兼顾:
- 查询效率
- 结构灵活性
- 配置扩展性
---
## 7. 后台编辑方式建议
后台不要直接给运营一个大 JSON 编辑框作为主要方式。
推荐做法:
- 地图编辑页
- Playfield 编辑页
- 玩法规则页
- 资源包页
- 活动编排页
按模块表单化编辑。
最后由 Go 中间层负责装配成最终配置 JSON。
也就是:
**后台是“编辑结构化对象”,不是“手工拼最终运行文件”。**
---
## 8. 发布机制建议
发布时建议按下面流程:
1. 后台选定某个 `Event Version`
2. Go 中间层读取它引用的:
- `Map Version`
- `Playfield Version`
- `GameMode Version`
- `ResourcePack Version`
3. 做装配
4. 做校验
5. 生成最终运行态 JSON
6. 上传 OSS/CDN
7. 记录一条 release
客户端只读:
- 已发布的静态配置 URL
不要让客户端直接查数据库 API 动态拼。
---
## 9. 推荐增加 Release 层
建议增加:
- `event_releases`
字段例如:
- `id`
- `event_id`
- `event_version_id`
- `release_no`
- `manifest_url`
- `published_by`
- `published_at`
- `status`
它的作用:
- 一键回滚
- 客户端锁定某次 release
- 管理历史发布记录
- 灰度验证
---
## 10. Go 中间层建议职责
Go 中间层不要只做 CRUD。
建议它至少承担这 4 类职责:
### 10.1 校验
- schema 校验
- 引用存在校验
- 字段完整性校验
- 规则约束校验
### 10.2 装配
把:
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event Overrides`
装配成最终配置结构。
### 10.3 发布
- 生成最终静态 JSON
- 上传到 OSS/CDN
- 记录 release
### 10.4 对比与预览
- 给后台显示 diff
- 给发布前做预览
一句话:
**Go 中间层本质上是配置编译器。**
---
## 11. 校验建议
建议尽量做强校验。
至少包括:
- schemaVersion 合法
- 引用对象存在
- KML 路径存在
- 地图 meta 存在
- 玩法字段完整
以及玩法特定约束,例如:
- 顺序赛必须有 start / finish
- 积分赛 control set 需要 score 或可派生 score
- `punch.radiusMeters > 0`
- `skip.radiusMeters > punch.radiusMeters`
这样能把很多错误挡在发布前。
---
## 12. 和当前静态目录的关系
当前你已经有类似目录:
- `map/`
- `kml/`
- `event/`
这很好,可以继续保留。
建议把它理解成:
- 数据库 = 编辑态
- 这些目录 = 发布产物态
也就是后台发布后Go 中间层继续生成:
- `event/classic-sequential.json`
- `event/score-o.json`
- `map/...`
- `kml/...`
客户端保持现有读取方式不变。
---
## 13. 推荐的后续实施顺序
建议按这个顺序落地:
### 第一步
先建 5 个核心对象模型:
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event`
### 第二步
为每个对象补版本表。
### 第三步
Go 中间层实现“装配成最终 JSON”。
### 第四步
实现“发布到 OSS/CDN”。
### 第五步
后台逐步从 JSON 编辑过渡到模块化表单编辑。
---
## 14. 一句话总结
这类配置驱动应用最稳的后台方案是:
**PostgreSQL 管结构化、可版本化的编辑态对象Go 中间层负责校验、装配和发布;客户端只消费发布后的静态 JSON。**
这样才能做到:
- 可复用
- 可扩展
- 可审核
- 可回滚
- 可稳定运行

View File

@@ -0,0 +1,406 @@
# 配置频繁变更场景下的后台管理方案
本文用于整理一套更适合“配置项变化很频繁”的后台方案。
适用前提:
- 配置驱动型应用
- 游戏规则和字段会持续变化
- PostgreSQL 作为主数据库
- Go 作为中间层
- 客户端最终读取静态 JSON
核心目标是:
**在保证后端稳定的前提下,让前端和玩法配置可以持续快速迭代。**
---
## 1. 核心原则
这版方案的核心思想只有一句:
**后端管理“容器、版本、引用、发布”,不要深度管理每个细字段。**
也就是说:
- 后端负责管理对象关系
- 后端负责管理版本和发布
- 后端负责做基础校验
- 后端尽量不要写死每个玩法里的所有字段细节
---
## 2. 总体结构
推荐分成 3 层:
### 2.1 编辑层
后台管理系统面向的是“对象”,不是最终运行文件。
建议核心对象仍然是:
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event`
### 2.2 装配层
Go 中间层负责:
- 读取对象
- 合并引用
- 基础校验
- 生成最终运行态配置
### 2.3 发布层
装配完成后,生成静态 JSON 上传到 OSS/CDN。
客户端只读取:
- 已发布的静态配置
---
## 3. 数据库存什么
数据库建议只存两类数据:
### 3.1 稳定元信息
结构化列保存:
- `id`
- `slug`
- `name`
- `status`
- `current_version_id`
- `created_at`
- `updated_at`
### 3.2 易变配置内容
使用 `jsonb` 保存:
- `content_jsonb`
也就是说,每个对象都建议拆成:
- 主表
- version 表
例如:
- `maps` / `map_versions`
- `playfields` / `playfield_versions`
- `game_modes` / `game_mode_versions`
- `resource_packs` / `resource_pack_versions`
- `events` / `event_versions`
这套结构最适合承接频繁变化的配置字段。
---
## 4. 为什么要用 version 表
配置频繁变化时,版本表非常重要:
- 支持草稿
- 支持当前版
- 支持发布版
- 支持历史回滚
- 支持 diff
- 支持审计
如果没有版本表,配置演进到后面会越来越难控。
---
## 5. 后端真正该负责的内容
后端建议强管理下面这 4 件事:
### 5.1 对象关系
例如:
- Event 引用哪个 Map
- Event 引用哪个 Playfield
- Event 引用哪个 GameMode
- Event 引用哪个 ResourcePack
### 5.2 版本机制
例如:
- 草稿
- 当前版本
- 发布版本
- 回滚历史
### 5.3 基础校验
只做真正稳定的校验:
- 顶层结构是否合法
- 引用是否存在
- schemaVersion 是否兼容
- 必填对象是否齐全
### 5.4 发布装配
把编辑态对象装配成最终运行态 JSON。
---
## 6. 后端不要过度负责的内容
后端不要把下面这些写死:
- 每个玩法的小规则字段
- 每个 HUD 开关
- 每个实验性参数
- 每个视觉细节配置
- 每次快速迭代里新增的小配置项
这些变化太频繁,应该优先放在 `jsonb` 内容里,由前端消费。
一句话:
**后端不要成为“所有细字段的业务解释器”。**
---
## 7. 配置校验的推荐分层
建议分成 3 层校验。
### 7.1 通用结构校验
所有配置都校验:
- `schemaVersion`
- `map`
- `playfield`
- `game`
### 7.2 公共字段校验
只校验稳定公共字段,例如:
- `game.mode` 必须存在
- `game.punch.radiusMeters > 0`
### 7.3 玩法校验器
`game.mode` 分发,例如:
- `classic-sequential` validator
- `score-o` validator
但这里有个重要原则:
**未识别字段默认允许透传。**
也就是说:
- 不要因为多了一个新字段就发布失败
- 只有破坏基础结构或关键规则时才拦截
---
## 8. 后台编辑策略
后台不要追求“一开始把所有字段都做成完美表单”。
建议分成两类:
### 8.1 稳定字段
做正式表单:
- 名称
- 状态
- 模式
- 地图引用
- Playfield 引用
- 资源包引用
- 关键半径
- 是否必须起点/终点
### 8.2 易变字段
先保留模块化 JSON 编辑区:
- `game.sequence`
- `game.guidance`
- `game.visibility`
- `game.feedback`
- `playfield.controlOverrides`
- 其他试验性字段
等这些字段稳定后,再逐步升级成正式表单。
这会比一开始硬做全表单更现实。
---
## 9. 推荐的发布模型
建议增加一层:
- `event_releases`
推荐字段:
- `id`
- `event_id`
- `event_version_id`
- `release_no`
- `manifest_url`
- `published_by`
- `published_at`
- `status`
发布流程:
1. 后台选择某个 `event_version`
2. Go 层装配最终配置
3. Go 层校验
4. 上传 OSS/CDN
5. 写入 release 记录
客户端只消费:
- 某次 release 对应的静态 JSON
---
## 10. Go 中间层的职责
Go 中间层建议承担 4 类职责:
### 10.1 装配器
负责把:
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event Overrides`
装配成最终运行态配置。
### 10.2 校验器
负责:
- 通用校验
- 公共字段校验
- 按玩法分发的插件式校验
### 10.3 发布器
负责:
- 生成静态 JSON
- 上传 OSS/CDN
- 写入 release
### 10.4 预览 / Diff
负责:
- 给后台看发布前的预览
- 对比不同版本差异
一句话:
**Go 中间层本质上是配置编译器,不只是 CRUD 服务。**
---
## 11. 这套方案为什么适合当前项目
因为当前项目的真实情况就是:
- 配置字段变化快
- 玩法在持续演进
- 前端经常需要新增规则项
- 客户端更适合消费静态配置
如果后端每次都跟着细字段改表、改结构、改接口,成本会非常高。
这套方案可以避免:
- 频繁 migration
- 后端字段爆炸
- 每次小字段变更都改很多 Go 代码
---
## 12. 推荐你现在就定死的原则
### 原则 1
**数据库结构稳定,配置内容灵活。**
### 原则 2
**后端强管理对象关系,不强管理每个细字段。**
### 原则 3
**未知字段默认允许透传。**
### 原则 4
**客户端消费细规则,后端负责发布与校验。**
### 原则 5
**最终运行态永远是静态 JSON。**
---
## 13. 和当前目录结构的关系
如果当前静态目录是:
- `map/`
- `kml/`
- `event/`
这套可以继续保留。
理解方式是:
- 数据库 = 编辑态
- Go 装配 = 发布态转换
- OSS 目录 = 运行态产物
也就是说后台发布后,继续生成:
- `event/classic-sequential.json`
- `event/score-o.json`
- `map/...`
- `kml/...`
客户端现有读取逻辑无需推翻。
---
## 14. 推荐实施顺序
建议按下面顺序推进:
### 第一步
先建 5 个核心对象:
- `Map`
- `Playfield`
- `GameMode`
- `ResourcePack`
- `Event`
### 第二步
为每个对象补 version 表。
### 第三步
Go 中间层先做最小装配功能。
### 第四步
实现发布到 OSS/CDN。
### 第五步
后台逐步把稳定字段表单化。
### 第六步
把易变字段继续保留为 JSON 编辑区。
---
## 15. 一句话总结
这套更适合频繁变化配置项的后台方案是:
**PostgreSQL 存“版本化对象 + jsonb 内容”Go 中间层做“装配 + 校验 + 发布”,客户端只读静态发布结果。**

441
gameplay-ideas-proposal.md Normal file
View File

@@ -0,0 +1,441 @@
# 新玩法建议方案
本文档用于整理当前阶段值得考虑的新游戏玩法方向,重点回答以下问题:
- 哪些玩法对用户更有吸引力
- 哪些玩法更适合当前架构
- 哪些玩法适合优先推进
- 新玩法大致会消耗哪些底座能力
当前判断基于现有系统能力:
- 地图引擎
- 规则引擎
- telemetry 信息层
- map / hud presentation
- feedback 反馈层
- 真实 / 模拟 GPS
- 真实 / 模拟心率
---
## 1. 总结结论
当前最值得优先考虑的新玩法,不是简单继续加“顺序赛变体”,而是做更有差异化和传播性的玩法。
综合吸引力、架构适配度、开发投入和可验证性,推荐优先级如下:
1. `幽灵追逐赛`
2. `动态积分赛`
3. `迷雾探索赛`
4. `区域金币冲刺`
5. `蛇尾生存赛`
如果从“最快做出明显新体验”的角度看:
- 最推荐优先试做:`幽灵追逐赛`
- 最容易从现有玩法演化:`动态积分赛`
- 最能体现数字地图优势:`迷雾探索赛`
---
## 2. 当前架构适不适合继续长新玩法
结论是:`适合`
原因在于当前系统已经不是“一个写死的地图页”,而是具备了比较清晰的分层:
- `MapEngine`
- 管地图交互、相机、锁定、缩放、自动转图
- `RuleEngine / RulePlugin`
- 管玩法推进和状态转换
- `TelemetryRuntime`
- 管速度、距离、心率、卡路里等通用信息
- `Presentation`
- 管地图和 HUD 展示态
- `Feedback`
- 管音效、震动、动效
因此后续大多数玩法更像是:
- 新增一个 `RulePlugin`
- 新增一组 `modeState`
- 新增一组 `presentation`
而不是推翻现有主架构。
---
## 3. 推荐玩法清单
### 3.1 幽灵追逐赛
#### 核心乐趣
- 一边找点,一边躲避“幽灵”追踪
- 地图不再只是找路,而是持续有压力感
- 心率越高、速度越乱,越容易“暴露”
#### 玩法示意
- 选定一个虚拟追逐者或 AI 幽灵
- 玩家需要完成打点或收集任务
- 幽灵根据规则靠近、感知或巡逻
- 特定点位可以提供隐身、干扰、冻结幽灵等道具
#### 为什么适合当前架构
这类玩法天然会复用现有能力:
- GPS位置与距离
- 心率:暴露度或危险加成
- HUD危险提示、追逐状态
- Feedback警报音、边缘警示
- 模拟器:可快速室内调试
#### 需要新增的内容
- 新的 `RulePlugin`
- `ghostState / stealthState / alertState`
- 地图上的感知圈、危险圈、幽灵标记
- 特定的提示与反馈
#### 开发判断
- 吸引力:`高`
- 架构适配度:`高`
- 开发成本:`中`
- 推荐优先级:`最高`
---
### 3.2 动态积分赛
#### 核心乐趣
- 同一个点的分值不是固定的
- 玩家不只是拼体力,也要拼判断和策略
- “继续赌高分”还是“见好就收”会成为玩法核心
#### 玩法示意
- 控制点分值随时间变化
- 热门点越多人去,分值越低
- 冷门点无人问津时,分值慢慢上涨
- 可随时结束,也可继续冲刺
#### 为什么适合当前架构
它本质上是现有 `score-o` 的增强版:
- 自由选点
- 点位分值
- HUD 计分
- 地图点位状态变化
主要增加的是“分值更新机制”和“同步能力”。
#### 需要新增的内容
- 动态分值模型
- 分值同步/刷新策略
- HUD 上的分值变化提示
- 地图点位的分值高低表现
#### 开发判断
- 吸引力:`高`
- 架构适配度:`高`
- 开发成本:`低到中`
- 推荐优先级:`高`
---
### 3.3 迷雾探索赛
#### 核心乐趣
- 开局地图是黑的,玩家是在“开图”
- 不是靠死记地图,而是靠探索解锁空间
- 很能体现数字地图的独特优势
#### 玩法示意
- 开局全图被迷雾遮住
- 靠近某些区域后局部解锁
- 某些点位可提供“雷达”或“远程扫描”能力
- 玩家要在有限信息下做探索决策
#### 为什么适合当前架构
这类玩法会复用:
- 位置输入
- 规则状态推进
- presentation 层
但它对地图渲染的要求更高,需要你继续增强地图遮罩和 reveal 系统。
#### 需要新增的内容
- `fogState / revealedAreaState`
- 地图迷雾遮罩
- reveal / scan 类事件
- HUD 上的探索进度提示
#### 开发判断
- 吸引力:`高`
- 架构适配度:`中高`
- 开发成本:`中高`
- 推荐优先级:`中高`
---
### 3.4 区域金币冲刺
#### 核心乐趣
- 上手门槛低
- 节奏快
- 很适合短局、多次复玩
#### 玩法示意
- 在某一区域内自由收集金币
- 连续收集触发倍率
- 特殊金币提供时间奖励或得分加成
- 可设置出口点或终点结算
#### 为什么适合当前架构
这类玩法本质接近积分赛和自由收集:
- 多目标自由采集
- 简单直观的分值逻辑
- 容易做 HUD 和地图高亮
#### 需要新增的内容
- `coin / bonus / exit` 等对象类型
- 收集动效和连击显示
- 区域完成度 / 剩余金币等 HUD 信息
#### 开发判断
- 吸引力:`中高`
- 架构适配度:`高`
- 开发成本:`中`
- 推荐优先级:`中高`
---
### 3.5 蛇尾生存赛
#### 核心乐趣
- 尾巴越来越长
- 视觉反馈强
- 规则新鲜且容易“上头”
#### 玩法示意
- 玩家移动轨迹形成蛇身
- 收集奖励会增长尾巴
- 尾巴过长或撞到自己会触发裁切/失败
- 可叠加危险区、奖励点、加速点等机制
#### 为什么适合当前架构
这类玩法非常依赖:
- GPS 轨迹
- 持续 telemetry
- 地图轨迹绘制
- 规则插件式状态推进
#### 需要新增的内容
- `snakeBody / tailWindow / tailLength`
- 自碰撞检测
- 蛇尾地图表现
- 奖励点 / 危险区
#### 开发判断
- 吸引力:`中高`
- 架构适配度:`中高`
- 开发成本:`中高`
- 推荐优先级:`中`
---
## 4. 其他值得保留的玩法方向
以下玩法同样值得保留为候选,但优先级可以放在上面 5 个之后。
### 4.1 领地争夺战
特点:
- 队伍占点
- 点位变色
- 可被对方夺回
- 强调多人实时对抗和策略
适配判断:
- 架构上可以支持
- 但会推动你补多人实时同步、区域关系判定、队伍状态管理
开发判断:
- 吸引力:`高`
- 架构适配度:`中`
- 开发成本:`高`
---
### 4.2 贪吃蛇式玩法
特点:
- 连续轨迹形成尾巴
- 奖励与风险明显
- 适合做更强游戏化实验
适配判断:
- 适合当前架构
- 主要消耗 `modeState` 和地图表现能力
开发判断:
- 吸引力:`中高`
- 架构适配度:`中高`
- 开发成本:`中高`
---
### 4.3 超级玛丽拾金币式玩法
特点:
- 更偏轻量、直观、上手快
- 类似“区域金币冲刺”的泛化版本
适配判断:
- 也非常适合当前架构
- 本质上就是更多对象类型和收集逻辑
开发判断:
- 吸引力:`中高`
- 架构适配度:`高`
- 开发成本:`中`
---
## 5. 按不同目标如何选玩法
### 如果目标是最快做出“新鲜感”
建议优先:
1. `幽灵追逐赛`
2. `动态积分赛`
### 如果目标是最能体现数字地图优势
建议优先:
1. `迷雾探索赛`
2. `幽灵追逐赛`
### 如果目标是最低理解门槛
建议优先:
1. `区域金币冲刺`
2. `动态积分赛`
### 如果目标是实验性和传播性
建议优先:
1. `蛇尾生存赛`
2. `幽灵追逐赛`
---
## 6. 对当前底座的主要消耗点
不同玩法会主要消耗不同底座能力:
- `幽灵追逐赛`
- 规则层
- telemetry
- 反馈层
- `动态积分赛`
- 规则层
- 分值同步
- HUD
- `迷雾探索赛`
- 地图引擎
- map presentation
- reveal 机制
- `区域金币冲刺`
- 内容模型
- presentation
- HUD
- `蛇尾生存赛`
- modeState
- 轨迹与碰撞
- 地图表现
---
## 7. 当前最值得优先投入的方向
综合判断,当前最推荐的推进顺序是:
1. `幽灵追逐赛`
2. `动态积分赛`
3. `迷雾探索赛`
原因:
- `幽灵追逐赛`
最能发挥你当前已经做好的 GPS / 心率 / HUD / feedback / 模拟器价值
- `动态积分赛`
最容易从现有 `score-o` 演化,投入小、效果明显
- `迷雾探索赛`
最能体现你地图引擎的差异化能力
---
## 8. 实际推进建议
建议后续不要同时推进多个重玩法,而是按“先验证新体验,再加深系统支撑”的节奏来。
推荐顺序:
1. 先做一个高吸引力但逻辑较清晰的玩法样板
- 推荐:`幽灵追逐赛`
2. 用这个玩法进一步验证:
- `RulePlugin`
- `modeState`
- `Presentation`
- `Feedback`
3. 再继续补底座能力:
- 更通用的对象模型
- 更强的地图表现
- 更清晰的玩法事件字典
---
## 9. 一句话结论
当前这套架构已经不只是适合传统顺序赛和积分赛,也适合继续承载更游戏化、更有传播性的运动玩法。
如果只优先选一个最值得推进的新玩法,建议先做:`幽灵追逐赛`

View File

@@ -55,12 +55,15 @@ const AUTO_ROTATE_EASE = 0.34
const AUTO_ROTATE_SNAP_DEG = 0.1
const AUTO_ROTATE_DEADZONE_DEG = 4
const AUTO_ROTATE_MAX_STEP_DEG = 0.75
const AUTO_ROTATE_HEADING_SMOOTHING = 0.32
const COMPASS_NEEDLE_SMOOTHING = 0.12
const AUTO_ROTATE_HEADING_SMOOTHING = 0.46
const COMPASS_NEEDLE_MIN_SMOOTHING = 0.24
const COMPASS_NEEDLE_MAX_SMOOTHING = 0.56
const SMART_HEADING_BLEND_START_SPEED_KMH = 1.2
const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0
const SMART_HEADING_MIN_DISTANCE_METERS = 8
const SMART_HEADING_MIN_DISTANCE_METERS = 12
const SMART_HEADING_MAX_ACCURACY_METERS = 25
const SMART_HEADING_MOVEMENT_MIN_SMOOTHING = 0.12
const SMART_HEADING_MOVEMENT_MAX_SMOOTHING = 0.24
const GPS_TRACK_MAX_POINTS = 200
const GPS_TRACK_MIN_STEP_METERS = 3
const MAP_TAP_MOVE_THRESHOLD_PX = 14
@@ -384,6 +387,35 @@ function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: numb
return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
}
function getCompassNeedleSmoothingFactor(currentDeg: number, targetDeg: number): number {
const deltaDeg = Math.abs(normalizeAngleDeltaDeg(targetDeg - currentDeg))
if (deltaDeg <= 4) {
return COMPASS_NEEDLE_MIN_SMOOTHING
}
if (deltaDeg >= 36) {
return COMPASS_NEEDLE_MAX_SMOOTHING
}
const progress = (deltaDeg - 4) / (36 - 4)
return COMPASS_NEEDLE_MIN_SMOOTHING
+ (COMPASS_NEEDLE_MAX_SMOOTHING - COMPASS_NEEDLE_MIN_SMOOTHING) * progress
}
function getMovementHeadingSmoothingFactor(speedKmh: number | null): number {
if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
return SMART_HEADING_MOVEMENT_MIN_SMOOTHING
}
if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
return SMART_HEADING_MOVEMENT_MAX_SMOOTHING
}
const progress = (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH)
/ (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH)
return SMART_HEADING_MOVEMENT_MIN_SMOOTHING
+ (SMART_HEADING_MOVEMENT_MAX_SMOOTHING - SMART_HEADING_MOVEMENT_MIN_SMOOTHING) * progress
}
function formatGameSessionStatusText(status: 'idle' | 'running' | 'finished' | 'failed'): string {
if (status === 'running') {
return '进行中'
@@ -705,16 +737,19 @@ export class MapEngine {
autoRotateTimer: number
pendingViewPatch: Partial<MapEngineViewState>
mounted: boolean
diagnosticUiEnabled: boolean
northReferenceMode: NorthReferenceMode
sensorHeadingDeg: number | null
smoothedSensorHeadingDeg: number | null
compassDisplayHeadingDeg: number | null
smoothedMovementHeadingDeg: number | null
autoRotateHeadingDeg: number | null
courseHeadingDeg: number | null
targetAutoRotationDeg: number | null
autoRotateSourceMode: AutoRotateSourceMode
autoRotateCalibrationOffsetDeg: number | null
autoRotateCalibrationPending: boolean
lastStatsUiSyncAt: number
minZoom: number
maxZoom: number
defaultZoom: number
@@ -776,14 +811,18 @@ export class MapEngine {
y,
z,
})
this.setState(this.getTelemetrySensorViewPatch(), true)
if (this.diagnosticUiEnabled) {
this.setState(this.getTelemetrySensorViewPatch(), true)
}
},
onError: (message) => {
this.accelerometerErrorText = `不可用: ${message}`
this.setState({
...this.getTelemetrySensorViewPatch(),
statusText: `加速度计启动失败 (${this.buildVersion})`,
}, true)
if (this.diagnosticUiEnabled) {
this.setState({
...this.getTelemetrySensorViewPatch(),
statusText: `加速度计启动失败 (${this.buildVersion})`,
}, true)
}
},
})
this.compassController = new CompassHeadingController({
@@ -803,10 +842,14 @@ export class MapEngine {
y,
z,
})
this.setState(this.getTelemetrySensorViewPatch(), true)
if (this.diagnosticUiEnabled) {
this.setState(this.getTelemetrySensorViewPatch(), true)
}
},
onError: () => {
this.setState(this.getTelemetrySensorViewPatch(), true)
if (this.diagnosticUiEnabled) {
this.setState(this.getTelemetrySensorViewPatch(), true)
}
},
})
this.deviceMotionController = new DeviceMotionController({
@@ -818,17 +861,21 @@ export class MapEngine {
beta,
gamma,
})
this.setState({
...this.getTelemetrySensorViewPatch(),
autoRotateSourceText: this.getAutoRotateSourceText(),
}, true)
if (this.diagnosticUiEnabled) {
this.setState({
...this.getTelemetrySensorViewPatch(),
autoRotateSourceText: this.getAutoRotateSourceText(),
}, true)
}
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
}
},
onError: () => {
this.setState(this.getTelemetrySensorViewPatch(), true)
if (this.diagnosticUiEnabled) {
this.setState(this.getTelemetrySensorViewPatch(), true)
}
},
})
this.locationController = new LocationController({
@@ -840,7 +887,7 @@ export class MapEngine {
gpsTracking: this.locationController.listening,
gpsTrackingText: message,
...this.getLocationControllerViewPatch(),
}, true)
})
},
onError: (message) => {
this.setState({
@@ -848,10 +895,12 @@ export class MapEngine {
gpsTrackingText: message,
...this.getLocationControllerViewPatch(),
statusText: `${message} (${this.buildVersion})`,
}, true)
})
},
onDebugStateChange: () => {
this.setState(this.getLocationControllerViewPatch(), true)
if (this.diagnosticUiEnabled) {
this.setState(this.getLocationControllerViewPatch(), true)
}
},
})
this.heartRateController = new HeartRateInputController({
@@ -872,7 +921,7 @@ export class MapEngine {
heartRateDeviceText: deviceName,
heartRateScanText: this.getHeartRateScanText(),
...this.getHeartRateControllerViewPatch(),
}, true)
})
},
onError: (message) => {
this.clearHeartRateSignal()
@@ -886,7 +935,7 @@ export class MapEngine {
heartRateScanText: this.getHeartRateScanText(),
...this.getHeartRateControllerViewPatch(),
statusText: `${message} (${this.buildVersion})`,
}, true)
})
},
onConnectionChange: (connected, deviceName) => {
if (!connected) {
@@ -906,17 +955,21 @@ export class MapEngine {
heartRateScanText: this.getHeartRateScanText(),
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
...this.getHeartRateControllerViewPatch(),
}, true)
})
},
onDeviceListChange: (devices) => {
this.setState({
heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
heartRateScanText: this.getHeartRateScanText(),
...this.getHeartRateControllerViewPatch(),
}, true)
if (this.diagnosticUiEnabled) {
this.setState({
heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
heartRateScanText: this.getHeartRateScanText(),
...this.getHeartRateControllerViewPatch(),
}, true)
}
},
onDebugStateChange: () => {
this.setState(this.getHeartRateControllerViewPatch(), true)
if (this.diagnosticUiEnabled) {
this.setState(this.getHeartRateControllerViewPatch(), true)
}
},
})
this.feedbackDirector = new FeedbackDirector({
@@ -1119,22 +1172,53 @@ export class MapEngine {
this.autoRotateTimer = 0
this.pendingViewPatch = {}
this.mounted = false
this.diagnosticUiEnabled = false
this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
this.sensorHeadingDeg = null
this.smoothedSensorHeadingDeg = null
this.compassDisplayHeadingDeg = null
this.smoothedMovementHeadingDeg = null
this.autoRotateHeadingDeg = null
this.courseHeadingDeg = null
this.targetAutoRotationDeg = null
this.autoRotateSourceMode = 'smart'
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
this.autoRotateCalibrationPending = false
this.lastStatsUiSyncAt = 0
}
getInitialData(): MapEngineViewState {
return { ...this.state }
}
setDiagnosticUiEnabled(enabled: boolean): void {
if (this.diagnosticUiEnabled === enabled) {
return
}
this.diagnosticUiEnabled = enabled
if (!enabled) {
return
}
this.setState({
...this.getTelemetrySensorViewPatch(),
...this.getLocationControllerViewPatch(),
...this.getHeartRateControllerViewPatch(),
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
autoRotateSourceText: this.getAutoRotateSourceText(),
visibleTileCount: this.state.visibleTileCount,
readyTileCount: this.state.readyTileCount,
memoryTileCount: this.state.memoryTileCount,
diskTileCount: this.state.diskTileCount,
memoryHitCount: this.state.memoryHitCount,
diskHitCount: this.state.diskHitCount,
networkFetchCount: this.state.networkFetchCount,
cacheHitRateText: this.state.cacheHitRateText,
}, true)
}
getGameInfoSnapshot(): MapEngineGameInfoSnapshot {
const definition = this.gameRuntime.definition
const sessionState = this.gameRuntime.state
@@ -1253,12 +1337,14 @@ export class MapEngine {
this.currentGpsTrack = []
this.currentGpsAccuracyMeters = null
this.currentGpsInsideMap = false
this.smoothedMovementHeadingDeg = null
this.courseOverlayVisible = false
this.setCourseHeading(null)
}
clearStartSessionResidue(): void {
this.currentGpsTrack = []
this.smoothedMovementHeadingDeg = null
this.courseOverlayVisible = false
this.setCourseHeading(null)
}
@@ -1534,7 +1620,7 @@ export class MapEngine {
panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
panelAccuracyValueText: telemetryPresentation.accuracyValueText,
panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
}, true)
})
}
updateSessionTimerLoop(): void {
@@ -1798,6 +1884,7 @@ export class MapEngine {
this.currentGpsPoint = nextPoint
this.currentGpsAccuracyMeters = accuracyMeters
this.updateMovementHeadingDeg()
const gpsWorldPoint = lonLatToWorldTile(nextPoint, this.state.zoom)
const gpsTileX = Math.floor(gpsWorldPoint.x)
@@ -2167,7 +2254,7 @@ export class MapEngine {
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
...this.getGameViewPatch(gameStatusText),
}
@@ -2683,18 +2770,26 @@ export class MapEngine {
const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null
? compassHeadingDeg
: interpolateAngleDeg(this.compassDisplayHeadingDeg, compassHeadingDeg, COMPASS_NEEDLE_SMOOTHING)
: interpolateAngleDeg(
this.compassDisplayHeadingDeg,
compassHeadingDeg,
getCompassNeedleSmoothingFactor(this.compassDisplayHeadingDeg, compassHeadingDeg),
)
this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
this.setState({
sensorHeadingText: formatHeadingText(compassHeadingDeg),
...this.getTelemetrySensorViewPatch(),
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
autoRotateSourceText: this.getAutoRotateSourceText(),
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
...(this.diagnosticUiEnabled
? {
sensorHeadingText: formatHeadingText(compassHeadingDeg),
...this.getTelemetrySensorViewPatch(),
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
autoRotateSourceText: this.getAutoRotateSourceText(),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
}
: {}),
})
if (!this.refreshAutoRotateTarget()) {
@@ -2740,7 +2835,7 @@ export class MapEngine {
...this.getTelemetrySensorViewPatch(),
compassDeclinationText: formatCompassDeclinationText(nextMode),
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
},
`${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
@@ -2759,7 +2854,7 @@ export class MapEngine {
...this.getTelemetrySensorViewPatch(),
compassDeclinationText: formatCompassDeclinationText(nextMode),
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
}, true)
@@ -2780,7 +2875,7 @@ export class MapEngine {
}
}
getMovementHeadingDeg(): number | null {
getRawMovementHeadingDeg(): number | null {
if (!this.currentGpsInsideMap) {
return null
}
@@ -2809,6 +2904,23 @@ export class MapEngine {
return null
}
updateMovementHeadingDeg(): void {
const rawMovementHeadingDeg = this.getRawMovementHeadingDeg()
if (rawMovementHeadingDeg === null) {
this.smoothedMovementHeadingDeg = null
return
}
const smoothingFactor = getMovementHeadingSmoothingFactor(this.telemetryRuntime.state.currentSpeedKmh)
this.smoothedMovementHeadingDeg = this.smoothedMovementHeadingDeg === null
? rawMovementHeadingDeg
: interpolateAngleDeg(this.smoothedMovementHeadingDeg, rawMovementHeadingDeg, smoothingFactor)
}
getMovementHeadingDeg(): number | null {
return this.smoothedMovementHeadingDeg
}
getPreferredSensorHeadingDeg(): number | null {
return this.smoothedSensorHeadingDeg === null
? null
@@ -2959,7 +3071,7 @@ export class MapEngine {
}
applyStats(stats: MapRendererStats): void {
this.setState({
const statsPatch = {
visibleTileCount: stats.visibleTileCount,
readyTileCount: stats.readyTileCount,
memoryTileCount: stats.memoryTileCount,
@@ -2968,7 +3080,27 @@ export class MapEngine {
diskHitCount: stats.diskHitCount,
networkFetchCount: stats.networkFetchCount,
cacheHitRateText: formatCacheHitRate(stats.memoryHitCount, stats.diskHitCount, stats.networkFetchCount),
})
}
if (!this.diagnosticUiEnabled) {
this.state = {
...this.state,
...statsPatch,
}
return
}
const now = Date.now()
if (now - this.lastStatsUiSyncAt < 500) {
this.state = {
...this.state,
...statsPatch,
}
return
}
this.lastStatsUiSyncAt = now
this.setState(statsPatch)
}
setState(patch: Partial<MapEngineViewState>, immediateUi = false): void {

View File

@@ -5,7 +5,7 @@ export interface CompassHeadingControllerCallbacks {
type SensorSource = 'compass' | 'motion' | null
const ABSOLUTE_HEADING_CORRECTION = 0.24
const ABSOLUTE_HEADING_CORRECTION = 0.44
function normalizeHeadingDeg(headingDeg: number): number {
const normalized = headingDeg % 360
@@ -202,5 +202,3 @@ export class CompassHeadingController {
}

View File

@@ -35,6 +35,7 @@ type MapPageData = MapEngineViewState & {
showDebugPanel: boolean
showGameInfoPanel: boolean
showCenterScaleRuler: boolean
showPunchHintBanner: boolean
centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode
statusBarHeight: number
topInsetHeight: number
@@ -74,11 +75,150 @@ type MapPageData = MapEngineViewState & {
showRightButtonGroups: boolean
showBottomDebugButton: boolean
}
const INTERNAL_BUILD_VERSION = 'map-build-252'
const INTERNAL_BUILD_VERSION = 'map-build-261'
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'
const PUNCH_HINT_AUTO_HIDE_MS = 30000
let mapEngine: MapEngine | null = null
let stageCanvasAttached = false
let gameInfoPanelSyncTimer = 0
let centerScaleRulerSyncTimer = 0
let punchHintDismissTimer = 0
const DEBUG_ONLY_VIEW_KEYS = new Set<string>([
'buildVersion',
'renderMode',
'projectionMode',
'mapReady',
'mapReadyText',
'mapName',
'configStatusText',
'sensorHeadingText',
'deviceHeadingText',
'devicePoseText',
'headingConfidenceText',
'accelerometerText',
'gyroscopeText',
'deviceMotionText',
'compassDeclinationText',
'northReferenceButtonText',
'autoRotateSourceText',
'autoRotateCalibrationText',
'northReferenceText',
'centerText',
'tileSource',
'visibleTileCount',
'readyTileCount',
'memoryTileCount',
'diskTileCount',
'memoryHitCount',
'diskHitCount',
'networkFetchCount',
'cacheHitRateText',
'locationSourceMode',
'locationSourceText',
'mockBridgeConnected',
'mockBridgeStatusText',
'mockBridgeUrlText',
'mockCoordText',
'mockSpeedText',
'gpsCoordText',
'heartRateSourceMode',
'heartRateSourceText',
'heartRateConnected',
'heartRateStatusText',
'heartRateDeviceText',
'heartRateScanText',
'heartRateDiscoveredDevices',
'mockHeartRateBridgeConnected',
'mockHeartRateBridgeStatusText',
'mockHeartRateBridgeUrlText',
'mockHeartRateText',
])
const CENTER_SCALE_RULER_DEP_KEYS = new Set<string>([
'showCenterScaleRuler',
'centerScaleRulerAnchorMode',
'stageWidth',
'stageHeight',
'topInsetHeight',
'zoom',
'centerTileY',
'tileSizePx',
'previewScale',
])
const RULER_ONLY_VIEW_KEYS = new Set<string>([
'zoom',
'centerTileX',
'centerTileY',
'tileSizePx',
'previewScale',
'stageWidth',
'stageHeight',
'stageLeft',
'stageTop',
])
const SIDE_BUTTON_DEP_KEYS = new Set<string>([
'sideButtonMode',
'showGameInfoPanel',
'showCenterScaleRuler',
'centerScaleRulerAnchorMode',
'skipButtonEnabled',
'gameSessionStatus',
'gpsLockEnabled',
'gpsLockAvailable',
])
function hasAnyPatchKey(patch: Record<string, unknown>, keys: Set<string>): boolean {
return Object.keys(patch).some((key) => keys.has(key))
}
function filterDebugOnlyPatch(
patch: Partial<MapPageData>,
includeDebugFields: boolean,
includeRulerFields: boolean,
): Partial<MapPageData> {
if (includeDebugFields && includeRulerFields) {
return patch
}
const filteredPatch: Partial<MapPageData> = {}
for (const [key, value] of Object.entries(patch)) {
if (!includeDebugFields && DEBUG_ONLY_VIEW_KEYS.has(key)) {
continue
}
if (!includeRulerFields && RULER_ONLY_VIEW_KEYS.has(key)) {
continue
}
{
;(filteredPatch as Record<string, unknown>)[key] = value
}
}
return filteredPatch
}
function clearGameInfoPanelSyncTimer() {
if (gameInfoPanelSyncTimer) {
clearTimeout(gameInfoPanelSyncTimer)
gameInfoPanelSyncTimer = 0
}
}
function clearCenterScaleRulerSyncTimer() {
if (centerScaleRulerSyncTimer) {
clearTimeout(centerScaleRulerSyncTimer)
centerScaleRulerSyncTimer = 0
}
}
function clearPunchHintDismissTimer() {
if (punchHintDismissTimer) {
clearTimeout(punchHintDismissTimer)
punchHintDismissTimer = 0
}
}
function buildSideButtonVisibility(mode: SideButtonMode) {
return {
sideButtonMode: mode,
@@ -389,6 +529,7 @@ Page({
panelDistanceValueText: '--',
panelDistanceUnitText: '',
panelProgressText: '0/0',
showPunchHintBanner: true,
gameSessionStatus: 'idle',
gameModeText: '顺序赛',
gpsLockEnabled: false,
@@ -488,9 +629,11 @@ Page({
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
onData: (patch) => {
const nextPatch = patch as Partial<MapPageData>
const nextData: Partial<MapPageData> = {
const includeDebugFields = this.data.showDebugPanel
const includeRulerFields = this.data.showCenterScaleRuler
const nextData: Partial<MapPageData> = filterDebugOnlyPatch({
...nextPatch,
}
}, includeDebugFields, includeRulerFields)
if (
typeof nextPatch.mockBridgeUrlText === 'string'
@@ -511,18 +654,52 @@ Page({
...nextData,
} as MapPageData
this.setData({
...nextData,
...buildCenterScaleRulerPatch(mergedData),
...buildSideButtonState(mergedData),
})
const derivedPatch: Partial<MapPageData> = {}
if (
this.data.showCenterScaleRuler
&& hasAnyPatchKey(nextPatch as Record<string, unknown>, CENTER_SCALE_RULER_DEP_KEYS)
) {
Object.assign(derivedPatch, buildCenterScaleRulerPatch(mergedData))
}
if (hasAnyPatchKey(nextPatch as Record<string, unknown>, SIDE_BUTTON_DEP_KEYS)) {
Object.assign(derivedPatch, buildSideButtonState(mergedData))
}
if (typeof nextPatch.punchHintText === 'string') {
const nextHintText = nextPatch.punchHintText.trim()
if (nextHintText !== this.data.punchHintText) {
clearPunchHintDismissTimer()
nextData.showPunchHintBanner = nextHintText.length > 0
if (nextHintText.length > 0) {
punchHintDismissTimer = setTimeout(() => {
punchHintDismissTimer = 0
this.setData({
showPunchHintBanner: false,
})
}, PUNCH_HINT_AUTO_HIDE_MS) as unknown as number
}
} else if (!nextHintText) {
clearPunchHintDismissTimer()
nextData.showPunchHintBanner = false
}
}
if (Object.keys(nextData).length || Object.keys(derivedPatch).length) {
this.setData({
...nextData,
...derivedPatch,
})
}
if (this.data.showGameInfoPanel) {
this.syncGameInfoPanelSnapshot()
this.scheduleGameInfoPanelSnapshotSync()
}
},
})
mapEngine.setDiagnosticUiEnabled(false)
this.setData({
...mapEngine.getInitialData(),
showDebugPanel: false,
@@ -542,6 +719,7 @@ Page({
panelDistanceValueText: '--',
panelDistanceUnitText: '',
panelProgressText: '0/0',
showPunchHintBanner: true,
gameSessionStatus: 'idle',
gameModeText: '顺序赛',
gpsLockEnabled: false,
@@ -647,6 +825,9 @@ Page({
},
onUnload() {
clearGameInfoPanelSyncTimer()
clearCenterScaleRulerSyncTimer()
clearPunchHintDismissTimer()
if (mapEngine) {
mapEngine.destroy()
mapEngine = null
@@ -686,7 +867,7 @@ Page({
})
},
measureStageAndCanvas() {
measureStageAndCanvas(onApplied?: () => void) {
const page = this
const applyStage = (rawRect?: Partial<WechatMiniprogram.BoundingClientRectCallbackResult>) => {
const fallbackRect = getFallbackStageRect()
@@ -703,6 +884,9 @@ Page({
}
currentEngine.setStage(rect)
if (onApplied) {
onApplied()
}
if (stageCanvasAttached) {
return
@@ -1053,7 +1237,26 @@ Page({
})
},
scheduleGameInfoPanelSnapshotSync() {
if (!this.data.showGameInfoPanel) {
clearGameInfoPanelSyncTimer()
return
}
if (gameInfoPanelSyncTimer) {
return
}
gameInfoPanelSyncTimer = setTimeout(() => {
gameInfoPanelSyncTimer = 0
if (this.data.showGameInfoPanel) {
this.syncGameInfoPanelSnapshot()
}
}, 400) as unknown as number
},
handleOpenGameInfoPanel() {
clearGameInfoPanelSyncTimer()
this.syncGameInfoPanelSnapshot()
this.setData({
showDebugPanel: false,
@@ -1072,6 +1275,7 @@ Page({
},
handleCloseGameInfoPanel() {
clearGameInfoPanelSyncTimer()
this.setData({
showGameInfoPanel: false,
...buildSideButtonState({
@@ -1107,6 +1311,13 @@ Page({
}
},
handleClosePunchHint() {
clearPunchHintDismissTimer()
this.setData({
showPunchHintBanner: false,
})
},
handleHudPanelChange(event: WechatMiniprogram.CustomEvent<{ current: number }>) {
this.setData({
hudPanelIndex: event.detail.current || 0,
@@ -1147,8 +1358,15 @@ Page({
mapEngine.handleSetHeadingUpMode()
},
handleToggleDebugPanel() {
const nextShowDebugPanel = !this.data.showDebugPanel
if (!nextShowDebugPanel) {
clearGameInfoPanelSyncTimer()
}
if (mapEngine) {
mapEngine.setDiagnosticUiEnabled(nextShowDebugPanel)
}
this.setData({
showDebugPanel: !this.data.showDebugPanel,
showDebugPanel: nextShowDebugPanel,
showGameInfoPanel: false,
...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode,
@@ -1164,6 +1382,9 @@ Page({
},
handleCloseDebugPanel() {
if (mapEngine) {
mapEngine.setDiagnosticUiEnabled(false)
}
this.setData({
showDebugPanel: false,
...buildSideButtonState({
@@ -1182,16 +1403,51 @@ Page({
handleToggleCenterScaleRuler() {
const nextEnabled = !this.data.showCenterScaleRuler
this.data.showCenterScaleRuler = nextEnabled
const mergedData = {
...this.data,
showCenterScaleRuler: nextEnabled,
} as MapPageData
clearCenterScaleRulerSyncTimer()
const syncRulerFromEngine = () => {
if (!mapEngine) {
return
}
const engineSnapshot = mapEngine.getInitialData() as Partial<MapPageData>
const mergedData = {
...engineSnapshot,
...this.data,
showCenterScaleRuler: nextEnabled,
} as MapPageData
this.setData({
...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, nextEnabled),
showCenterScaleRuler: nextEnabled,
...buildCenterScaleRulerPatch(mergedData),
...buildSideButtonState(mergedData),
})
}
if (!nextEnabled) {
syncRulerFromEngine()
return
}
this.setData({
showCenterScaleRuler: nextEnabled,
...buildCenterScaleRulerPatch(mergedData),
...buildSideButtonState(mergedData),
showCenterScaleRuler: true,
...buildSideButtonState({
...this.data,
showCenterScaleRuler: true,
} as MapPageData),
})
this.measureStageAndCanvas(() => {
syncRulerFromEngine()
})
centerScaleRulerSyncTimer = setTimeout(() => {
centerScaleRulerSyncTimer = 0
if (!this.data.showCenterScaleRuler) {
return
}
syncRulerFromEngine()
}, 96) as unknown as number
},
handleToggleCenterScaleRulerAnchor() {
@@ -1202,13 +1458,16 @@ Page({
const nextAnchorMode: CenterScaleRulerAnchorMode = this.data.centerScaleRulerAnchorMode === 'screen-center'
? 'compass-center'
: 'screen-center'
const engineSnapshot = mapEngine ? (mapEngine.getInitialData() as Partial<MapPageData>) : {}
this.data.centerScaleRulerAnchorMode = nextAnchorMode
const mergedData = {
...engineSnapshot,
...this.data,
centerScaleRulerAnchorMode: nextAnchorMode,
} as MapPageData
this.setData({
...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, true),
centerScaleRulerAnchorMode: nextAnchorMode,
...buildCenterScaleRulerPatch(mergedData),
...buildSideButtonState(mergedData),

View File

@@ -28,7 +28,10 @@
<view class="map-stage__map-pulse {{mapPulseFxClass}}" wx:if="{{mapPulseVisible}}" style="left: {{mapPulseLeftPx}}px; top: {{mapPulseTopPx}}px;"></view>
<view class="map-stage__stage-fx {{stageFxClass}}" wx:if="{{stageFxVisible}}"></view>
<view class="game-punch-hint" wx:if="{{punchHintText}}">{{punchHintText}}</view>
<view class="game-punch-hint" wx:if="{{showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;">
<view class="game-punch-hint__text">{{punchHintText}}</view>
<view class="game-punch-hint__close" bindtap="handleClosePunchHint">×</view>
</view>
<view class="game-punch-feedback game-punch-feedback--{{punchFeedbackTone}} {{punchFeedbackFxClass}}" wx:if="{{punchFeedbackVisible}}">{{punchFeedbackText}}</view>
<view class="game-content-card {{contentCardFxClass}}" wx:if="{{contentCardVisible}}" bindtap="handleCloseContentCard">
<view class="game-content-card__title">{{contentCardTitle}}</view>

View File

@@ -1580,18 +1580,40 @@
.game-punch-hint {
position: absolute;
left: 50%;
bottom: 280rpx;
top: 0;
transform: translateX(-50%);
max-width: 72vw;
padding: 14rpx 24rpx;
max-width: calc(100vw - 112rpx);
display: flex;
align-items: center;
gap: 12rpx;
padding: 14rpx 18rpx 14rpx 24rpx;
border-radius: 999rpx;
background: rgba(18, 33, 24, 0.78);
color: #f7fbf2;
font-size: 24rpx;
line-height: 1.2;
text-align: center;
text-align: left;
z-index: 16;
pointer-events: none;
pointer-events: auto;
}
.game-punch-hint__text {
flex: 1;
min-width: 0;
}
.game-punch-hint__close {
width: 40rpx;
height: 40rpx;
flex: 0 0 40rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
color: rgba(247, 251, 242, 0.9);
font-size: 32rpx;
line-height: 1;
background: rgba(255, 255, 255, 0.08);
}
.game-punch-feedback {