Files
guoxichao b0467054c8 fix: 修复批量缺陷并优化性能与交互体验
修复移动端摇杆输入失效问题,修正Rex摇杆的力值使用逻辑
重构输入互斥为计数模型,新增ui:opened事件解决纪念页穿透输入问题
修复引导箭头首帧闪现问题,设置初始透明度过渡
修复hasSave误判存档问题,新增合法景点id校验集
修复底图加载失败白屏问题,新增占位底图降级逻辑
移除无用的Phaser照片预加载,节省约65MB纹理显存
优化性能:缓存景点索引与解锁集合,跳过UI锁期间的世界更新
升级纪念页支持点击照片复用拍立得弹窗放大,新增照片降级
修复HUD指针事件不生效的CSS特异性问题
同步更新事件协议文档与输入锁模型说明
2026-10-17 20:54:23 +08:00

346 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 西安旅行记忆互动地图游戏 · 技术规格说明(细化版)
> 本文档是 `docs/overview.md`(原始需求)经过需求澄清与设计确认后的细化规格,作为后续实现的唯一依据。
> 日期:2026-10-16 · 状态:已与需求方逐节确认
---
## 1. 项目概述
一款 2D 俯视角互动地图游戏:玩家控制 Q 版小人在手绘手账风的西安地图上漫游,走进景点触发区即弹出"拍立得照片"回忆卡。集齐全部景点后解锁"旅行完成"纪念页。
- **一句话玩法**:走地图 → 触发景点 → 翻拍立得照片 → 集齐进度 → 纪念页
- **目标平台**:手机/PC 浏览器(Web 优先交付);Android APK 为第二阶段
- **分享形态**:URL 链接即玩;后期 APK 微信分发
- **情感定位**:一本"可以走进去的旅行手账",暖色调、手写感、记忆感
---
## 2. 已确认决策记录
| # | 决策项 | 结论 | 备注 |
|---|---|---|---|
| 1 | 照片资源 | 暂未准备 | 占位图先行开发,后期按《照片替换指南》换真实照片 |
| 2 | 地图范围 | 主城区 + 经典景点 | 约 8–12 个景点,地图 3000×2100 viewBox |
| 3 | 玩法深度 | 收集进度 + 方向引导 | 屏幕边缘箭头指向最近未解锁景点 |
| 4 | 地图视觉 | 手绘旅行手账风 | 羊皮纸底色 + 棕褐线条 + 手绘图标 |
| 5 | 底图制作 | AI 素材 + SVG 路网混合 | 位置准确性归 SVG,氛围归 AI 生成素材 |
| 6 | 角色形象 | Q 版小人精灵图 | AI 生成 4 方向 × 3 帧精灵表,帧格 48×64 |
| 7 | 照片弹窗 | 拍立得照片风 | 白边相纸 + 手写描述 + 轻微旋转,多照片叠沓翻看 |
| 8 | 进度存档 | localStorage 持久化 | 可重置 |
| 9 | 开场页 | 手账风封面页 | "开始/继续旅行" + 进度概览 |
| 10 | 声音 | 暂不做 | 后期想加再加(预留 audio 目录) |
| 11 | 交付顺序 | Web 优先,APK 后置 | M1–M5 为 Web,M6 为 Capacitor 打包 |
| 12 | 架构 | Phaser 地图层 + DOM UI 层混合 | 取代原 overview.md 的纯 Phaser 场景方案 |
---
## 3. 架构设计
### 3.1 分层
```
index.html
├── #game → Phaser Canvas 层(下层):游戏世界
├── #ui → DOM + CSS UI 层(上层,pointer-events 按需开合)
└── 横切模块:event-bus.js(事件桥)、game-state.js(存档)、data/spots.js(内容配置)
```
**Phaser 层**(仅两个场景,较原方案砍掉 PhotoScene):
- `PreloadScene`:进度条 + 资源预加载,完成后自动进入 MapScene
- `MapScene`:地图渲染、相机跟随、角色精灵与动画、键盘/摇杆输入、景点触发检测(Zone overlap)、方向引导箭头渲染
**DOM UI 层**(纯 DOM 组件,零 Phaser 依赖):
- `TitleOverlay`:手账封面页(开始/继续旅行、进度概览、重新开始)
- `PhotoModal`:拍立得弹窗(照片叠沓、翻看、手写描述、关闭)
- `Hud`:收集进度徽章(如 `5/12`)+ 已解锁数量
- `CompleteOverlay`:100% 纪念页(照片九宫格 + 旅行统计 + 继续漫游)
- `dom-layer.js`:层管理器,负责显示/隐藏/互斥/覆盖
### 3.2 事件桥(EventBus)
自写轻量事件模块(约 20 行,`on/once/off/emit`)。跨层事件协议:
| 事件名 | 方向 | 载荷 | 语义 |
|---|---|---|---|
| `poi:reached` | Phaser / UI → UI | `{ spot }` | 请求弹出景点拍立得弹窗(进入触发区,或纪念页点击照片) |
| `ui:opened` | UI → Phaser | 无 | 弹窗/纪念页显示,获取一层输入锁 |
| `ui:closed` | UI → Phaser | `{ spotId? }` | 关闭,释放一层输入锁;带 `spotId` 时触发该景点解锁与冷却 |
| `state:changed` | game-state → 两层 | 进度快照 | 解锁变化,刷新 HUD 与地图标记 |
| `game:complete` | game-state → UI | 进度快照 | 全部解锁,弹出纪念页 |
### 3.3 输入互斥(弹窗打开时)
**输入锁计数模型**:MapScene 维护 `uiLocks`,封面页初始持有 1 层;每个 UI 组件
(PhotoModal / CompleteOverlay)显示时 emit `ui:opened` 加锁、关闭时 emit `ui:closed`
解锁;计数归零才恢复游戏输入。这样纪念页与其上叠加的拍立得弹窗能各自持锁,
不会因先关闭其中一层就提前放行地图输入。
双保险:
1. DOM 层全屏遮罩拦截所有触摸/点击(`pointer-events: auto`)
2. `game.input.enabled = false` 禁用 Phaser 输入(摇杆同时失效)
关闭弹窗后反向恢复。弹窗期间 MapScene 不 pause(`scene.pause` 也不再需要,简化状态机)。
### 3.4 数据流
```
spots.js(静态配置) ──┐
├→ MapScene(渲染地图/标记/触发) ──poi:reached→ PhotoModal(展示照片)
game-state.js ──state:changed──┴→ Hud(进度徽章) / PoiMarker(亮色标记) / TitleOverlay(进度概览)
↑ localStorage 读写
```
---
## 4. 游戏流程(状态流转)
```
打开页面
→ PreloadScene(加载进度条)
→ TitleOverlay(手账封面)
├─ 首次: "开始旅行"
└─ 有存档: "继续旅行 (5/12)" + "重新开始"
→ MapScene 漫游(WASD/方向键/摇杆)
├─ 进入未解锁景点触发区 → PhotoModal(拍立得, 首张)
│ → 关闭 → 解锁标记盖章动效 + HUD +1 → state:changed → 存档
├─ 进入已解锁景点触发区 → PhotoModal(重看, 不重复计数)
└─ 屏幕边缘箭头始终指向最近未解锁景点
→ 全部解锁 → CompleteOverlay(纪念页: 照片九宫格+统计)
└─ "继续漫游" → 回到地图(引导箭头消失, 标记全亮)
```
**触发防抖规则**:关闭弹窗后,玩家必须离开该景点触发区,才能再次触发该景点(防止原地连弹)。实现:记录 `activePoi` 与 `cooldown` 标记,overlap 回调中判断。
---
## 5. 数据模型
### 5.1 景点配置 `src/data/spots.js`(唯一内容源)
```js
/**
* 景点清单:坐标为 SVG 底图 viewBox 坐标系单位。
* 替换真实照片时:同名覆盖文件 + 更新 photos[].caption / story / date。
*/
export const SPOTS = [
{
id: 'dayanta', // 唯一 ID,存档键使用
name: '大雁塔',
x: 1820, y: 1450, // 地图像素坐标(SVG viewBox 单位)
radius: 70, // 触发半径
icon: '🗼', // 地图标记图标
photos: [ // 1–5 张,弹窗叠沓翻看
{ src: 'assets/photos/dayanta_01.jpg', caption: '夕阳下的大雁塔' },
],
story: '国庆第 3 天,我们在塔下等灯光亮起。',
date: '10月3日', // 拍立得右下角手写日期
},
// ... 共 8–12 个景点;M1 内置 10 个景点占位数据完整跑通,后期随真实照片增删
];
```
### 5.2 存档结构 `src/game-state.js`
- localStorage key:`xian-travel-progress`
- 结构:`{ unlocked: string[], updatedAt: number }`
- API:`loadProgress()` / `unlock(spotId)` / `resetProgress()` / `isUnlocked(spotId)` / `getSnapshot()`
- 隐私模式降级:localStorage 不可用时回退内存 Map,会话内有效,封面页不显示"继续"
### 5.3 占位资源规范
| 资源 | 占位形态 | 替换路径 |
|---|---|---|
| 照片 | 4:3 渐变 SVG 色块(每个景点不同色相,含景点名水印) | 同名覆盖 + 更新 caption |
| 底图 | 简化 SVG 路网(城墙环/主干道/水系/POI 点) | 路网不动,叠加 AI 纹理层 |
| 角色 | 程序绘制方向指示标记 | AI 精灵表替换加载 key |
| 纪念页九宫格 | 复用照片占位色块 | 随照片自动生效 |
---
## 6. 地图与美术制作管线
1. **参照层**:高德/OSM 截图仅作绘制参照,不进仓库(规避版权)
2. **SVG 路网层**(一次性人工绘制):明城墙环 + 护城河、二环、主干道网、渭河水系、POI 圆点;viewBox `0 0 3000 2100`;景点坐标在 SVG 内直接读取
3. **AI 素材层**(文生图 API 生成):纸张纹理底图、罗盘装饰、地图边框、区域淡彩;作为 Phaser 叠加 image 层
4. **角色精灵表**:AI 生成 Q 版小人,4 方向(上下左右)× 3 帧(idle/迈步),帧格 48×64,导出单张 spritesheet PNG
5. **POI 标记**:图标 + 状态样式——未解锁为灰调轮廓,已解锁为亮色 + 解锁瞬间"盖章"缩放动效(手账贴纸语义)
6. **视觉基调**:羊皮纸 `#f5ecd7` 底、棕褐 `#8a6a42` 线条、暖橙 `#e67e22` 强调色;中文字体优先 `Kaiti SC`/手写体回退
---
## 7. 玩法系统细节
### 7.1 触发
- Phaser `physics.add.overlap(player, poiZone)` 检测
- 弹窗关闭后设 `cooldown[spotId] = true`,玩家离开该 Zone 的 `overlap` 消失事件清除
- 已解锁景点重复进入仍可重看(不重复计数)
### 7.2 进度
- 解锁即 `game-state.unlock(id)` → `state:changed` → HUD 徽章 `n/total` + 标记亮色
- 达到 `total` 时额外广播 `game:complete` → CompleteOverlay
### 7.3 方向引导
- 每帧计算:玩家位置 → 最近未解锁景点向量
- 渲染屏幕边缘箭头(贴边固定距离 + 轻微浮动动画 + 箭头根部显示目标景点名标签,不显示数字距离——viewBox 单位无真实米义)
- 触发条件补充:目标已在相机视野内时箭头淡出(避免遮住玩家要看的内容)
- 全解锁后移除
### 7.4 重置
- 封面页"重新开始" → 原生 `confirm` 确认 → `resetProgress()` → 重载页面
### 7.5 纪念页
- 手账风全屏页:已解锁景点照片九宫格(点击可放大重看)+ 景点数/旅行天数统计(天数口径 = spots 配置中出现的不重复 `date` 数量)+ "继续漫游"按钮
---
## 8. UI 设计规格(DOM 层)
### 8.1 拍立得弹窗 PhotoModal
- 结构:全屏半透明遮罩 + 居中拍立得卡(白边相纸:照片区 4:3,下边加宽放手写描述)
- 旋转:卡片 `-2.5°` 随机 ±2°,投影 `0 6px 18px rgba(0,0,0,.35)`
- 多照片:向右错位叠沓(每张偏移 12px 旋转 +2°),点击当前卡片翻至下一张(CSS transform 过渡 300ms)
- 描述:手写体(`Kaiti SC` 回退),右下角日期角标
- 关闭:遮罩点击 / ESC / 空格(PC);关闭按钮常显(移动端单手区)
- 照片缺失降级:显示"照片待补"占位卡(兼作换图核对清单)
### 8.2 封面页 TitleOverlay
- 手账封面质感:纸张底纹 + 标题"西安旅行记忆" + 装饰(罗盘/胶带贴纸)
- 首次进入:单一"开始旅行"大按钮
- 有存档:显示"继续旅行 (n/12)" + 次级"重新开始"
- 进入游戏时整页翻页/淡出过渡(600ms)
### 8.3 HUD
- 右上角进度徽章:`🗺️ 5/12`,解锁时数字跳动动效
- 移动端安全区内渲染
### 8.4 竖屏提示
- 检测 `window.innerHeight > window.innerWidth` 且为触摸设备时全屏显示"请旋转手机"插画页;回到横屏自动消失
---
## 9. 移动端适配
| 项 | 方案 |
|---|---|
| 缩放 | `Phaser.Scale.FIT` + `autoCenter: CENTER_BOTH`,设计分辨率 **1600×900** |
| 屏幕方向 | Web 竖屏显示旋转提示页;M6 Capacitor 阶段锁横屏 |
| 摇杆 | `rexVirtualJoystick` 左下角,半径 60,仅触摸设备显示,安全区偏移 |
| 触控 | CSS `touch-action: none`、`user-select: none`、`viewport-fit=cover` + `env(safe-area-inset-*)` |
| 字号 | DOM UI 层 `clamp()` 响应式字号 |
| 调试 | `vite --host` 局域网真机调试 |
---
## 10. 项目结构
```
xian-travel-game/
├── public/assets/
│ ├── maps/ # map-base.svg(路网) map-texture.png(AI 纸纹) 等分层素材
│ ├── photos/ # 占位 SVG → 真实照片(景点id_序号.jpg)
│ ├── sprites/ # player.png(Q版精灵表)
│ └── audio/ # 预留(本期不实现)
├── src/
│ ├── main.js # Phaser 配置与启动(FIT/1600×900/插件注册)
│ ├── event-bus.js # 自写事件桥 on/once/off/emit
│ ├── game-state.js # localStorage 存档模块
│ ├── data/spots.js # 景点配置(唯一内容源)
│ ├── scenes/
│ │ ├── PreloadScene.js # 进度条 + 资源加载 + 失败降级
│ │ └── MapScene.js # 地图/角色/摇杆/触发/引导箭头/标记状态
│ ├── objects/
│ │ ├── Player.js # 角色移动与动画(键盘+摇杆,斜向归一化)
│ │ └── PoiMarker.js # 景点标记(图标/状态样式/盖章动效)
│ └── ui/ # DOM UI 层
│ ├── dom-layer.js # 层管理(挂载/互斥/过渡)
│ ├── TitleOverlay.js
│ ├── PhotoModal.js
│ ├── Hud.js
│ ├── CompleteOverlay.js
│ └── rotate-hint.js # 竖屏提示
├── index.html # #game + #ui 容器 + 移动端 meta
├── vite.config.js # base './' / host:true / assetsInlineLimit:0
└── package.json # phaser + phaser3-rex-plugins(Capacitor 依赖 M6 再装)
```
**编码规范**:每个文件顶部文件头注释(用途/职责/主要流程);函数级 Google 风格注释(中文);ESM 模块。
---
## 11. 错误处理
| 场景 | 处理 |
|---|---|
| 资源加载失败 | PreloadScene `load.on('loaderror')` 记录并以内联占位图降级,不中断 |
| localStorage 不可用 | game-state 内存降级;封面页隐藏"继续旅行" |
| 照片文件缺失 | PhotoModal"照片待补"占位卡 |
| resize / orientationchange | `scale.refresh()` + DOM 层重排 |
| 触发风暴 | 冷却机制(见 7.1) |
---
## 12. 测试与验收标准
| # | 验收项 | 通过标准 |
|---|---|---|
| 1 | PC 移动 | WASD/方向键八方向,斜向速度归一化不超速 |
| 2 | 触发弹窗 | 进入景点弹拍立得;ESC/空格/点击遮罩/关闭按钮可关;无原地连弹 |
| 3 | 移动端 | 摇杆可移动;弹窗可关;竖屏出现旋转提示,回横屏恢复 |
| 4 | 进度持久化 | 刷新/重开浏览器进度保留;重新开始可用并清档 |
| 5 | 引导 | 边缘箭头指向最近未解锁景点;目标进入视野时箭头淡出;全解锁后消失 |
| 6 | 纪念页 | n/n 全解锁出现;九宫格可点开放大;继续漫游可回地图 |
| 7 | 占位降级 | 断照片/断资源不白屏,显示占位卡 |
| 8 | 性能 | 首屏 < 3s(WiFi);底图 < 1.5MB;单照片 < 300KB;60fps 漫游(中端手机) |
验收方式:M1/M2 结束各跑一遍 PC + 真机清单;`npm run build` 后本地 preview 冒烟。
---
## 13. 交付路线(里程碑)
| 阶段 | 内容 | 出口标准 |
|---|---|---|
| M1 可玩核心 | 项目脚手架 + 占位地图/角色 + 移动 + 触发 + 拍立得弹窗 | 验收 1/2/7 |
| M2 完整体验 | 封面页 + 存档/进度 + 引导箭头 + 纪念页 + 摇杆 + 竖屏提示 | 验收 3/4/5/6 |
| M3 手账美化 | AI 纸纹/罗盘素材 + Q 版精灵表 + 盖章动效 + 封面精化 | 视觉走查 |
| M4 真实内容 | 照片替换(附《照片替换指南》)+ 坐标微调 | 验收 7/8 |
| M5 Web 发布 | build + Vercel/GitHub Pages(`base: './'`) | 手机打开链接可玩 |
| M6 APK(后置) | 安装 Capacitor 依赖 + init + 打包(锁横屏) | 真机安装可玩 |
> M1–M5 为本期承诺范围;M6 在 Web 版验收后单独启动。
---
## 14. 照片替换指南(后期运营,随 M4 交付)
1. **命名规范**:`public/assets/photos/<景点id>_<两位序号>.jpg`(如 `dayanta_01.jpg`)
2. **压缩规范**:宽度 ≤ 1200px,单张 ≤ 300KB,优先 WebP(文件名可保留 .jpg 后缀或统一改并同步 spots.js)
3. **配置更新**:在 `spots.js` 对应景点的 `photos[]` 增删条目、写 caption;`story`/`date` 按手账文案填写
4. **自检**:跑一遍游戏,缺失照片会显示"照片待补"占位卡,逐个补齐即可
---
## 15. 与原 overview.md 的差异说明
| 原方案 | 细化后 | 原因 |
|---|---|---|
| PhotoScene 独立场景 + scene.pause/launch | DOM 层 PhotoModal + 输入禁用 | 拍立得/中文排版/动画是 CSS 强项,Phaser UI 弱项 |
| 三场景 | 两场景(Preload/Map) | 弹窗职责外移,简化状态机 |
| 图片地图 + 手动取坐标 | SVG 路网 + viewBox 坐标 | 位置准确、可分层叠加 AI 素材、坐标在源文件中直接可读 |
| 1280×720 | 1600×900 | 地图比例 3000×2100 匹配 10:7,横屏视野更舒展 |
| 无开场/进度/引导/纪念页 | 封面页 + 进度徽章 + 边缘箭头 + 纪念页 | 需求澄清新增的游戏化决策 |
| 一次性全部依赖安装 | Capacitor 依赖 M6 再装 | Web 优先交付,减少前期装机量 |