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

17 KiB
Raw Permalink Blame History

西安旅行记忆互动地图游戏 — 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. 环境搭建与依赖安装

# 创建 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 基础配置:

import { defineConfig } from 'vite';

export default defineConfig({
  base: './',                // 相对路径,便于部署到子目录
  build: {
    outDir: 'dist',
    assetsInlineLimit: 0     // 避免大图片被内联
  },
  server: {
    host: true,              // 允许局域网访问,方便手机调试
    port: 3000
  }
});

5. 核心代码实现

5.1 入口与全局配置(src/main.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:

<!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)

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)

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)

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)

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)

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 版本:

    npm run build
    
  2. 配置 capacitor.config.ts:

    import { CapacitorConfig } from '@capacitor/cli';
    
    const config: CapacitorConfig = {
      appId: 'com.yourname.xianTravel',
      appName: '西安旅行记忆',
      webDir: 'dist',
      server: { androidScheme: 'https' }
    };
    export default config;
    
  3. 添加 Android 平台并同步:

    npx cap add android
    npx cap sync
    
  4. 用 Android Studio 打开并生成 APK:

    npx cap open android
    

    在 Android Studio 中点击 Build → Build Bundle(s) / APK(s) → Build APK(s)。


9. 部署与分享

9.1 GitHub Pages(免费链接分享)

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 官方文档。