From d695308a55466ec7a1bbc8eaf8c8c72b7d5758dc Mon Sep 17 00:00:00 2001 From: zhangyan Date: Thu, 26 Mar 2026 12:20:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=9C=B0=E5=9B=BE=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E4=B8=8E=E6=96=87=E6=A1=A3=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- animation-design-proposal.md | 450 ++++++++++++++++++ backend-config-management-proposal.md | 416 ++++++++++++++++ backend-config-management-v2.md | 406 ++++++++++++++++ gameplay-ideas-proposal.md | 441 +++++++++++++++++ miniprogram/engine/map/mapEngine.ts | 216 +++++++-- .../engine/sensor/compassHeadingController.ts | 4 +- miniprogram/pages/map/map.ts | 295 +++++++++++- miniprogram/pages/map/map.wxml | 5 +- miniprogram/pages/map/map.wxss | 32 +- 9 files changed, 2196 insertions(+), 69 deletions(-) create mode 100644 animation-design-proposal.md create mode 100644 backend-config-management-proposal.md create mode 100644 backend-config-management-v2.md create mode 100644 gameplay-ideas-proposal.md diff --git a/animation-design-proposal.md b/animation-design-proposal.md new file mode 100644 index 0000000..af32eea --- /dev/null +++ b/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. 当前阶段结论 + +当前项目已经具备做动画体系的基础。 +最正确的方向不是继续零散补动效,而是: + +- 先按层组织动画 +- 再按事件驱动 +- 最后再做配置化和降级 + +一句话总结: + +**后续动画建设应以“打点成功”和“目标状态”两条高频体验为起点,把动画正式纳入现有架构,而不是继续做零散样式补丁。** diff --git a/backend-config-management-proposal.md b/backend-config-management-proposal.md new file mode 100644 index 0000000..e62506e --- /dev/null +++ b/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。** + +这样才能做到: + +- 可复用 +- 可扩展 +- 可审核 +- 可回滚 +- 可稳定运行 diff --git a/backend-config-management-v2.md b/backend-config-management-v2.md new file mode 100644 index 0000000..e83153a --- /dev/null +++ b/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 中间层做“装配 + 校验 + 发布”,客户端只读静态发布结果。** diff --git a/gameplay-ideas-proposal.md b/gameplay-ideas-proposal.md new file mode 100644 index 0000000..dcd393c --- /dev/null +++ b/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. 一句话结论 + +当前这套架构已经不只是适合传统顺序赛和积分赛,也适合继续承载更游戏化、更有传播性的运动玩法。 +如果只优先选一个最值得推进的新玩法,建议先做:`幽灵追逐赛`。 diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index 7ea63b6..70f4041 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/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 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, immediateUi = false): void { diff --git a/miniprogram/engine/sensor/compassHeadingController.ts b/miniprogram/engine/sensor/compassHeadingController.ts index 87dcd57..0eb0bb5 100644 --- a/miniprogram/engine/sensor/compassHeadingController.ts +++ b/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 { } - - diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 96687b5..8d86656 100644 --- a/miniprogram/pages/map/map.ts +++ b/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([ + '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([ + 'showCenterScaleRuler', + 'centerScaleRulerAnchorMode', + 'stageWidth', + 'stageHeight', + 'topInsetHeight', + 'zoom', + 'centerTileY', + 'tileSizePx', + 'previewScale', +]) + +const RULER_ONLY_VIEW_KEYS = new Set([ + 'zoom', + 'centerTileX', + 'centerTileY', + 'tileSizePx', + 'previewScale', + 'stageWidth', + 'stageHeight', + 'stageLeft', + 'stageTop', +]) + +const SIDE_BUTTON_DEP_KEYS = new Set([ + 'sideButtonMode', + 'showGameInfoPanel', + 'showCenterScaleRuler', + 'centerScaleRulerAnchorMode', + 'skipButtonEnabled', + 'gameSessionStatus', + 'gpsLockEnabled', + 'gpsLockAvailable', +]) + +function hasAnyPatchKey(patch: Record, keys: Set): boolean { + return Object.keys(patch).some((key) => keys.has(key)) +} + +function filterDebugOnlyPatch( + patch: Partial, + includeDebugFields: boolean, + includeRulerFields: boolean, +): Partial { + if (includeDebugFields && includeRulerFields) { + return patch + } + + const filteredPatch: Partial = {} + 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)[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 - const nextData: Partial = { + const includeDebugFields = this.data.showDebugPanel + const includeRulerFields = this.data.showCenterScaleRuler + const nextData: Partial = 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 = {} + if ( + this.data.showCenterScaleRuler + && hasAnyPatchKey(nextPatch as Record, CENTER_SCALE_RULER_DEP_KEYS) + ) { + Object.assign(derivedPatch, buildCenterScaleRulerPatch(mergedData)) + } + + if (hasAnyPatchKey(nextPatch as Record, 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) => { 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 + 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) : {} 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), diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 76d2d35..b80bc67 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -28,7 +28,10 @@ - {{punchHintText}} + + {{punchHintText}} + × + {{punchFeedbackText}} {{contentCardTitle}} diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index 016a07e..89350a3 100644 --- a/miniprogram/pages/map/map.wxss +++ b/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 {