Files
xtrip/docs/spec.md
T
Stephen a404e42ca8 feat: 初始化西安旅行记忆互动地图游戏项目
完成项目基础搭建:配置 Vite 开发构建环境,集成 Phaser 3 与虚拟摇杆插件,实现地图漫游、景点触发、拍立得弹窗、进度存档等核心功能,添加占位资源与批量生成脚本,配置项目元数据与忽略规则。
2026-10-17 18:01:46 +08:00

16 KiB
Raw Blame History

西安旅行记忆互动地图游戏 · 技术规格说明(细化版)

本文档是 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 spot 配置对象 玩家进入景点触发区,请求弹窗
ui:closed UI → Phaser 无 弹窗/封面关闭,恢复游戏输入
state:changed game-state → 两层 进度快照 解锁变化,刷新 HUD 与地图标记
game:complete game-state → UI 进度快照 全部解锁,弹出纪念页

3.3 输入互斥(弹窗打开时)

双保险:

  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(唯一内容源)

/**
 * 景点清单:坐标为 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 优先交付,减少前期装机量