feat: 初始化西安旅行记忆互动地图游戏项目
完成项目基础搭建:配置 Vite 开发构建环境,集成 Phaser 3 与虚拟摇杆插件,实现地图漫游、景点触发、拍立得弹窗、进度存档等核心功能,添加占位资源与批量生成脚本,配置项目元数据与忽略规则。
This commit is contained in:
commit
a404e42ca8
42 files changed
+5489
No files matched your search
@@ -0,0 +1,612 @@
|
||||
# 西安旅行记忆互动地图游戏 — AI 开发指导文档
|
||||
|
||||
> **技术栈**:Phaser 3 + Vite + Capacitor
|
||||
> **目标平台**:PC 浏览器、Android App
|
||||
> **核心玩法**:玩家控制角色在西安旅游地图上移动,抵达景点触发照片弹窗
|
||||
> **文档用途**:可直接作为 AI 编程工具(Cursor / Copilot / Claude Code 等)的项目开发规格说明
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
开发一款 2D 俯视角互动地图游戏,将国庆期间在西安拍摄的照片与地图景点绑定。玩家通过键盘(PC)或虚拟摇杆(移动端)控制角色移动,当角色进入景点触发区域时,弹出对应照片和描述文字。项目需支持跨平台运行,并方便通过链接或 APK 分享。
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术选型
|
||||
|
||||
| 模块 | 技术 | 说明 |
|
||||
|---|---|---|
|
||||
| 游戏引擎 | Phaser 3 | HTML5 2D 游戏框架,内置物理、输入、场景管理 |
|
||||
| 构建工具 | Vite | 快速开发与构建,支持 ES 模块 |
|
||||
| 移动端打包 | Capacitor | 将 Web 应用包装为原生 Android/iOS 应用 |
|
||||
| UI 插件 | phaser3-rex-plugins | 提供对话框、虚拟摇杆等组件 |
|
||||
| 地图编辑(可选) | Tiled Map Editor | 导出 JSON 瓦片地图,Phaser 直接加载 |
|
||||
| 部署托管 | GitHub Pages / Vercel / Netlify | 免费静态托管,支持 HTTPS |
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目结构
|
||||
|
||||
```
|
||||
xian-travel-game/
|
||||
├── public/
|
||||
│ ├── assets/
|
||||
│ │ ├── maps/ # 西安地图图片(xian-map.png)
|
||||
│ │ ├── photos/ # 景点照片(按景点命名)
|
||||
│ │ ├── sprites/ # 角色精灵图(player.png)
|
||||
│ │ └── audio/ # 背景音乐/音效(可选)
|
||||
│ └── index.html
|
||||
├── src/
|
||||
│ ├── main.js # 游戏入口与全局配置
|
||||
│ ├── scenes/
|
||||
│ │ ├── BootScene.js # 资源预加载
|
||||
│ │ ├── MapScene.js # 地图主场景
|
||||
│ │ └── PhotoScene.js # 照片弹窗场景
|
||||
│ ├── objects/
|
||||
│ │ ├── Player.js # 玩家角色类
|
||||
│ │ └── POI.js # 景点触发点类(可选)
|
||||
│ └── data/
|
||||
│ └── spots.js # 景点坐标、照片、描述配置
|
||||
├── capacitor.config.ts
|
||||
├── vite.config.js
|
||||
├── package.json
|
||||
└── index.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 环境搭建与依赖安装
|
||||
|
||||
```bash
|
||||
# 创建 Vite 项目
|
||||
npm create vite@latest xian-travel-game -- --template vanilla
|
||||
cd xian-travel-game
|
||||
|
||||
# 安装 Phaser 与 RexUI 插件
|
||||
npm install phaser phaser3-rex-plugins
|
||||
|
||||
# 安装 Capacitor(用于 Android 打包)
|
||||
npm install @capacitor/core @capacitor/cli @capacitor/android
|
||||
|
||||
# 初始化 Capacitor
|
||||
npx cap init
|
||||
```
|
||||
|
||||
`vite.config.js` 基础配置:
|
||||
|
||||
```js
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
base: './', // 相对路径,便于部署到子目录
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsInlineLimit: 0 // 避免大图片被内联
|
||||
},
|
||||
server: {
|
||||
host: true, // 允许局域网访问,方便手机调试
|
||||
port: 3000
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 核心代码实现
|
||||
|
||||
### 5.1 入口与全局配置(`src/main.js`)
|
||||
|
||||
```js
|
||||
import Phaser from 'phaser';
|
||||
import RexUIPlugin from 'phaser3-rex-plugins/templates/ui/ui-plugin.js';
|
||||
import VirtualJoystickPlugin from 'phaser3-rex-plugins/plugins/virtualjoystick-plugin.js';
|
||||
|
||||
import BootScene from './scenes/BootScene.js';
|
||||
import MapScene from './scenes/MapScene.js';
|
||||
import PhotoScene from './scenes/PhotoScene.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT, // 自适应屏幕,保持宽高比
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
parent: 'game-container'
|
||||
},
|
||||
physics: {
|
||||
default: 'arcade',
|
||||
arcade: {
|
||||
gravity: { y: 0 },
|
||||
debug: false
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
scene: [
|
||||
{ key: 'rexUI', plugin: RexUIPlugin, mapping: 'rexUI' },
|
||||
{ key: 'rexVirtualJoystick', plugin: VirtualJoystickPlugin, mapping: 'rexVirtualJoystick' }
|
||||
]
|
||||
},
|
||||
scene: [BootScene, MapScene, PhotoScene]
|
||||
};
|
||||
|
||||
new Phaser.Game(config);
|
||||
```
|
||||
|
||||
`index.html` 需包含游戏容器和移动端 meta:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0,
|
||||
maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>西安旅行记忆</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none; /* 禁止触摸滚动 */
|
||||
-webkit-user-select: none;
|
||||
background: #000;
|
||||
}
|
||||
#game-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-container"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 5.2 资源加载(`src/scenes/BootScene.js`)
|
||||
|
||||
```js
|
||||
import Phaser from 'phaser';
|
||||
import { SPOTS } from '../data/spots.js';
|
||||
|
||||
export default class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'BootScene' });
|
||||
}
|
||||
|
||||
preload() {
|
||||
// 显示加载进度
|
||||
const width = this.scale.width;
|
||||
const height = this.scale.height;
|
||||
const progressBar = this.add.graphics();
|
||||
const progressBox = this.add.graphics();
|
||||
progressBox.fillStyle(0x222222, 0.8);
|
||||
progressBox.fillRect(width / 2 - 160, height / 2 - 25, 320, 50);
|
||||
|
||||
this.load.on('progress', (value) => {
|
||||
progressBar.clear();
|
||||
progressBar.fillStyle(0xffffff, 1);
|
||||
progressBar.fillRect(width / 2 - 150, height / 2 - 15, 300 * value, 30);
|
||||
});
|
||||
|
||||
// 加载地图
|
||||
this.load.image('xianMap', 'assets/maps/xian-map.png');
|
||||
|
||||
// 加载角色精灵图(假设为 4 方向行走图,每帧 32x48)
|
||||
this.load.spritesheet('player', 'assets/sprites/player.png', {
|
||||
frameWidth: 32,
|
||||
frameHeight: 48
|
||||
});
|
||||
|
||||
// 加载所有景点照片
|
||||
SPOTS.forEach(spot => {
|
||||
spot.photos.forEach((photoPath, index) => {
|
||||
this.load.image(`${spot.id}_${index}`, photoPath);
|
||||
});
|
||||
});
|
||||
|
||||
// 可选:加载背景音乐
|
||||
// this.load.audio('bgm', 'assets/audio/bgm.mp3');
|
||||
}
|
||||
|
||||
create() {
|
||||
// 创建角色动画(示例:向下走)
|
||||
this.anims.create({
|
||||
key: 'walk-down',
|
||||
frames: this.anims.generateFrameNumbers('player', { start: 0, end: 2 }),
|
||||
frameRate: 8,
|
||||
repeat: -1
|
||||
});
|
||||
// 其他方向动画按需添加...
|
||||
|
||||
this.scene.start('MapScene');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 地图主场景(`src/scenes/MapScene.js`)
|
||||
|
||||
```js
|
||||
import Phaser from 'phaser';
|
||||
import Player from '../objects/Player.js';
|
||||
import { SPOTS } from '../data/spots.js';
|
||||
|
||||
export default class MapScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'MapScene' });
|
||||
}
|
||||
|
||||
create() {
|
||||
// 1. 添加地图背景
|
||||
this.map = this.add.image(0, 0, 'xianMap').setOrigin(0, 0);
|
||||
|
||||
// 2. 设置物理世界边界为地图尺寸
|
||||
this.physics.world.setBounds(0, 0, this.map.width, this.map.height);
|
||||
|
||||
// 3. 创建玩家
|
||||
this.player = new Player(this, 400, 300);
|
||||
|
||||
// 4. 摄像机跟随玩家
|
||||
this.cameras.main.startFollow(this.player);
|
||||
this.cameras.main.setBounds(0, 0, this.map.width, this.map.height);
|
||||
|
||||
// 5. 创建虚拟摇杆(移动端)
|
||||
this.createVirtualJoystick();
|
||||
|
||||
// 6. 创建景点触发区域
|
||||
this.createSpotTriggers();
|
||||
|
||||
// 7. 可选:播放背景音乐
|
||||
// this.bgm = this.sound.add('bgm', { loop: true, volume: 0.5 });
|
||||
// this.bgm.play();
|
||||
}
|
||||
|
||||
createVirtualJoystick() {
|
||||
// 使用 rexVirtualJoystick 插件
|
||||
this.joystick = this.rexVirtualJoystick.add(this, {
|
||||
x: 120,
|
||||
y: this.scale.height - 120,
|
||||
radius: 60,
|
||||
base: this.add.circle(0, 0, 60, 0x888888, 0.3),
|
||||
thumb: this.add.circle(0, 0, 30, 0xcccccc, 0.6),
|
||||
enable: true
|
||||
});
|
||||
|
||||
// 将摇杆传递给玩家
|
||||
this.player.setJoystick(this.joystick);
|
||||
}
|
||||
|
||||
createSpotTriggers() {
|
||||
SPOTS.forEach(spot => {
|
||||
// 添加景点标记(半透明圆圈 + 名称)
|
||||
this.add.circle(spot.x, spot.y, spot.radius, 0xffaa00, 0.2);
|
||||
this.add.text(spot.x, spot.y - spot.radius - 20, spot.name, {
|
||||
fontSize: '16px',
|
||||
color: '#ffffff',
|
||||
backgroundColor: '#00000088',
|
||||
padding: { x: 6, y: 3 }
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// 创建触发区域(Zone + 静态物理体)
|
||||
const zone = this.add.zone(spot.x, spot.y, spot.radius * 2, spot.radius * 2);
|
||||
this.physics.add.existing(zone, true); // static body
|
||||
|
||||
// 重叠检测:玩家进入区域时弹出照片
|
||||
this.physics.add.overlap(this.player, zone, () => {
|
||||
if (!this.photoOpen) {
|
||||
this.photoOpen = true;
|
||||
this.scene.pause();
|
||||
this.scene.launch('PhotoScene', { spot });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 监听照片场景关闭,恢复主场景
|
||||
this.events.on('resume', () => {
|
||||
this.photoOpen = false;
|
||||
});
|
||||
}
|
||||
|
||||
update() {
|
||||
this.player.update();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 玩家类(`src/objects/Player.js`)
|
||||
|
||||
```js
|
||||
import Phaser from 'phaser';
|
||||
|
||||
export default class Player extends Phaser.Physics.Arcade.Sprite {
|
||||
constructor(scene, x, y) {
|
||||
super(scene, x, y, 'player');
|
||||
scene.add.existing(this);
|
||||
scene.physics.add.existing(this);
|
||||
|
||||
this.speed = 200;
|
||||
this.setCollideWorldBounds(true);
|
||||
|
||||
// 键盘输入
|
||||
this.cursors = scene.input.keyboard.createCursorKeys();
|
||||
this.wasd = scene.input.keyboard.addKeys('W,A,S,D');
|
||||
|
||||
// 虚拟摇杆(由外部注入)
|
||||
this.joystick = null;
|
||||
}
|
||||
|
||||
setJoystick(joystick) {
|
||||
this.joystick = joystick;
|
||||
}
|
||||
|
||||
update() {
|
||||
let vx = 0;
|
||||
let vy = 0;
|
||||
|
||||
// 键盘输入
|
||||
if (this.cursors.left.isDown || this.wasd.A.isDown) vx = -1;
|
||||
else if (this.cursors.right.isDown || this.wasd.D.isDown) vx = 1;
|
||||
if (this.cursors.up.isDown || this.wasd.W.isDown) vy = -1;
|
||||
else if (this.cursors.down.isDown || this.wasd.S.isDown) vy = 1;
|
||||
|
||||
// 虚拟摇杆输入(优先级高于键盘)
|
||||
if (this.joystick && this.joystick.isPressed) {
|
||||
vx = this.joystick.forceX;
|
||||
vy = this.joystick.forceY;
|
||||
}
|
||||
|
||||
// 归一化速度,避免斜向移动过快
|
||||
const len = Math.sqrt(vx * vx + vy * vy);
|
||||
if (len > 0) {
|
||||
this.setVelocity((vx / len) * this.speed, (vy / len) * this.speed);
|
||||
// 播放行走动画(根据方向切换)
|
||||
// this.anims.play('walk-down', true); // 示例
|
||||
} else {
|
||||
this.setVelocity(0, 0);
|
||||
this.anims.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 景点数据(`src/data/spots.js`)
|
||||
|
||||
```js
|
||||
export const SPOTS = [
|
||||
{
|
||||
id: 'dayanta',
|
||||
name: '大雁塔',
|
||||
x: 820,
|
||||
y: 450,
|
||||
radius: 60,
|
||||
photos: [
|
||||
'assets/photos/dayanta_01.jpg',
|
||||
'assets/photos/dayanta_02.jpg'
|
||||
],
|
||||
description: '国庆第3天,夕阳下的大雁塔格外壮观。'
|
||||
},
|
||||
{
|
||||
id: 'bingmayong',
|
||||
name: '兵马俑',
|
||||
x: 1500,
|
||||
y: 200,
|
||||
radius: 70,
|
||||
photos: [
|
||||
'assets/photos/bingmayong_01.jpg'
|
||||
],
|
||||
description: '世界第八大奇迹,震撼到说不出话。'
|
||||
},
|
||||
{
|
||||
id: 'zhonglou',
|
||||
name: '钟楼',
|
||||
x: 600,
|
||||
y: 350,
|
||||
radius: 50,
|
||||
photos: [
|
||||
'assets/photos/zhonglou_01.jpg'
|
||||
],
|
||||
description: '夜晚的钟楼灯火辉煌,车水马龙。'
|
||||
}
|
||||
// ... 按实际照片添加更多景点
|
||||
];
|
||||
```
|
||||
|
||||
> **坐标说明**:`x, y` 为地图图片上的像素坐标。可用图片编辑工具或在线取点工具获取。
|
||||
|
||||
### 5.6 照片弹窗场景(`src/scenes/PhotoScene.js`)
|
||||
|
||||
```js
|
||||
import Phaser from 'phaser';
|
||||
|
||||
export default class PhotoScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'PhotoScene' });
|
||||
}
|
||||
|
||||
init(data) {
|
||||
this.spot = data.spot;
|
||||
}
|
||||
|
||||
create() {
|
||||
const width = this.scale.width;
|
||||
const height = this.scale.height;
|
||||
|
||||
// 半透明遮罩,阻止点击穿透
|
||||
const overlay = this.add.rectangle(0, 0, width, height, 0x000000, 0.85)
|
||||
.setOrigin(0, 0)
|
||||
.setInteractive();
|
||||
|
||||
// 照片显示(取第一张照片,可扩展为轮播)
|
||||
const photoKey = `${this.spot.id}_0`;
|
||||
const photo = this.add.image(width / 2, height / 2 - 40, photoKey);
|
||||
// 等比例缩放至合适大小
|
||||
const maxW = width * 0.7;
|
||||
const maxH = height * 0.6;
|
||||
const scale = Math.min(maxW / photo.width, maxH / photo.height, 1);
|
||||
photo.setScale(scale);
|
||||
|
||||
// 景点名称
|
||||
this.add.text(width / 2, height / 2 - maxH / 2 - 60, this.spot.name, {
|
||||
fontSize: '32px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold'
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// 描述文字
|
||||
this.add.text(width / 2, height / 2 + maxH / 2 + 30, this.spot.description, {
|
||||
fontSize: '18px',
|
||||
color: '#cccccc',
|
||||
wordWrap: { width: width * 0.7 },
|
||||
align: 'center'
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// 点击任意位置关闭
|
||||
overlay.on('pointerdown', () => {
|
||||
this.scene.stop();
|
||||
this.scene.resume('MapScene');
|
||||
});
|
||||
|
||||
// 也支持按 ESC / 空格关闭(PC)
|
||||
this.input.keyboard.once('keydown-ESC', () => {
|
||||
this.scene.stop();
|
||||
this.scene.resume('MapScene');
|
||||
});
|
||||
this.input.keyboard.once('keydown-SPACE', () => {
|
||||
this.scene.stop();
|
||||
this.scene.resume('MapScene');
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 资源准备规范
|
||||
|
||||
### 6.1 地图底图
|
||||
|
||||
- 格式:PNG 或 JPG,宽度建议 2000–4000px
|
||||
- 来源:陕西省测绘地理信息局标准地图、城市吧旅游地图,或自行风格化处理
|
||||
- 存放:`public/assets/maps/xian-map.png`
|
||||
|
||||
### 6.2 景点照片
|
||||
|
||||
- 按景点分类,每个景点 1–5 张
|
||||
- 统一压缩至宽度 1200px 以内,推荐 WebP 格式
|
||||
- 命名:`景点id_序号.jpg`(如 `dayanta_01.jpg`)
|
||||
- 存放:`public/assets/photos/`
|
||||
|
||||
### 6.3 角色精灵图
|
||||
|
||||
- 格式:PNG 精灵图,每帧 32×48 或 48×64
|
||||
- 至少包含 4 方向行走帧
|
||||
- 存放:`public/assets/sprites/player.png`
|
||||
|
||||
---
|
||||
|
||||
## 7. 移动端适配
|
||||
|
||||
- **缩放模式**:`Phaser.Scale.FIT` + `autoCenter: CENTER_BOTH`
|
||||
- **禁止滚动**:CSS `touch-action: none; overflow: hidden;`
|
||||
- **虚拟摇杆**:使用 `rexVirtualJoystick`,位于屏幕左下角
|
||||
- **屏幕旋转**:监听 `orientationchange` 并调用 `game.scale.refresh()`
|
||||
- **安全区域**:可使用 `viewport-fit=cover` 和 CSS `env(safe-area-inset-*)` 处理刘海屏
|
||||
|
||||
---
|
||||
|
||||
## 8. 打包 Android App(Capacitor)
|
||||
|
||||
1. 构建 Web 版本:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. 配置 `capacitor.config.ts`:
|
||||
```ts
|
||||
import { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.yourname.xianTravel',
|
||||
appName: '西安旅行记忆',
|
||||
webDir: 'dist',
|
||||
server: { androidScheme: 'https' }
|
||||
};
|
||||
export default config;
|
||||
```
|
||||
|
||||
3. 添加 Android 平台并同步:
|
||||
```bash
|
||||
npx cap add android
|
||||
npx cap sync
|
||||
```
|
||||
|
||||
4. 用 Android Studio 打开并生成 APK:
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
在 Android Studio 中点击 **Build → Build Bundle(s) / APK(s) → Build APK(s)**。
|
||||
|
||||
---
|
||||
|
||||
## 9. 部署与分享
|
||||
|
||||
### 9.1 GitHub Pages(免费链接分享)
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npx gh-pages -d dist
|
||||
```
|
||||
在 GitHub 仓库 **Settings → Pages** 中,将 Source 设为 `gh-pages` 分支。
|
||||
若部署在子路径,需在 `vite.config.js` 中设置 `base: '/仓库名/'`。
|
||||
|
||||
### 9.2 Vercel / Netlify(自动部署 + 自定义域名)
|
||||
|
||||
- Build command:`npm run build`
|
||||
- Publish directory:`dist`
|
||||
- 连接 GitHub 仓库后自动部署,分配 `https://项目名.vercel.app` 域名。
|
||||
|
||||
### 9.3 APK 直接分享
|
||||
|
||||
通过 Capacitor 生成的 APK 可通过微信、QQ 直接发送安装。
|
||||
|
||||
---
|
||||
|
||||
## 10. 性能优化清单
|
||||
|
||||
| 优化项 | 做法 |
|
||||
|---|---|
|
||||
| 图片压缩 | 照片转 WebP,宽度 ≤1200px |
|
||||
| 按需加载 | 照片在 BootScene 中统一加载,或进入景点时动态加载 |
|
||||
| 对象池 | 弹窗场景复用,避免频繁创建销毁 |
|
||||
| 缓存引用 | 将 `this.player` 等存为类属性,避免每帧查找 |
|
||||
| 选择性更新 | 仅当玩家移动时更新摇杆逻辑 |
|
||||
| 渲染模式 | 低端设备可尝试 `Phaser.CANVAS` 替代 `Phaser.AUTO` |
|
||||
| 世界边界 | 设置 `physics.world.setBounds` 避免角色跑出地图 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 开发时间估算
|
||||
|
||||
| 阶段 | 内容 | 预计时间 |
|
||||
|---|---|---|
|
||||
| 项目搭建 | Vite + Phaser + Capacitor 初始化 | 0.5 天 |
|
||||
| 地图与角色 | 地图加载、角色移动、边界碰撞 | 1 天 |
|
||||
| 景点系统 | 触发区域、照片弹窗、描述文字 | 1 天 |
|
||||
| 移动端适配 | 虚拟摇杆、缩放适配、横屏处理 | 0.5–1 天 |
|
||||
| 打包与部署 | Capacitor 打包 APK、GitHub Pages 部署 | 0.5 天 |
|
||||
| 测试调优 | 真机测试、性能优化、Bug 修复 | 1 天 |
|
||||
| **合计** | | **4.5–5 天** |
|
||||
|
||||
---
|
||||
|
||||
## 12. AI 编程工具使用建议
|
||||
|
||||
- 将本文件作为项目根目录的 `README.md` 或 `SPEC.md`,让 AI 工具读取上下文。
|
||||
- 按章节逐步实现,每完成一个模块运行 `npm run dev` 验证。
|
||||
- 遇到具体错误时,将错误信息与相关代码段一并提供给 AI 工具。
|
||||
- 地图坐标和照片路径需根据实际资源手动调整,AI 工具无法自动获取。
|
||||
- 虚拟摇杆的 `rexVirtualJoystick` 用法可参考 [phaser3-rex-plugins 官方文档](https://rexrainbow.github.io/phaser3-rex-notes/docs/site/virtualjoystick/)。
|
||||
|
||||
---
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
# 西安旅行记忆互动地图游戏 · 技术规格说明(细化版)
|
||||
|
||||
> 本文档是 `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`(唯一内容源)
|
||||
|
||||
```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 优先交付,减少前期装机量 |
|
||||
Reference in new issue
Block a user