# 西安旅行记忆互动地图游戏 — 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 西安旅行记忆
``` ### 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/)。 ---