修复移动端摇杆输入失效问题,修正Rex摇杆的力值使用逻辑 重构输入互斥为计数模型,新增ui:opened事件解决纪念页穿透输入问题 修复引导箭头首帧闪现问题,设置初始透明度过渡 修复hasSave误判存档问题,新增合法景点id校验集 修复底图加载失败白屏问题,新增占位底图降级逻辑 移除无用的Phaser照片预加载,节省约65MB纹理显存 优化性能:缓存景点索引与解锁集合,跳过UI锁期间的世界更新 升级纪念页支持点击照片复用拍立得弹窗放大,新增照片降级 修复HUD指针事件不生效的CSS特异性问题 同步更新事件协议文档与输入锁模型说明
346 lines
16 KiB
Markdown
346 lines
16 KiB
Markdown
# 西安旅行记忆互动地图游戏 · 技术规格说明(细化版)
|
||
|
||
> 本文档是 `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 优先交付,减少前期装机量 |
|