# 西安旅行记忆互动地图游戏 · 技术规格说明(细化版) > 本文档是 `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 优先交付,减少前期装机量 |