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

613 lines
17 KiB
Markdown
Raw 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.
# 西安旅行记忆互动地图游戏 — 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/)。
---