优化地图交互与文档方案
This commit is contained in:
450
animation-design-proposal.md
Normal file
450
animation-design-proposal.md
Normal 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. 当前阶段结论
|
||||||
|
|
||||||
|
当前项目已经具备做动画体系的基础。
|
||||||
|
最正确的方向不是继续零散补动效,而是:
|
||||||
|
|
||||||
|
- 先按层组织动画
|
||||||
|
- 再按事件驱动
|
||||||
|
- 最后再做配置化和降级
|
||||||
|
|
||||||
|
一句话总结:
|
||||||
|
|
||||||
|
**后续动画建设应以“打点成功”和“目标状态”两条高频体验为起点,把动画正式纳入现有架构,而不是继续做零散样式补丁。**
|
||||||
416
backend-config-management-proposal.md
Normal file
416
backend-config-management-proposal.md
Normal 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。**
|
||||||
|
|
||||||
|
这样才能做到:
|
||||||
|
|
||||||
|
- 可复用
|
||||||
|
- 可扩展
|
||||||
|
- 可审核
|
||||||
|
- 可回滚
|
||||||
|
- 可稳定运行
|
||||||
406
backend-config-management-v2.md
Normal file
406
backend-config-management-v2.md
Normal 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
441
gameplay-ideas-proposal.md
Normal 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. 一句话结论
|
||||||
|
|
||||||
|
当前这套架构已经不只是适合传统顺序赛和积分赛,也适合继续承载更游戏化、更有传播性的运动玩法。
|
||||||
|
如果只优先选一个最值得推进的新玩法,建议先做:`幽灵追逐赛`。
|
||||||
@@ -55,12 +55,15 @@ const AUTO_ROTATE_EASE = 0.34
|
|||||||
const AUTO_ROTATE_SNAP_DEG = 0.1
|
const AUTO_ROTATE_SNAP_DEG = 0.1
|
||||||
const AUTO_ROTATE_DEADZONE_DEG = 4
|
const AUTO_ROTATE_DEADZONE_DEG = 4
|
||||||
const AUTO_ROTATE_MAX_STEP_DEG = 0.75
|
const AUTO_ROTATE_MAX_STEP_DEG = 0.75
|
||||||
const AUTO_ROTATE_HEADING_SMOOTHING = 0.32
|
const AUTO_ROTATE_HEADING_SMOOTHING = 0.46
|
||||||
const COMPASS_NEEDLE_SMOOTHING = 0.12
|
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_BLEND_START_SPEED_KMH = 1.2
|
||||||
const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0
|
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_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_MAX_POINTS = 200
|
||||||
const GPS_TRACK_MIN_STEP_METERS = 3
|
const GPS_TRACK_MIN_STEP_METERS = 3
|
||||||
const MAP_TAP_MOVE_THRESHOLD_PX = 14
|
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)
|
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 {
|
function formatGameSessionStatusText(status: 'idle' | 'running' | 'finished' | 'failed'): string {
|
||||||
if (status === 'running') {
|
if (status === 'running') {
|
||||||
return '进行中'
|
return '进行中'
|
||||||
@@ -705,16 +737,19 @@ export class MapEngine {
|
|||||||
autoRotateTimer: number
|
autoRotateTimer: number
|
||||||
pendingViewPatch: Partial<MapEngineViewState>
|
pendingViewPatch: Partial<MapEngineViewState>
|
||||||
mounted: boolean
|
mounted: boolean
|
||||||
|
diagnosticUiEnabled: boolean
|
||||||
northReferenceMode: NorthReferenceMode
|
northReferenceMode: NorthReferenceMode
|
||||||
sensorHeadingDeg: number | null
|
sensorHeadingDeg: number | null
|
||||||
smoothedSensorHeadingDeg: number | null
|
smoothedSensorHeadingDeg: number | null
|
||||||
compassDisplayHeadingDeg: number | null
|
compassDisplayHeadingDeg: number | null
|
||||||
|
smoothedMovementHeadingDeg: number | null
|
||||||
autoRotateHeadingDeg: number | null
|
autoRotateHeadingDeg: number | null
|
||||||
courseHeadingDeg: number | null
|
courseHeadingDeg: number | null
|
||||||
targetAutoRotationDeg: number | null
|
targetAutoRotationDeg: number | null
|
||||||
autoRotateSourceMode: AutoRotateSourceMode
|
autoRotateSourceMode: AutoRotateSourceMode
|
||||||
autoRotateCalibrationOffsetDeg: number | null
|
autoRotateCalibrationOffsetDeg: number | null
|
||||||
autoRotateCalibrationPending: boolean
|
autoRotateCalibrationPending: boolean
|
||||||
|
lastStatsUiSyncAt: number
|
||||||
minZoom: number
|
minZoom: number
|
||||||
maxZoom: number
|
maxZoom: number
|
||||||
defaultZoom: number
|
defaultZoom: number
|
||||||
@@ -776,14 +811,18 @@ export class MapEngine {
|
|||||||
y,
|
y,
|
||||||
z,
|
z,
|
||||||
})
|
})
|
||||||
|
if (this.diagnosticUiEnabled) {
|
||||||
this.setState(this.getTelemetrySensorViewPatch(), true)
|
this.setState(this.getTelemetrySensorViewPatch(), true)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (message) => {
|
onError: (message) => {
|
||||||
this.accelerometerErrorText = `不可用: ${message}`
|
this.accelerometerErrorText = `不可用: ${message}`
|
||||||
|
if (this.diagnosticUiEnabled) {
|
||||||
this.setState({
|
this.setState({
|
||||||
...this.getTelemetrySensorViewPatch(),
|
...this.getTelemetrySensorViewPatch(),
|
||||||
statusText: `加速度计启动失败 (${this.buildVersion})`,
|
statusText: `加速度计启动失败 (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.compassController = new CompassHeadingController({
|
this.compassController = new CompassHeadingController({
|
||||||
@@ -803,10 +842,14 @@ export class MapEngine {
|
|||||||
y,
|
y,
|
||||||
z,
|
z,
|
||||||
})
|
})
|
||||||
|
if (this.diagnosticUiEnabled) {
|
||||||
this.setState(this.getTelemetrySensorViewPatch(), true)
|
this.setState(this.getTelemetrySensorViewPatch(), true)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
if (this.diagnosticUiEnabled) {
|
||||||
this.setState(this.getTelemetrySensorViewPatch(), true)
|
this.setState(this.getTelemetrySensorViewPatch(), true)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.deviceMotionController = new DeviceMotionController({
|
this.deviceMotionController = new DeviceMotionController({
|
||||||
@@ -818,17 +861,21 @@ export class MapEngine {
|
|||||||
beta,
|
beta,
|
||||||
gamma,
|
gamma,
|
||||||
})
|
})
|
||||||
|
if (this.diagnosticUiEnabled) {
|
||||||
this.setState({
|
this.setState({
|
||||||
...this.getTelemetrySensorViewPatch(),
|
...this.getTelemetrySensorViewPatch(),
|
||||||
autoRotateSourceText: this.getAutoRotateSourceText(),
|
autoRotateSourceText: this.getAutoRotateSourceText(),
|
||||||
}, true)
|
}, true)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
||||||
this.scheduleAutoRotate()
|
this.scheduleAutoRotate()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
if (this.diagnosticUiEnabled) {
|
||||||
this.setState(this.getTelemetrySensorViewPatch(), true)
|
this.setState(this.getTelemetrySensorViewPatch(), true)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.locationController = new LocationController({
|
this.locationController = new LocationController({
|
||||||
@@ -840,7 +887,7 @@ export class MapEngine {
|
|||||||
gpsTracking: this.locationController.listening,
|
gpsTracking: this.locationController.listening,
|
||||||
gpsTrackingText: message,
|
gpsTrackingText: message,
|
||||||
...this.getLocationControllerViewPatch(),
|
...this.getLocationControllerViewPatch(),
|
||||||
}, true)
|
})
|
||||||
},
|
},
|
||||||
onError: (message) => {
|
onError: (message) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -848,10 +895,12 @@ export class MapEngine {
|
|||||||
gpsTrackingText: message,
|
gpsTrackingText: message,
|
||||||
...this.getLocationControllerViewPatch(),
|
...this.getLocationControllerViewPatch(),
|
||||||
statusText: `${message} (${this.buildVersion})`,
|
statusText: `${message} (${this.buildVersion})`,
|
||||||
}, true)
|
})
|
||||||
},
|
},
|
||||||
onDebugStateChange: () => {
|
onDebugStateChange: () => {
|
||||||
|
if (this.diagnosticUiEnabled) {
|
||||||
this.setState(this.getLocationControllerViewPatch(), true)
|
this.setState(this.getLocationControllerViewPatch(), true)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.heartRateController = new HeartRateInputController({
|
this.heartRateController = new HeartRateInputController({
|
||||||
@@ -872,7 +921,7 @@ export class MapEngine {
|
|||||||
heartRateDeviceText: deviceName,
|
heartRateDeviceText: deviceName,
|
||||||
heartRateScanText: this.getHeartRateScanText(),
|
heartRateScanText: this.getHeartRateScanText(),
|
||||||
...this.getHeartRateControllerViewPatch(),
|
...this.getHeartRateControllerViewPatch(),
|
||||||
}, true)
|
})
|
||||||
},
|
},
|
||||||
onError: (message) => {
|
onError: (message) => {
|
||||||
this.clearHeartRateSignal()
|
this.clearHeartRateSignal()
|
||||||
@@ -886,7 +935,7 @@ export class MapEngine {
|
|||||||
heartRateScanText: this.getHeartRateScanText(),
|
heartRateScanText: this.getHeartRateScanText(),
|
||||||
...this.getHeartRateControllerViewPatch(),
|
...this.getHeartRateControllerViewPatch(),
|
||||||
statusText: `${message} (${this.buildVersion})`,
|
statusText: `${message} (${this.buildVersion})`,
|
||||||
}, true)
|
})
|
||||||
},
|
},
|
||||||
onConnectionChange: (connected, deviceName) => {
|
onConnectionChange: (connected, deviceName) => {
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
@@ -906,17 +955,21 @@ export class MapEngine {
|
|||||||
heartRateScanText: this.getHeartRateScanText(),
|
heartRateScanText: this.getHeartRateScanText(),
|
||||||
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
|
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
|
||||||
...this.getHeartRateControllerViewPatch(),
|
...this.getHeartRateControllerViewPatch(),
|
||||||
}, true)
|
})
|
||||||
},
|
},
|
||||||
onDeviceListChange: (devices) => {
|
onDeviceListChange: (devices) => {
|
||||||
|
if (this.diagnosticUiEnabled) {
|
||||||
this.setState({
|
this.setState({
|
||||||
heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
|
heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
|
||||||
heartRateScanText: this.getHeartRateScanText(),
|
heartRateScanText: this.getHeartRateScanText(),
|
||||||
...this.getHeartRateControllerViewPatch(),
|
...this.getHeartRateControllerViewPatch(),
|
||||||
}, true)
|
}, true)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDebugStateChange: () => {
|
onDebugStateChange: () => {
|
||||||
|
if (this.diagnosticUiEnabled) {
|
||||||
this.setState(this.getHeartRateControllerViewPatch(), true)
|
this.setState(this.getHeartRateControllerViewPatch(), true)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.feedbackDirector = new FeedbackDirector({
|
this.feedbackDirector = new FeedbackDirector({
|
||||||
@@ -1119,22 +1172,53 @@ export class MapEngine {
|
|||||||
this.autoRotateTimer = 0
|
this.autoRotateTimer = 0
|
||||||
this.pendingViewPatch = {}
|
this.pendingViewPatch = {}
|
||||||
this.mounted = false
|
this.mounted = false
|
||||||
|
this.diagnosticUiEnabled = false
|
||||||
this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
|
this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
|
||||||
this.sensorHeadingDeg = null
|
this.sensorHeadingDeg = null
|
||||||
this.smoothedSensorHeadingDeg = null
|
this.smoothedSensorHeadingDeg = null
|
||||||
this.compassDisplayHeadingDeg = null
|
this.compassDisplayHeadingDeg = null
|
||||||
|
this.smoothedMovementHeadingDeg = null
|
||||||
this.autoRotateHeadingDeg = null
|
this.autoRotateHeadingDeg = null
|
||||||
this.courseHeadingDeg = null
|
this.courseHeadingDeg = null
|
||||||
this.targetAutoRotationDeg = null
|
this.targetAutoRotationDeg = null
|
||||||
this.autoRotateSourceMode = 'smart'
|
this.autoRotateSourceMode = 'smart'
|
||||||
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
|
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
|
||||||
this.autoRotateCalibrationPending = false
|
this.autoRotateCalibrationPending = false
|
||||||
|
this.lastStatsUiSyncAt = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
getInitialData(): MapEngineViewState {
|
getInitialData(): MapEngineViewState {
|
||||||
return { ...this.state }
|
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 {
|
getGameInfoSnapshot(): MapEngineGameInfoSnapshot {
|
||||||
const definition = this.gameRuntime.definition
|
const definition = this.gameRuntime.definition
|
||||||
const sessionState = this.gameRuntime.state
|
const sessionState = this.gameRuntime.state
|
||||||
@@ -1253,12 +1337,14 @@ export class MapEngine {
|
|||||||
this.currentGpsTrack = []
|
this.currentGpsTrack = []
|
||||||
this.currentGpsAccuracyMeters = null
|
this.currentGpsAccuracyMeters = null
|
||||||
this.currentGpsInsideMap = false
|
this.currentGpsInsideMap = false
|
||||||
|
this.smoothedMovementHeadingDeg = null
|
||||||
this.courseOverlayVisible = false
|
this.courseOverlayVisible = false
|
||||||
this.setCourseHeading(null)
|
this.setCourseHeading(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
clearStartSessionResidue(): void {
|
clearStartSessionResidue(): void {
|
||||||
this.currentGpsTrack = []
|
this.currentGpsTrack = []
|
||||||
|
this.smoothedMovementHeadingDeg = null
|
||||||
this.courseOverlayVisible = false
|
this.courseOverlayVisible = false
|
||||||
this.setCourseHeading(null)
|
this.setCourseHeading(null)
|
||||||
}
|
}
|
||||||
@@ -1534,7 +1620,7 @@ export class MapEngine {
|
|||||||
panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
|
panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
|
||||||
panelAccuracyValueText: telemetryPresentation.accuracyValueText,
|
panelAccuracyValueText: telemetryPresentation.accuracyValueText,
|
||||||
panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
|
panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
|
||||||
}, true)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSessionTimerLoop(): void {
|
updateSessionTimerLoop(): void {
|
||||||
@@ -1798,6 +1884,7 @@ export class MapEngine {
|
|||||||
|
|
||||||
this.currentGpsPoint = nextPoint
|
this.currentGpsPoint = nextPoint
|
||||||
this.currentGpsAccuracyMeters = accuracyMeters
|
this.currentGpsAccuracyMeters = accuracyMeters
|
||||||
|
this.updateMovementHeadingDeg()
|
||||||
|
|
||||||
const gpsWorldPoint = lonLatToWorldTile(nextPoint, this.state.zoom)
|
const gpsWorldPoint = lonLatToWorldTile(nextPoint, this.state.zoom)
|
||||||
const gpsTileX = Math.floor(gpsWorldPoint.x)
|
const gpsTileX = Math.floor(gpsWorldPoint.x)
|
||||||
@@ -2167,7 +2254,7 @@ export class MapEngine {
|
|||||||
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
||||||
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||||||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||||||
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
|
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
|
||||||
...this.getGameViewPatch(gameStatusText),
|
...this.getGameViewPatch(gameStatusText),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2683,18 +2770,26 @@ export class MapEngine {
|
|||||||
const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
|
const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
|
||||||
this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null
|
this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null
|
||||||
? compassHeadingDeg
|
? compassHeadingDeg
|
||||||
: interpolateAngleDeg(this.compassDisplayHeadingDeg, compassHeadingDeg, COMPASS_NEEDLE_SMOOTHING)
|
: interpolateAngleDeg(
|
||||||
|
this.compassDisplayHeadingDeg,
|
||||||
|
compassHeadingDeg,
|
||||||
|
getCompassNeedleSmoothingFactor(this.compassDisplayHeadingDeg, compassHeadingDeg),
|
||||||
|
)
|
||||||
|
|
||||||
this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
|
this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
|
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
|
||||||
|
...(this.diagnosticUiEnabled
|
||||||
|
? {
|
||||||
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
||||||
...this.getTelemetrySensorViewPatch(),
|
...this.getTelemetrySensorViewPatch(),
|
||||||
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
||||||
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||||||
autoRotateSourceText: this.getAutoRotateSourceText(),
|
autoRotateSourceText: this.getAutoRotateSourceText(),
|
||||||
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
|
|
||||||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!this.refreshAutoRotateTarget()) {
|
if (!this.refreshAutoRotateTarget()) {
|
||||||
@@ -2740,7 +2835,7 @@ export class MapEngine {
|
|||||||
...this.getTelemetrySensorViewPatch(),
|
...this.getTelemetrySensorViewPatch(),
|
||||||
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
||||||
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
||||||
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
|
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
|
||||||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
|
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
|
||||||
},
|
},
|
||||||
`${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
|
`${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
|
||||||
@@ -2759,7 +2854,7 @@ export class MapEngine {
|
|||||||
...this.getTelemetrySensorViewPatch(),
|
...this.getTelemetrySensorViewPatch(),
|
||||||
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
||||||
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
||||||
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
|
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
|
||||||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
|
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
|
||||||
statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
|
statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
@@ -2780,7 +2875,7 @@ export class MapEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getMovementHeadingDeg(): number | null {
|
getRawMovementHeadingDeg(): number | null {
|
||||||
if (!this.currentGpsInsideMap) {
|
if (!this.currentGpsInsideMap) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -2809,6 +2904,23 @@ export class MapEngine {
|
|||||||
return null
|
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 {
|
getPreferredSensorHeadingDeg(): number | null {
|
||||||
return this.smoothedSensorHeadingDeg === null
|
return this.smoothedSensorHeadingDeg === null
|
||||||
? null
|
? null
|
||||||
@@ -2959,7 +3071,7 @@ export class MapEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyStats(stats: MapRendererStats): void {
|
applyStats(stats: MapRendererStats): void {
|
||||||
this.setState({
|
const statsPatch = {
|
||||||
visibleTileCount: stats.visibleTileCount,
|
visibleTileCount: stats.visibleTileCount,
|
||||||
readyTileCount: stats.readyTileCount,
|
readyTileCount: stats.readyTileCount,
|
||||||
memoryTileCount: stats.memoryTileCount,
|
memoryTileCount: stats.memoryTileCount,
|
||||||
@@ -2968,7 +3080,27 @@ export class MapEngine {
|
|||||||
diskHitCount: stats.diskHitCount,
|
diskHitCount: stats.diskHitCount,
|
||||||
networkFetchCount: stats.networkFetchCount,
|
networkFetchCount: stats.networkFetchCount,
|
||||||
cacheHitRateText: formatCacheHitRate(stats.memoryHitCount, stats.diskHitCount, 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 {
|
setState(patch: Partial<MapEngineViewState>, immediateUi = false): void {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export interface CompassHeadingControllerCallbacks {
|
|||||||
|
|
||||||
type SensorSource = 'compass' | 'motion' | null
|
type SensorSource = 'compass' | 'motion' | null
|
||||||
|
|
||||||
const ABSOLUTE_HEADING_CORRECTION = 0.24
|
const ABSOLUTE_HEADING_CORRECTION = 0.44
|
||||||
|
|
||||||
function normalizeHeadingDeg(headingDeg: number): number {
|
function normalizeHeadingDeg(headingDeg: number): number {
|
||||||
const normalized = headingDeg % 360
|
const normalized = headingDeg % 360
|
||||||
@@ -202,5 +202,3 @@ export class CompassHeadingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type MapPageData = MapEngineViewState & {
|
|||||||
showDebugPanel: boolean
|
showDebugPanel: boolean
|
||||||
showGameInfoPanel: boolean
|
showGameInfoPanel: boolean
|
||||||
showCenterScaleRuler: boolean
|
showCenterScaleRuler: boolean
|
||||||
|
showPunchHintBanner: boolean
|
||||||
centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode
|
centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode
|
||||||
statusBarHeight: number
|
statusBarHeight: number
|
||||||
topInsetHeight: number
|
topInsetHeight: number
|
||||||
@@ -74,11 +75,150 @@ type MapPageData = MapEngineViewState & {
|
|||||||
showRightButtonGroups: boolean
|
showRightButtonGroups: boolean
|
||||||
showBottomDebugButton: 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 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 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 mapEngine: MapEngine | null = null
|
||||||
let stageCanvasAttached = false
|
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) {
|
function buildSideButtonVisibility(mode: SideButtonMode) {
|
||||||
return {
|
return {
|
||||||
sideButtonMode: mode,
|
sideButtonMode: mode,
|
||||||
@@ -389,6 +529,7 @@ Page({
|
|||||||
panelDistanceValueText: '--',
|
panelDistanceValueText: '--',
|
||||||
panelDistanceUnitText: '',
|
panelDistanceUnitText: '',
|
||||||
panelProgressText: '0/0',
|
panelProgressText: '0/0',
|
||||||
|
showPunchHintBanner: true,
|
||||||
gameSessionStatus: 'idle',
|
gameSessionStatus: 'idle',
|
||||||
gameModeText: '顺序赛',
|
gameModeText: '顺序赛',
|
||||||
gpsLockEnabled: false,
|
gpsLockEnabled: false,
|
||||||
@@ -488,9 +629,11 @@ Page({
|
|||||||
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
|
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
|
||||||
onData: (patch) => {
|
onData: (patch) => {
|
||||||
const nextPatch = patch as Partial<MapPageData>
|
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,
|
...nextPatch,
|
||||||
}
|
}, includeDebugFields, includeRulerFields)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof nextPatch.mockBridgeUrlText === 'string'
|
typeof nextPatch.mockBridgeUrlText === 'string'
|
||||||
@@ -511,18 +654,52 @@ Page({
|
|||||||
...nextData,
|
...nextData,
|
||||||
} as MapPageData
|
} as MapPageData
|
||||||
|
|
||||||
|
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({
|
this.setData({
|
||||||
...nextData,
|
...nextData,
|
||||||
...buildCenterScaleRulerPatch(mergedData),
|
...derivedPatch,
|
||||||
...buildSideButtonState(mergedData),
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (this.data.showGameInfoPanel) {
|
if (this.data.showGameInfoPanel) {
|
||||||
this.syncGameInfoPanelSnapshot()
|
this.scheduleGameInfoPanelSnapshotSync()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mapEngine.setDiagnosticUiEnabled(false)
|
||||||
|
|
||||||
this.setData({
|
this.setData({
|
||||||
...mapEngine.getInitialData(),
|
...mapEngine.getInitialData(),
|
||||||
showDebugPanel: false,
|
showDebugPanel: false,
|
||||||
@@ -542,6 +719,7 @@ Page({
|
|||||||
panelDistanceValueText: '--',
|
panelDistanceValueText: '--',
|
||||||
panelDistanceUnitText: '',
|
panelDistanceUnitText: '',
|
||||||
panelProgressText: '0/0',
|
panelProgressText: '0/0',
|
||||||
|
showPunchHintBanner: true,
|
||||||
gameSessionStatus: 'idle',
|
gameSessionStatus: 'idle',
|
||||||
gameModeText: '顺序赛',
|
gameModeText: '顺序赛',
|
||||||
gpsLockEnabled: false,
|
gpsLockEnabled: false,
|
||||||
@@ -647,6 +825,9 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onUnload() {
|
onUnload() {
|
||||||
|
clearGameInfoPanelSyncTimer()
|
||||||
|
clearCenterScaleRulerSyncTimer()
|
||||||
|
clearPunchHintDismissTimer()
|
||||||
if (mapEngine) {
|
if (mapEngine) {
|
||||||
mapEngine.destroy()
|
mapEngine.destroy()
|
||||||
mapEngine = null
|
mapEngine = null
|
||||||
@@ -686,7 +867,7 @@ Page({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
measureStageAndCanvas() {
|
measureStageAndCanvas(onApplied?: () => void) {
|
||||||
const page = this
|
const page = this
|
||||||
const applyStage = (rawRect?: Partial<WechatMiniprogram.BoundingClientRectCallbackResult>) => {
|
const applyStage = (rawRect?: Partial<WechatMiniprogram.BoundingClientRectCallbackResult>) => {
|
||||||
const fallbackRect = getFallbackStageRect()
|
const fallbackRect = getFallbackStageRect()
|
||||||
@@ -703,6 +884,9 @@ Page({
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentEngine.setStage(rect)
|
currentEngine.setStage(rect)
|
||||||
|
if (onApplied) {
|
||||||
|
onApplied()
|
||||||
|
}
|
||||||
|
|
||||||
if (stageCanvasAttached) {
|
if (stageCanvasAttached) {
|
||||||
return
|
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() {
|
handleOpenGameInfoPanel() {
|
||||||
|
clearGameInfoPanelSyncTimer()
|
||||||
this.syncGameInfoPanelSnapshot()
|
this.syncGameInfoPanelSnapshot()
|
||||||
this.setData({
|
this.setData({
|
||||||
showDebugPanel: false,
|
showDebugPanel: false,
|
||||||
@@ -1072,6 +1275,7 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleCloseGameInfoPanel() {
|
handleCloseGameInfoPanel() {
|
||||||
|
clearGameInfoPanelSyncTimer()
|
||||||
this.setData({
|
this.setData({
|
||||||
showGameInfoPanel: false,
|
showGameInfoPanel: false,
|
||||||
...buildSideButtonState({
|
...buildSideButtonState({
|
||||||
@@ -1107,6 +1311,13 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleClosePunchHint() {
|
||||||
|
clearPunchHintDismissTimer()
|
||||||
|
this.setData({
|
||||||
|
showPunchHintBanner: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
handleHudPanelChange(event: WechatMiniprogram.CustomEvent<{ current: number }>) {
|
handleHudPanelChange(event: WechatMiniprogram.CustomEvent<{ current: number }>) {
|
||||||
this.setData({
|
this.setData({
|
||||||
hudPanelIndex: event.detail.current || 0,
|
hudPanelIndex: event.detail.current || 0,
|
||||||
@@ -1147,8 +1358,15 @@ Page({
|
|||||||
mapEngine.handleSetHeadingUpMode()
|
mapEngine.handleSetHeadingUpMode()
|
||||||
},
|
},
|
||||||
handleToggleDebugPanel() {
|
handleToggleDebugPanel() {
|
||||||
|
const nextShowDebugPanel = !this.data.showDebugPanel
|
||||||
|
if (!nextShowDebugPanel) {
|
||||||
|
clearGameInfoPanelSyncTimer()
|
||||||
|
}
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.setDiagnosticUiEnabled(nextShowDebugPanel)
|
||||||
|
}
|
||||||
this.setData({
|
this.setData({
|
||||||
showDebugPanel: !this.data.showDebugPanel,
|
showDebugPanel: nextShowDebugPanel,
|
||||||
showGameInfoPanel: false,
|
showGameInfoPanel: false,
|
||||||
...buildSideButtonState({
|
...buildSideButtonState({
|
||||||
sideButtonMode: this.data.sideButtonMode,
|
sideButtonMode: this.data.sideButtonMode,
|
||||||
@@ -1164,6 +1382,9 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleCloseDebugPanel() {
|
handleCloseDebugPanel() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.setDiagnosticUiEnabled(false)
|
||||||
|
}
|
||||||
this.setData({
|
this.setData({
|
||||||
showDebugPanel: false,
|
showDebugPanel: false,
|
||||||
...buildSideButtonState({
|
...buildSideButtonState({
|
||||||
@@ -1182,16 +1403,51 @@ Page({
|
|||||||
handleToggleCenterScaleRuler() {
|
handleToggleCenterScaleRuler() {
|
||||||
const nextEnabled = !this.data.showCenterScaleRuler
|
const nextEnabled = !this.data.showCenterScaleRuler
|
||||||
this.data.showCenterScaleRuler = nextEnabled
|
this.data.showCenterScaleRuler = nextEnabled
|
||||||
|
clearCenterScaleRulerSyncTimer()
|
||||||
|
|
||||||
|
const syncRulerFromEngine = () => {
|
||||||
|
if (!mapEngine) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const engineSnapshot = mapEngine.getInitialData() as Partial<MapPageData>
|
||||||
const mergedData = {
|
const mergedData = {
|
||||||
|
...engineSnapshot,
|
||||||
...this.data,
|
...this.data,
|
||||||
showCenterScaleRuler: nextEnabled,
|
showCenterScaleRuler: nextEnabled,
|
||||||
} as MapPageData
|
} as MapPageData
|
||||||
|
|
||||||
this.setData({
|
this.setData({
|
||||||
|
...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, nextEnabled),
|
||||||
showCenterScaleRuler: nextEnabled,
|
showCenterScaleRuler: nextEnabled,
|
||||||
...buildCenterScaleRulerPatch(mergedData),
|
...buildCenterScaleRulerPatch(mergedData),
|
||||||
...buildSideButtonState(mergedData),
|
...buildSideButtonState(mergedData),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextEnabled) {
|
||||||
|
syncRulerFromEngine()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
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() {
|
handleToggleCenterScaleRulerAnchor() {
|
||||||
@@ -1202,13 +1458,16 @@ Page({
|
|||||||
const nextAnchorMode: CenterScaleRulerAnchorMode = this.data.centerScaleRulerAnchorMode === 'screen-center'
|
const nextAnchorMode: CenterScaleRulerAnchorMode = this.data.centerScaleRulerAnchorMode === 'screen-center'
|
||||||
? 'compass-center'
|
? 'compass-center'
|
||||||
: 'screen-center'
|
: 'screen-center'
|
||||||
|
const engineSnapshot = mapEngine ? (mapEngine.getInitialData() as Partial<MapPageData>) : {}
|
||||||
this.data.centerScaleRulerAnchorMode = nextAnchorMode
|
this.data.centerScaleRulerAnchorMode = nextAnchorMode
|
||||||
const mergedData = {
|
const mergedData = {
|
||||||
|
...engineSnapshot,
|
||||||
...this.data,
|
...this.data,
|
||||||
centerScaleRulerAnchorMode: nextAnchorMode,
|
centerScaleRulerAnchorMode: nextAnchorMode,
|
||||||
} as MapPageData
|
} as MapPageData
|
||||||
|
|
||||||
this.setData({
|
this.setData({
|
||||||
|
...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, true),
|
||||||
centerScaleRulerAnchorMode: nextAnchorMode,
|
centerScaleRulerAnchorMode: nextAnchorMode,
|
||||||
...buildCenterScaleRulerPatch(mergedData),
|
...buildCenterScaleRulerPatch(mergedData),
|
||||||
...buildSideButtonState(mergedData),
|
...buildSideButtonState(mergedData),
|
||||||
|
|||||||
@@ -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__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="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-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 {{contentCardFxClass}}" wx:if="{{contentCardVisible}}" bindtap="handleCloseContentCard">
|
||||||
<view class="game-content-card__title">{{contentCardTitle}}</view>
|
<view class="game-content-card__title">{{contentCardTitle}}</view>
|
||||||
|
|||||||
@@ -1580,18 +1580,40 @@
|
|||||||
.game-punch-hint {
|
.game-punch-hint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: 280rpx;
|
top: 0;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
max-width: 72vw;
|
max-width: calc(100vw - 112rpx);
|
||||||
padding: 14rpx 24rpx;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 14rpx 18rpx 14rpx 24rpx;
|
||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
background: rgba(18, 33, 24, 0.78);
|
background: rgba(18, 33, 24, 0.78);
|
||||||
color: #f7fbf2;
|
color: #f7fbf2;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
z-index: 16;
|
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 {
|
.game-punch-feedback {
|
||||||
|
|||||||
Reference in New Issue
Block a user