Переглянути джерело

优化地图交互与文档方案

zhangyan 1 тиждень тому
батько
коміт
d695308a55

+ 450 - 0
animation-design-proposal.md

@@ -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 - 0
backend-config-management-proposal.md

@@ -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 - 0
backend-config-management-v2.md

@@ -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 - 0
gameplay-ideas-proposal.md

@@ -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. 一句话结论
+
+当前这套架构已经不只是适合传统顺序赛和积分赛,也适合继续承载更游戏化、更有传播性的运动玩法。  
+如果只优先选一个最值得推进的新玩法,建议先做:`幽灵追逐赛`。

+ 174 - 42
miniprogram/engine/map/mapEngine.ts

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

+ 1 - 3
miniprogram/engine/sensor/compassHeadingController.ts

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

+ 277 - 18
miniprogram/pages/map/map.ts

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

+ 4 - 1
miniprogram/pages/map/map.wxml

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

+ 27 - 5
miniprogram/pages/map/map.wxss

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