春江暮客

春江暮客的个人学习分享网站

用 Phaser 构建 Sky Flap 浏览器小游戏:从源码下载到本地运行

2026-06-01 技术
用 Phaser 构建 Sky Flap 浏览器小游戏:从源码下载到本地运行

很多浏览器小游戏看起来只是一个 Canvas,但真正要做成可玩的版本,需要处理输入、重力、碰撞、计分、重开和发布。本文用我的小游戏 Sky Flap 做例子,目标很直接:下载线上页面源码,理解 Phaser 项目结构,然后用 Vite 和 Phaser 写出一个可以本地运行、可以打包发布的飞行躲避小游戏。

可以先试玩线上版本:

Sky Flap 在线游戏

方法 1:下载线上页面源码

如果你只是想查看当前线上页面的 HTML,可以直接用 curl 下载。因为这里通过 IP 访问,需要手动带上 Host 头:

curl -L -H 'Host:game.bobobk.com' http://152.67.254.178/play/sky-flap/ -o sky-flap.html

下载后先看页面里加载了哪些脚本和样式:

grep -oE 'src="[^"]+"' sky-flap.html
grep -oE 'href="[^"]+"' sky-flap.html

如果页面是 Vite 打包后的产物,通常会看到类似这样的入口:

<script type="module" crossorigin src="/assets/index-DQ8f3xY2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BG4p7kLm.css">

继续下载 JS bundle:

JS_PATH=$(grep -oE '/assets/[^"]+\.js' sky-flap.html | head -1)
curl -L -H 'Host:game.bobobk.com' "http://152.67.254.178${JS_PATH}" -o sky-flap.bundle.js

这一步的价值不是直接修改压缩后的 bundle,而是确认游戏入口、资源路径和部署方式。真正开发时,建议维护一份清晰的源码项目,再用 npm run build 生成线上文件。

方法 2:创建 Phaser 项目

先创建一个 Vite 项目,再安装 Phaser:

npm create vite@latest sky-flap -- --template vanilla
cd sky-flap
npm install
npm install phaser

启动开发服务器:

npm run dev -- --host 0.0.0.0

浏览器打开终端里显示的地址。如果你在服务器上调试,记得开放对应端口,或者通过 Nginx 反向代理到 Vite 开发端口。

实现:写一个最小可玩的 Sky Flap

先把 index.html 简化成一个游戏容器:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Sky Flap</title>
  </head>
  <body>
    <div id="game"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

再写 src/main.js。这个版本不依赖外部图片,直接用 Phaser 生成纹理,方便复制后立即运行:

import Phaser from 'phaser';

const WIDTH = 480;
const HEIGHT = 720;
const GRAVITY = 900;
const FLAP = -360;

class SkyFlap extends Phaser.Scene {
  constructor() {
    super('SkyFlap');
    this.score = 0;
    this.gameOver = false;
  }

  create() {
    this.score = 0;
    this.gameOver = false;
    this.createTextures();

    this.add.image(WIDTH / 2, HEIGHT / 2, 'sky').setDisplaySize(WIDTH, HEIGHT);
    this.player = this.physics.add.sprite(120, HEIGHT / 2, 'bird');
    this.player.setCircle(18);
    this.player.setCollideWorldBounds(true);

    this.pipes = this.physics.add.group();
    this.scoreText = this.add.text(24, 24, '0', {
      fontFamily: 'Arial',
      fontSize: '42px',
      color: '#ffffff',
      stroke: '#1f2937',
      strokeThickness: 6,
    });

    this.input.on('pointerdown', () => this.flap());
    this.input.keyboard.on('keydown-SPACE', () => this.flap());

    this.time.addEvent({
      delay: 1350,
      callback: this.spawnPipePair,
      callbackScope: this,
      loop: true,
    });

    this.physics.add.overlap(this.player, this.pipes, () => this.endGame());
  }

  update() {
    if (this.gameOver) return;

    this.player.rotation = Phaser.Math.Clamp(this.player.body.velocity.y / 500, -0.45, 0.9);

    this.pipes.children.iterate((pipe) => {
      if (!pipe) return;
      if (!pipe.scored && pipe.x < this.player.x && pipe.getData('scorePipe')) {
        pipe.scored = true;
        this.score += 1;
        this.scoreText.setText(String(this.score));
      }
      if (pipe.x < -80) pipe.destroy();
    });

    if (this.player.y > HEIGHT - 20 || this.player.y < 20) {
      this.endGame();
    }
  }

  flap() {
    if (this.gameOver) {
      this.scene.restart();
      return;
    }
    this.player.setVelocityY(FLAP);
  }

  spawnPipePair() {
    if (this.gameOver) return;

    const gap = 185;
    const center = Phaser.Math.Between(210, HEIGHT - 210);
    const topHeight = center - gap / 2;
    const bottomY = center + gap / 2;
    const bottomHeight = HEIGHT - bottomY;

    this.addPipe(WIDTH + 60, topHeight / 2, topHeight, false);
    this.addPipe(WIDTH + 60, bottomY + bottomHeight / 2, bottomHeight, true);
  }

  addPipe(x, y, height, scorePipe) {
    const pipe = this.pipes.create(x, y, 'pipe');
    pipe.setDisplaySize(74, height);
    pipe.setVelocityX(-190);
    pipe.setImmovable(true);
    pipe.body.allowGravity = false;
    pipe.setData('scorePipe', scorePipe);
    return pipe;
  }

  endGame() {
    if (this.gameOver) return;
    this.gameOver = true;
    this.physics.pause();
    this.add.text(WIDTH / 2, HEIGHT / 2, 'Game Over\\nClick to restart', {
      fontFamily: 'Arial',
      fontSize: '34px',
      color: '#ffffff',
      align: 'center',
      stroke: '#111827',
      strokeThickness: 6,
    }).setOrigin(0.5);
  }

  createTextures() {
    const g = this.add.graphics();

    g.fillStyle(0x55b7e8, 1);
    g.fillRect(0, 0, WIDTH, HEIGHT);
    g.generateTexture('sky', WIDTH, HEIGHT);

    g.clear();
    g.fillStyle(0xffd43b, 1);
    g.fillEllipse(24, 24, 48, 36);
    g.fillStyle(0xfb8500, 1);
    g.fillTriangle(44, 20, 66, 28, 44, 36);
    g.generateTexture('bird', 72, 52);

    g.clear();
    g.fillStyle(0x2fb65d, 1);
    g.fillRect(0, 0, 74, 420);
    g.lineStyle(4, 0x16823a, 1);
    g.strokeRect(0, 0, 74, 420);
    g.generateTexture('pipe', 74, 420);

    g.destroy();
  }
}

new Phaser.Game({
  type: Phaser.AUTO,
  parent: 'game',
  width: WIDTH,
  height: HEIGHT,
  backgroundColor: '#55b7e8',
  physics: {
    default: 'arcade',
    arcade: {
      gravity: { y: GRAVITY },
      debug: false,
    },
  },
  scene: SkyFlap,
});

这个版本已经包含完整循环:

  1. 点击或按空格让角色上升
  2. 管道持续从右向左移动
  3. 角色碰到管道或飞出边界后结束
  4. 游戏结束后再次点击重开

打包和发布

本地确认能玩以后,执行构建:

npm run build

Vite 会生成 dist/ 目录。可以先本地预览:

npm run preview -- --host 0.0.0.0

如果要发布到 /play/sky-flap/,可以把 dist/ 上传到对应目录。示例:

rsync -av --delete dist/ user@example.com:/var/www/game/play/sky-flap/

如果站点不是部署在根路径,记得在 vite.config.js 里设置 base

import { defineConfig } from 'vite';

export default defineConfig({
  base: '/play/sky-flap/',
});

验证

发布后检查三件事:

curl -I https://game.bobobk.com/play/sky-flap/
curl -L https://game.bobobk.com/play/sky-flap/ | grep -E 'script|stylesheet'

浏览器里再确认:

  1. 页面能打开
  2. 点击或空格能控制角色
  3. 碰撞后能显示 Game Over
  4. 刷新后没有 404 的 JS、CSS 或图片资源

常见问题

1. 页面空白

先打开浏览器 DevTools,看 Console 是否有 JS 报错。最常见原因是资源路径错了。部署在 /play/sky-flap/ 时,vite.config.js 要设置:

export default defineConfig({
  base: '/play/sky-flap/',
});

2. Phaser 找不到模块

如果看到 Failed to resolve import "phaser",说明依赖没有安装:

npm install phaser

3. 线上源码下载不到

如果 DNS 没有解析到目标机器,可以按本文开头的方式用 IP 加 Host 头下载:

curl -L -H 'Host:game.bobobk.com' http://152.67.254.178/play/sky-flap/ -o sky-flap.html

如果你在本机能返回 200,但服务器里失败,通常是当前环境的出站网络、机房防火墙或安全组限制,需要换到能访问目标 IP 的机器执行。

总结

Phaser 很适合做这种轻量浏览器小游戏:输入、物理、碰撞、计时器和 Canvas 渲染都已经封装好。实际项目可以先从线上页面下载源码确认部署结构,再回到清晰的 Vite 源码里维护游戏逻辑。Sky Flap 的核心不复杂,关键是把“可玩循环”和“可发布结构”同时做好。

友情链接

其它