feat: 初始化西安旅行记忆互动地图游戏项目

完成项目基础搭建:配置 Vite 开发构建环境,集成 Phaser 3 与虚拟摇杆插件,实现地图漫游、景点触发、拍立得弹窗、进度存档等核心功能,添加占位资源与批量生成脚本,配置项目元数据与忽略规则。
This commit is contained in:
Stephen committed 2026-10-17 18:01:46 +08:00
commit a404e42ca8
42 files changed
+5489

No files matched your search

+612
View File
@@ -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/)。
---