65.9K
CodeProject 正在变化。 阅读更多。
Home

Greg The Robot - 使用 TypeScript 进行游戏开发

starIconstarIconstarIconstarIconstarIcon

5.00/5 (35投票s)

2017 年 11 月 26 日

CPOL

20分钟阅读

viewsIcon

49853

downloadIcon

797

使用 Phaser 游戏框架制作的射击类 HTML5 游戏

引言

在本文中,我将使用 TypeScript 和一个名为 Phaser 的很棒的游戏开发库,为您展示一款 HTML5 的“射击类”游戏。

这个项目是对 80 年代经典街机游戏的致敬,特别是 Konami 于 1986 年制作的精彩游戏“Knightmare”。

希望本文能吸引那些正在寻找基础游戏结构来在此基础上进行开发的人。我认为 TypeScript 部分是该项目的一大亮点,因为它提高了开发过程的生产力,这不仅限于游戏项目,也适用于 JavaScript 开发。

背景

2017 年 8 月,举办了一场以游戏为主题的黑客松比赛。我在 Caelum Ensino e Inovação 的一些同事决定参加比赛,但成员人数不断增加,所以我们不得不将他们分成不同的团队,负责不同的项目。最终,我们用 Unity、JavaScript 和 Phaser/TypeScript 开发了不同的游戏。

我成为其中一个团队的开发人员,经过一段时间关于可能项目的讨论,我提出了一个建议,团队其他人也同意了。当时,我受到 Konami 于 1986 年为 MSX 计算机平台制作的精彩游戏 Knightmare 的启发,那是一个互联网、HTML 或 JavaScript 出现之前的时代。Konami 成功地将一款拥有出色音效和画面的游戏压缩到了一个只有 32KB 内存的卡带中!

Knightmare MSX

图 1 - 1986 年 Konami 的 Knightmare

但显然,技术必须不同。我们无意抄袭 Konami 的角色、画面和音乐,所以我们不得不设计自己的故事情节和艺术风格。我们进行了一次头脑风暴会议,讨论了一个小时的想法,最终,我们同意将主角(英雄)设计成一个贫穷但讨人喜欢的机器人,在一个由邪恶机器人统治的机器人荒原上展开冒险。

想法不断涌现,我们开始开发,但不幸的是,在头脑风暴后不久,我们发现如果我们实现所有想象中的功能,就无法按时完成黑客松比赛的截止日期。所以我们不得不削减许多不是那么关键的游戏功能。

这款游戏完全使用 HTML5/JavaScript 和 Phaser 游戏库制作。虽然形式上与上面提到的 Konami 的原始游戏大相径庭,但在许多方面却与之相似,例如:

  • 孤独的英雄
  • “射击类”平台
  • 垂直自动滚动的背景
  • “从天而降”的敌人
  • 关卡 Boss

致谢

Priscila Sonnesso

她借助简单的循环合成器创作了游戏的音效和背景音乐。音效指示事件,例如玩家获得能量时或玩家死亡时。背景音乐是一个简单的循环,模仿赛博朋克环境。灵感来自德国电子音乐乐队 Kraftwerk,他们在八十年代以其关于机器人和未来世界的歌曲而闻名。

Kauê Felipe

他负责背景图像,以匹配关卡地图。这些地图由纯文本 ASCII 字符文件定义,其中一个点代表一个空方格,而“X”表示不可通行的墙壁。他使用 Paint.NET 创作图像,使其与地图边界和底层障碍物精确匹配。

Pedro Emanuel

他用一本学校笔记本绘制了游戏角色:Greg the Robot 和 Bosses。Pedro 是卡通和动漫的忠实粉丝,他的贡献为我们的游戏带来了可爱的卡通风格。

Marco Bruno

我们的团队经理。他组织团队,提供想法并指导我们的开发。

软件要求

Visual Studio 2017

在下载游戏源代码之前,请安装 Visual Studio Community 2017 或更高版本,这是该项目所需的集成开发环境。

TypeScript

您可以从下面的链接下载 Visual Studio 的 TypeScript 开发工具包。您将能够为任何 Web 浏览器、任何机器或操作系统开发大型 JavaScript 应用程序。TypeScript 生成的代码干净且易于阅读,符合 JavaScript 标准。

Phaser

本文附带的源代码已经包含了应用程序运行所需的所有 Phaser 库文件。但如果您想从头开始创建一个全新的 Phaser 项目并使用 TypeScript,可以遵循此 使用 TypeScript 开发 Phaser 游戏 教程。

Phaser

图 2 - Phaser 的“Martian”标志

Phaser 是一个基于 JavaScript/HTML5 的 2D 游戏开发库。它包含许多内置功能,适用于各种游戏风格和场景。例如,它具有精灵(sprites)的概念。游戏中的精灵是包含游戏角色(如英雄和敌人)定义的二维位图。不仅如此,精灵文件几乎总是包含构成角色动画的不同角色帧。公平地说,许多游戏库也处理精灵。但 Phaser 配备了内置的碰撞检测和处理机制,该机制非常易于设置和实现。

<!DOCTYPE html>

<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>Greg The Robot</title>
    <link rel="shortcut icon" href="greg.ico" />
    <link rel="stylesheet" href="app.css" type="text/css" />
    <script src="phaser/phaser.js"></script>
    <script src="app.js"></script>
</head>
<body>
    <h1></h1>

    <div id="content"></div>
</body>
</html>
列表 1 - 典型的 Index.html 文件,显示 phaser.js、app.js 和 app.css 依赖项

与其他许多游戏框架一样,Phaser 基于更新/渲染循环。这些循环将游戏执行的两个基本方面分开:每个游戏更新循环都有机会更新游戏状态和每个角色的背景状态(如位置、分数等)。它用于处理游戏逻辑数据和行为。另一方面,渲染循环是我们告诉图形如何显示的地方:例如,假设有两个不同的精灵,一个用于英雄,另一个用于其阴影。游戏如何决定哪个应该显示在背景和前景上?渲染循环描述了精灵在屏幕上出现的顺序。

class GregTheRobot {
    game: Phaser.Game;
    statusBar: Phaser.BitmapText;
    constructor() {
        this.game = new Phaser.Game(512, 512, Phaser.AUTO, 'content', {
            create: this.create, preload: this.preload
        });
    }
    .
    .
    .
    //a lot more code here...
    .
    .
    .
}

window.onload = () => {

    var game = new GregTheRobot();

};
列表 2 - GregTheRobot 类由 window.onload 事件调用。

幸运的是,除非您真的需要更改屏幕上内容的显示方式,否则 Phaser 不需要您实现渲染循环。它假定每个精灵都将被渲染,除非您以编程方式销毁它或将其可见性更改为隐藏。

如果您从小处着手,并逐渐将 Phaser 功能添加到您的游戏中,那么学习曲线会很短。如果您的游戏代码失控地增长,并且您不时清理和重构 JavaScript 代码,事情会变得更加复杂。幸运的是,Phaser 包还附带 TypeScript 支持,这有助于避免许多编程错误。

图 3 - VS 2017 项目浏览器,展示了典型的 Phaser 文件

至于 Phaser 背后的技术:它内部同时使用 Canvas 和 WebGL 渲染器,并可根据浏览器支持自动在这两者之间切换。这使得在桌面和移动设备上都能实现闪电般的渲染。Phaser 使用并贡献了出色的 Pixi.js 库用于渲染。

预加载器

游戏通常需要许多资源,例如图像、声音、精灵表、图块地图、JSON、XML,这些资源会被自动解析和处理。它们已准备好在游戏中,并存储在全局缓存中。Phaser 可以用一行代码加载每个资源。

您可以通过在名为 preload 的函数中调用 game.load 方法来加载 Phaser 项目中的资源。当程序启动时,Phaser 总是查找名为“preload”的函数并加载其中定义的资源。

preload() {
    //menu & splash screen images
    this.game.load.spritesheet('menu', 'assets/backgrounds/menu.png', 512, 384);
    this.game.load.spritesheet('splash1', 'assets/backgrounds/splash1.png', 512, 384);

    //game state JavaScript files
    this.game.load.script('baseState', 'gamestates/baseState.js');
    this.game.load.script('menu', 'gamestates/menu.js');
    this.game.load.script('splash1', 'gamestates/splash.js')
    this.game.load.script('gameOver', 'gamestates/gameOver.js')
    this.game.load.script('level', 'gamestates/level.js');
    this.game.load.script('theEnd', 'gamestates/theEnd.js')

    //classes for player, enemies, boss, etc. 
    this.game.load.script('player', 'player/player.js');
    this.game.load.script('playerBullet', 'player/playerBullet.js');
    this.game.load.script('playerState', 'player/playerState.js');
    this.game.load.script('boss', 'enemies/boss.js');
    this.game.load.script('enemy', 'enemies/enemy.js');
    this.game.load.script('battery', 'extras/battery.js');

    //level intro images
    this.game.load.image('level1', 'assets/backgrounds/level01.jpg');
    this.game.load.image('level2', 'assets/backgrounds/level02.jpg');
    this.game.load.image('level3', 'assets/backgrounds/level03.jpg');

    //spritesheets for every character in the game
    this.game.load.spritesheet('player', 'assets/sprites/player.png', 32, 32);
    this.game.load.spritesheet('battery', 'assets/sprites/battery.png', 32, 32);
    this.game.load.spritesheet('boss1', 'assets/sprites/boss1.png', 96, 96);
    //...etc...
    this.game.load.spritesheet('enemy1', 'assets/sprites/enemy1.png', 32, 32);
    //...etc...
    this.game.load.spritesheet('playerBullet', 
              'assets/sprites/PlayerBullet1SpriteSheet.png', 32, 32);

    //sound & music resources
    this.game.load.audio('start', ['assets/audio/start-level.wav']);
    this.game.load.audio('intro', ['assets/audio/sound-intro.wav']);
    //...etc...

    //the bitmap containing the game fonts
    this.game.load.bitmapFont('bitmapfont', 
            'assets/fonts/bitmapfont_0.png', 'assets/fonts/bitmapfont.xml');
}
列表 3 - GregTheRobot.preload() 方法

上面的 preload 函数处理以下类型的资源:

  • 精灵表
  • 图像
  • 音频
  • 位图字体

请注意,所有这些资源都是通过传递一个 string 键作为参数来加载的。当需要资源时,这个键将用于识别正确的资源。通过预加载资源,我们可以确保程序在游戏过程中不会出现延迟,因为所有资源都已在内存中可用。

动画

Phaser 支持具有固定帧大小的经典精灵表、纹理打包器和 flash CS6 CC JSON 文件以及 XML 文件。

对于这个项目,我选择使用固定帧大小的精灵表。

TypeScript

TypeScript 是由 Anders Hejlsberg(曾共同创建/设计 C#、Delphi 和 Turbo Pascal)共同创建并由 Microsoft 开发的免费开源编程语言,旨在改进和保障 JavaScript 代码的生成。它是 JavaScript 的超集,这意味着任何格式正确的 JavaScript 代码也是 TypeScript 代码。由于 Web 浏览器不识别 TypeScript,因此在发送到 Web 浏览器之前,必须将其编译(或通常称为转译)为 JavaScript。

TypeScript 支持 ECMAScript 6 规范并提供类型检查功能。这意味着如果您创建一个接受字符串和数字作为参数的自定义函数,您就不能用两个字符串、两个数字或任何其他不完全匹配函数签名的值来调用该函数。这种类型检查功能支持设计时调试,这是纯 JavaScript 无法实现的。这也意味着更快的开发速度,因为类型不匹配的错误会更早被捕获,而不是潜伏在暗处直到代码发布到生产环境。

TypeScript 的另一个优点是,您可以将一个 JavaScript 项目逐个文件地迁移到 TypeScript,因为任何 JavaScript 代码都是 TypeScript 代码。

关于在 Phaser 项目中使用 TypeScript,您可以阅读文章:使用 TypeScript 开发 Phaser 游戏

图 4 - 典型的 Phaser TypeScript 文件

有了 TypeScript,Phaser 开发会更加容易。例如,在传统的 JavaScript 代码中,IDE 大多数时候无法确定变量属于哪种类型,因此在您输入“对象 + 点 + 成员”时,它无法准确知道给定对象在当前时刻有哪些成员可用。这是因为 JavaScript 是一种弱类型语言

当我们使用 TypeScript 时,我们可以为对象关联类型,这样 IDE 就可以预测哪些成员属于该对象。

我们可以用 TypeScript 编写代码,但最终 Web 浏览器只识别 JavaScript。这时转译器就派上用场了。例如,观察以下 TypeScript 代码:

class Player implements IPlayer {
    level: BaseLevel;
    game: Phaser.Game;
    cursors: Phaser.CursorKeys;
    layer: Phaser.TilemapLayer;
    bulletSound: Phaser.Sound;
    diedSound: Phaser.Sound;
    damageSound: Phaser.Sound;
    rechargeSound: Phaser.Sound;
    sprite: Phaser.Sprite;
    isWeaponLoaded: boolean;
    velocity: number;
    walkingVelocity: number;
    state: IPlayerState;
    power: number;
    constructor(
        level: Level1, cursors: Phaser.CursorKeys,
        layer: Phaser.TilemapLayer, bulletSound: Phaser.Sound,
        diedSound: Phaser.Sound, damageSound: Phaser.Sound,
        rechargeSound: Phaser.Sound) {
        this.level = level;
        this.game = level.game;
        this.cursors = cursors;
        this.layer = layer;
        this.bulletSound = bulletSound;
        this.diedSound = diedSound;
        this.damageSound = damageSound;
        this.rechargeSound = rechargeSound;
        this.damageSound.onStop.add(function () {
            this.sprite.animations.play('run');
        }.bind(this));
        this.power = 10;
        this.create();
    }
列表 4 - Player 类片段,展示了强类型 TypeScript 代码

现在注意当 JavaScript 从 TypeScript 生成时,类型是如何被剥离的:

class Player {
    constructor(level, cursors, layer, bulletSound, diedSound, damageSound, rechargeSound) {
        this.level = level;
        this.game = level.game;
        this.cursors = cursors;
        this.layer = layer;
        this.bulletSound = bulletSound;
        this.diedSound = diedSound;
        this.damageSound = damageSound;
        this.rechargeSound = rechargeSound;
        this.damageSound.onStop.add(function () {
            this.sprite.animations.play('run');
        }.bind(this));
        this.power = 10;
        this.create();
    }
列表 5 - 同一个片段“转译”为 JavaScript 代码

任务

Robocity

游戏发生在一个反乌托邦的未来大都市 Robocity,这个地方被一个邪恶的机器人反派所腐蚀。这座城市曾经是一个电子天堂,里面的科技市民和谐地生活着,拥有异步行为、可预测的循环和执行随机任务。

Greg the Robot

Greg 是一个不随波逐流、自我意识觉醒的机器人。他是旧时代机器人的一位幸存者,由于其电子元件过于老旧,无法被“新秩序”黑客入侵,因此得以免受“新机器人秩序”的奴役。由于他所有的朋友现在都沦为奴隶,Greg 突然成为了一个意想不到的英雄。他看到了错误,并决定采取行动。他的行走缓慢,行为 erratic,但他知道 Robocity 的未来取决于他的成功。

敌人

图 5 - 不同类型的关卡敌人

英雄的任务是沿着道路前进,与敌人战斗,并击败每个关卡的 Boss。他的敌人是 Robocity 原本和平的市民,现在在邪恶的 Robotron 的奴役下变成了他的爪牙。当 Greg the Robot 行走时,他会损失能量单位,因此他必须通过避免不必要的奔跑来节省能量。幸运的是,沿途可以找到电池,Greg 可以在行走时补充能量。

Bosses

图 6 - Bulb-o-Boss,关卡 Boss

每个关卡都以一个由 Robotron 将军守护的最终竞技场结束。英雄必须击败 Boss 并通过通往下一关的大门。

游戏画面

图 7 - 游戏菜单图片

游戏菜单

游戏菜单仅显示游戏标题、Greg the robot 的图像以及一条要求玩家按空格键开始的文本。

玩家按下空格键后,会被带到下一个游戏画面:关卡介绍界面。

关卡介绍

图 8 - 关卡介绍

关卡介绍视图只显示带有关卡编号的文本。

关卡场景

图 9 - 关卡 1 的典型屏幕

关卡视图是游戏真正发生的地方。在这里,您可以使用光标键控制英雄,并射击弹药来消灭敌人。

当您损失能量时,屏幕下方的能量条会变短。每次与敌人碰撞或被弹药击中时,您都会损失一定量的能量。但是您可以在沿途拾取电池,您的能量将增加到您功率容量的 100%。

游戏结束

当 Greg 的能量条耗尽时,游戏结束。之后,玩家将被重定向到菜单屏幕,开始新一轮游戏。

游戏角色

玩家

图 10 - Greg the robot 行走

我们的英雄 Greg 由 Player 类表示,该类实现了 IPlayer 接口。该接口有一个速度字段、一个精灵字段以及一个布尔值字段,指示武器是否已加载。

图 11 - Player 类的图

速度是一个常量值,它决定了当玩家按下箭头按钮时,英雄移动一段距离的速度。例如:

    runLeft() {
        this.sprite.body.velocity.x = -this.velocity;
    }
列表 6 - Player.runLeft() 方法

sprite 是一种对象,它存储我们英雄(和敌人)的图形位图定义,并描述它们在屏幕上的显示方式。

this.sprite = this.game.add.sprite(this.game.world.centerX - 16, 
              this.game.world.height - 64, 'player');
列表 7 - Player.setup() 方法内的精灵赋值

布尔值表示武器是否已加载。英雄每次射击都会耗尽武器,这可以防止他同时发射大量弹药。

if (this.player.isWeaponLoaded && keyboard.isDown(Phaser.KeyCode.SPACEBAR)) {
    this.player.isWeaponLoaded = false;
    this.player.shoot();
}
else if (!keyboard.isDown(Phaser.KeyCode.SPACEBAR)) {
    this.player.isWeaponLoaded = true;
}
列表 8 - PlayerState.update() 方法中的武器加载管理

Player 对象有一个 wasHit 事件,当敌人和 Boss 与玩家精灵发生碰撞时会调用该事件。

wasHit() {
    this.damageSound.play();
    this.sprite.animations.play('hit');
    this.decreasePower(1);
}

分配给 Player 类的行为类作为 state 字段定义了它们可能的几种状态:running(跑步)和 dying(死亡),分别由 PlayerStateRunningPlayerStateDying 类提供。Player 类的构造函数定义了武器是已加载的。在 Player 类的设置中,我们定义了:精灵、动画、基础速度、行走速度以及英雄的尺寸。

当 Greg 被击中时,我们播放音效并显示相应的受损动画序列。能量也必须相应地减少。

wasHit() {
    this.damageSound.play();
    this.sprite.animations.play('hit');
    this.decreasePower(1);
}
列表 9 - Player.wasHit() 方法
    decreasePower(energyAmount: number): boolean {
        if (this.power - energyAmount > 0) {
            this.power -= energyAmount;
            this.level.updatePowerBar();
            return true;
        }
        else {
            this.power = 0;
            this.level.updatePowerBar();
            this.state = new PlayerStateDying(this);
            this.diedSound.play();
            this.level.playerStateChanged(this.state);
            return false;
        }
    }
列表 10 - Player.decreasePower() 方法

在“充电能量”事件中,我们播放充电音效并增加能量条的供应量。

    recharged(charge: number) {
        this.rechargeSound.play();
        this.increasePower(charge);
    }

    increasePower(energyAmount: number): boolean {
        if (this.power + energyAmount < 100) {
            this.power += energyAmount;
            this.level.updatePowerBar();
            return true;
        }
        else {
            this.power = 100;
            this.level.updatePowerBar();
            return false;
        }
    }
列表 11 - Player.recharged() 和 Player.increasePower() 方法

当英雄复活时,我们恢复其初始状态,并将默认动画设置为行走。通过这样做,我们确保玩家可以继续游戏。

walk 方法将玩家精灵向上移动,并自动调用,无需玩家干预。它仅用于补偿垂直自动滚动,该滚动会将关卡背景向下拖动。否则,玩家精灵会随着所有物体一起向下滚动,并最终离开屏幕。

    walk() {
        if (this.noCursorKeyDown()) {
            this.sprite.body.velocity.y = - this.walkingVelocity;
        }
    }
列表 12 - Player.walk() 方法通过向上移动玩家来补偿垂直自动滚动。

运行事件用于根据按下的箭头按钮更新速度向量的方向。

    runUp() {
        this.sprite.body.velocity.y = -this.velocity;
    }

    runDown() {
        this.sprite.body.velocity.y = this.velocity;
    }

    runLeft() {
        this.sprite.body.velocity.x = -this.velocity;
    }

    runRight() {
        this.sprite.body.velocity.x = this.velocity;
    }
列表 13 - Player 运行方法
    update(cursors: Phaser.CursorKeys, keyboard: Phaser.Keyboard, camera: Phaser.Camera) {
        if (cursors.up.isDown) {
            this.player.runUp();
        }
        else if (cursors.down.isDown) {
            if (this.player.sprite.body.y <
                camera.y + camera.height
                - this.player.sprite.height) {
                this.player.runDown();
            }
        }

        if (cursors.left.isDown) {
            this.player.runLeft();
        }

        else if (cursors.right.isDown) {
            this.player.runRight();
        }
列表 14 - PlayerStateRunning.update() 方法调用 Player.run* 方法

射击事件会触发一个方法,该方法会播放射击音效,并将一个带有子弹精灵的新对象添加到游戏中。这个弹药可以击中敌人并造成伤害。

    firePlayerBullet() {
        let playerBullet: PlayerBullet =
            new PlayerBullet(this, this.layer, this.bulletSound, this.player, this.boss);
        playerBullet.setup();
        this.playerBullets.push(playerBullet);
        if (this.player.decreasePower(1)) {
            this.updatePowerBar();
        }
    }
列表 15 - BaseLevel.firePlayerBullet() 方法

每当我们的英雄受到伤害时,其能量会根据伤害类型相应减少。如果剩余能量降至零,则 Greg 已遭受致命打击。在这种情况下,我们显示 Greg 被杀死的 sprite 动画,然后播放 Game Over 音乐。之后,玩家将被带到开始菜单。

另一方面,当能量增加时,能量条会根据接收到的量增加。能量条的最大能量水平为 100。

敌人

每个 enemy 类都继承自一个 abstract 类,该类包含:

  • 已加载武器指示器
  • 速度向量
  • 敌人位置
  • 敌人编号和 ID

图 12 - Enemy/BaseEnemy 类图

BaseEnemy 类包含处理与玩家碰撞的函数。当确认碰撞时,玩家会遭受与敌人强度相对应的伤害。但之后,敌人会被销毁。BaseEnemy 还允许检查敌人与其他敌人的碰撞。在这种情况下,它们都不会被销毁,但这样可以确保它们不会重叠,并为游戏带来更多真实感。

图 13 - Bee-bee 16 机器人

图 14 - Bulb-o-boss 关卡守护者

动画

关卡滚动

scroll() {
    this.game.camera.y -= this.getScrollStep();

    if (this.game.camera.y > 0) {
        this.statusBar.position.y -= this.getScrollStep();
        this.powerBar.position.y -= this.getScrollStep();
        if (this.player.state instanceof PlayerStateRunning) {
            this.player.walk();
        }
    }
    this.game.time.events.add(Phaser.Timer.SECOND / 32, this.scroll.bind(this));
}
列表 16 - BaseLevel.scroll() 方法

玩家动画

对于这个项目,我选择使用固定帧大小的精灵表。

图 15 - Greg 的精灵表,显示了行走动画(精灵 0-3)和死亡动画(4-7)。
add(name: string, frames?: number[] | string[], 
    frameRate?: number, loop?: boolean, useNumericIndex?: boolean): Phaser.Animation;
列表 17 - Phaser 的 AnimationManager.add() 方法签名

使用 Phaser 的 AnimationManager 类,我们可以添加新的帧动画,稍后将用于表示玩家的不同状态。

setup() {
    .
    .
    .
    this.sprite.animations.add('run', [0, 1, 2, 3], 4, true);
    this.sprite.animations.add('hit', [4, 5, 6, 7, 4, 5, 6, 7], 10, true);
    this.sprite.animations.add('die', [4, 5, 6, 7, 4, 5, 6, 7], 10, true);
    this.sprite.animations.play('run');
    .
    .
    .
}
列表 18 - Player.setup() 方法

以玩家的“hit”动画为例。当英雄受到伤害时会播放此动画。现在看看执行此动画所需的参数:

  • name: hit
  • frames: [4, 5, 6, 7, 4, 5, 6, 7]
  • frameRate: 10
  • loop: true

这意味着当英雄受到攻击时,具有上面帧索引的帧将以每秒 10 帧的帧率进行动画播放。此外,动画是循环的,这意味着帧序列将无限重复。

音效

游戏中的音效不仅仅是让游戏更有趣的资源,它们还扮演着非常重要的角色:它们定义了事件之间的间隔。例如;当用户在菜单屏幕上按下空格键时,音乐会开始播放,但关卡 1 要到音乐结束后才会真正开始。

update() {
    if (this.game.input.keyboard.isDown(Phaser.KeyCode.SPACEBAR)) {
        this.pushSpaceKey.alpha = 0;
        this.game.add.tween(this.pushSpaceKey).to({ alpha: 1 }, 100, 
                            Phaser.Easing.Linear.None, true, 0, 50, true);
        this.startSound.play();
    }
}
列表 19 - Menu.update() 方法

上述代码如何工作?

  • 首先,程序检查是否按下了空格键。
  • 如果按下,则“PUSH SPACE KEY”文本变得透明(即,我们将颜色 alpha 设置为零)。
  • 然后我们通过在颜色 alpha 0 和 1 之间交替使其无限闪烁。我们调用 tween 方法。“Tween”是“betweening”(中间生成)的缩写,即在关键帧之间生成中间帧的过程。
  • 在不停止闪烁动画的情况下,我们播放 startSound 音乐。

但程序如何知道音乐何时结束?要解释这一点,我们必须查看初始化 startSound 字段的代码。

create() {
    .
    .
    .
    this.startSound = this.game.add.audio('start');
    this.startSound.onStop.add(function () {
        this.game.state.start('splash1');
    }.bind(this));
}
列表 20 - Menu.create() 方法

现在我们可以看到 startSound onStop 事件正在被实现。当声音结束时,函数将触发,并且游戏关卡 splash1 将开始。

同样,其他每个游戏声音也将决定下一个事件何时开始。

  • 背景音乐
  • 射击
  • 能量提升/下降
  • 游戏结束

碰撞

您可以通过引入物理约束(如碰撞)来为游戏带来更多真实感。否则,精灵会互相重叠,或者像幽灵一样漂浮到屏幕外。

玩家 x 地图

关卡地图有既定的限制(左右边界,加上沿途的障碍物),所以我们不能让英雄绕过任何这些限制。幸运的是,Phaser 有一个很棒的碰撞检测库,允许我们定义玩家不能与其他给定游戏对象重叠。我们通过调用 game.physics.arcade.collide() 方法来实现这一点,将玩家的精灵和关卡层对象作为参数传递。

this.game.physics.arcade.collide(this.sprite, this.layer);
列表 21 - Player.update() 方法内的碰撞处理

玩家 x 敌人

我们不能让玩家和敌人重叠。我们通过调用 game.physics.arcade.collide() 方法来判断它们是否发生碰撞,但在此情况下,我们还提供了一个函数,该函数将定义当玩家被敌人身体击中时会发生什么行为。

  1. 我们让玩家受到伤害,并且
  2. 我们销毁敌人的精灵,将其从游戏中移除
checkPlayerCollisions() {
    this.game.physics.arcade.collide(this.sprite, this.player.sprite, function () {
        this.level.playerWasHit(this);
        this.sprite.destroy();
    }.bind(this));
}
列表 22 - Enemy.checkPlayerCollisions() 方法内的碰撞处理

子弹 x 敌人

子弹是在远距离摆脱敌人而不被击中并受伤的好方法。一旦子弹发射,我们就检查它是否与敌人碰撞,在这种情况下,我们销毁敌人和子弹。

this.level.enemies.forEach(enemy => {
    this.game.physics.arcade.collide(this.sprite, enemy.sprite, function () {
        this.destroyed = false;
        this.level.playerBulletHit(this, enemy);
        this.sprite.destroy();  
        enemy.sprite.destroy();  
    }.bind(this));
});
列表 23 - PlayerBullet.update() 方法内的碰撞处理

敌人 x 敌人

我们游戏中的敌人不是吃豆人鬼魂,所以它们不应该重叠。因此,当它们碰撞时,它们仍然保持分离,我们不销毁任何东西。我们只是为游戏带来更多一点的真实感。

checkEnemyCollisions(enemies: BaseEnemy[]) {
    enemies.forEach(other => {
        if (this.id != other.id) {
            this.game.physics.arcade.collide(this.sprite, other.sprite, function () {

            }.bind(this));  
        }
    });
}
列表 24 - Enemy.checkEnemyCollisions() 方法内的碰撞处理

玩家 x Boss

与 Boss 的碰撞几乎与与次要敌人的碰撞相同,即对玩家造成伤害。但在这种情况下,Boss 不会被销毁。

this.game.physics.arcade.collide(this.sprite, this.player.sprite, function () {
    if (this.player.sprite.animations.currentAnim.name == 'run') {
        this.player.wasHit(this);
    }
}.bind(this));
列表 25 - Boss.update() 方法内的碰撞处理

子弹 x Boss

关卡 Boss 在被销毁之前可以承受一定数量的子弹。但下面的代码仅从玩家子弹的角度描述了行为。Boss 的伤害管理在 Boss 类内部。

this.game.physics.arcade.collide(this.sprite, this.boss.sprite, function () {
    this.sprite.destroy();
    this.level.playerBulletHit(this, this.boss);
    this.boss.wasHit();
    this.destroyed = false;
}.bind(this));
列表 26 - PlayerBullet.update() 方法内的碰撞处理

关卡地图

如前所述,每个关卡都有一个背景图像,当我们的英雄走在路上时,该图像会向下滚动。

每个关卡还有一个关联的纯文本文件,其中每个字符代表背景图像中的一个 32x32 位置。

图 16 - Map01.txt 文件映射到 Level01.png 背景图像。

我们可以看到,点代表地图中一个空的 32x32 空间,而 X 对应于图像中的任何障碍物,例如墙壁、管道、迷宫等。

其他字符,例如小写字母(a, b, c, d),对应于应在地图滚动时放置的不同敌人。

当关卡开始时,它会加载纯文本文件并将 ASCII 文件内容填充到矩阵中。

let lines : string[] = this.readFile("/assets/maps/Map0" + 
                       this.levelNumber + ".txt").split('\n');
for (let y = 0; y < lines.length; y++) {
    let line : string = lines[y];
    let lineArray : number[] = new Array(line.length);
    for (let x = 0; x < line.length; x++) {
        let char : string = line[x];
        if (char == 'X') {
            this.map.putTile(1, x, y, this.layer);
            lineArray[x] = 1;
        }
        else {
            lineArray[x] = 0;
        }
    }
}
列表 27 - 在 BaseLevel.setupMap() 方法中读取地图

一旦矩阵加载完毕,我们仍然需要构建地图中找到的所有对象,例如敌人和电池。让我们看看它们是如何映射的。

图 17 - Map01.txt 中的敌人和电池位置。

BaseLevel 类持有一个 enemies 实例和另一个 batteries 实例。这些对象数组是根据 mapAsStringArray 矩阵的内容填充的。这些敌人中的每一个都使用在地图中找到的位置进行实例化。

setupEnemies(mapAsStringArray: string[]) {
    this.enemies = [];
    let enemycodes : string = 'abcde';
    for (let y = 0; y < mapAsStringArray.length; y++) {
        let line : string = mapAsStringArray[y];
        for (let x = 0; x < line.length; x++) {
            let char : string = line[x];
            let indexOf : number = enemycodes.indexOf(char);
            if (indexOf >= 0) {
                let enemy: BaseEnemy;
                let id: number = this.enemies.length + 1;
                switch (indexOf) {
                    case 0:
                        enemy = new EnemyA(this, this.game, this.layer, this.bulletSound, 
                        this.player, x * 32, y * 32, indexOf + 1, id);
                        break;
                    case 1:
                        enemy = new EnemyB(this, this.game, this.layer, this.bulletSound, 
                        this.player, x * 32, y * 32, indexOf + 1, id);
                        break;
                    case 2:
                        enemy = new EnemyC(this, this.game, this.layer, this.bulletSound, 
                        this.player, x * 32, y * 32, indexOf + 1, id);
                        break;
                    case 3:
                        enemy = new EnemyD(this, this.game, this.layer, this.bulletSound, 
                        this.player, x * 32, y * 32, indexOf + 1, id);
                        break;
                    case 4:
                        enemy = new EnemyE(this, this.game, this.layer, this.bulletSound, 
                        this.player, x * 32, y * 32, indexOf + 1, id);
                        break;
                }
                enemy.setup();
                this.enemies.push(enemy);
            }
        }
    }
}
列表 28 - BaseLevel.setupEnemies() 方法

结论

非常感谢您的时间和耐心。我希望这篇文章或项目的代码在某种程度上能帮助您,引起您的兴趣,或者至少让您对游戏开发有一些见解。在我看来,Phaser 易于使用,学习曲线短,提供良好的支持,并且拥有活跃的社区

欢迎随意使用代码。如果您有任何想法、抱怨或建议,请使用下面的评论区。另外,如果您想使用 Phaser 创建游戏项目,请告诉我。

历史

  • 2017/11/27 - 初始版本
  • 2017/11/28 - 添加了关卡地图说明
© . All rights reserved.