Phaser3+Vite 从零开始制作HTML5兔年小游戏 - Carzy Bunny
我正在参加「兔了个兔」创意投稿大赛,详情请看:「兔了个兔」创意投稿大赛
前言
这是我用三天时间设计并完成的游戏,名叫 Carzy Bunny 疯狂的兔叽,玩家将扮演兔子,通过简单点击屏幕控制跳跃来到处收集金币、以及散落的新年红包🧧,并最终寻得关卡钥匙🔑来通关!
你可以在下面码上掘金中体验游戏,由于时间有限所以只制作了两关:
在这篇文章中,我将会分享这整个游戏的制作历程,带你走进一个前端开发眼中的游戏世界~
游戏开发语言仅使用到JavaScript,框架选择了之前一直想尝试的 Phaser3 + Vite 组合,在文章的后半段,也会以这个游戏的简单原型,教你用十几行代码写出一个平台跳跃游戏,如果感兴趣的话,赶紧点赞+收藏咱们继续往下看吧~
游戏设定
其实说起和兔子🐰相关的事物,大部分人的第一反应可能是胡萝卜🥕,但事实上兔子是草食性动物,而且也没有相关证据说明兔子爱吃萝卜,之所以我们有这样的刻板印象,多半还是N年前一部国外动画造成的。
——资料来源网络
所以在我的游戏设定里,兔子和萝卜之间将会是一对宿敌,其中胡萝卜是调皮可爱的反派,会成为主角行进的路上的阻碍。不过我也加入了白萝卜,它看起来比较温顺,食用(划掉)触碰则会增加积分~
与Super Mario一样,玩家可以通过踩踏消灭敌人,但不同的是,在这里敌人没有攻击性,玩家触碰不会受伤或死亡,而是使胡萝卜进入暴走状态——此时的胡萝卜背上了喷气背包,开始随处飘荡,并且在这个过程中会偷走地图上的金币(不包括红包),如果最终兔子收集的金币没有萝卜多,那么游戏将宣告失败!另外如果让胡萝卜拿到通关钥匙,那么你将不得不重新开始这一关🤣当然我们玩家兔子并不是没有反制方法,别忘了在任何时候踩踏萝卜都能将敌人消灭,当然也包括空中~
这将是一场考验眼力、手速、判断力与心态的冒险旅 (兔) 途!
游戏玩法与机制
在开始制作前,我就确定了游戏的类型为平台跳跃+跑酷的基调,因为兔子总是蹦蹦跳跳的嘛。游戏玩法机制上,当时还找了在做游戏策划的表妹探讨一番,无果~毕竟3D动作大厂的策划,方案我有点Hold不住啊。最后我找到了几年前的一个ios平台跳跃游戏《Yeah Bunny》,玩法上手难度不高,操作只有一个指令,虽然简单但又不失挑战性,完美符合我对自己游戏的设想:
Jump Double Jump Step On On Wall 截取自 Yeah Bunny 游戏画面
美术资源制作
由于是个人开发,时间也有限,在美术资源的选择上毫无疑问是小成本的像素绘画,近几年来许多独立游戏制作也都常使用像素风格,下限低同时上限也高,在大师手中同样能有精良的制作。
截图来自 Aseprite 官方宣传视频
这里我用 PhotoShop 完成了一部分游戏素材,虽然鼠标也可以操作,不过我还是更习惯用数位板,拿出了尘封已久的Wacom手绘板,给你们看看是真的厚厚灰尘。。
像素画的制作使用的是 Aseprite,在设计主角时我想到了小时候电视上的动画《虹猫蓝兔三千问》,于是参考了蓝兔形象配色设计了这个像素兔子:
动画制作
Aseprite 是一款开源的像素画软件,它最大的功能不只是绘制像素画,还能方便地调试动画,这里2D动画使用的是精灵图技术,和我们CSS上的雪碧图是一个原理。
在软件中可以像这样打上 Tag 调试动画,可以看到每一帧都是需要画的,越流畅的动画就需要绘制越多的关键帧,所以 2D 游戏的开发工作量有的时候可能比 3D 游戏还要大。
完成绘制后按 Ctrl + E 导出序列,就可以合成这样一张精灵图:
别看这么小张图,就已经花了我一整天时间。
地图制作
现在角色制作好了,我们需要一个给它活动的平台,如果在代码里编写平台地图,工作量是十分巨大的,所以下面介绍一个辅助的工具。
Tiled 是一个免费且开源的软件,它可以很方便地制作我们游戏里的地图,右上角区域“Layers”可以看做是图层列表,这里我主要使用两种图层,一种是 Tile Layer,用来可视化绘制整个地图,另一个图层是 Object Layer,这是一个纯对象图层,我用它来标记敌人的位置、红包/金币的初始位置等,一个地图制作完大概是这样的:
其中的 Tiles 我在 Photoshop 中简单地制作了一下:
最后保存导出的是一个 JSON 格式文件,这上面记录了整个地图的点位,游戏引擎中就可以依此渲染出地图场景了,所以可以看出它只是帮我们建立了一个个对象数据,场景中比如敌人和金币,都是有动画的,这里不应该直接渲染出来,这时我们就可以在代码中解析 Object Layer 对象,再根据这个对象就可以渲染对应的元素了。制作过程中需要改动地图、增加减少敌人金币,就只需要在软件中修改一下再保存就可以了~
在这张1080P的地图中,像素点大约为200万个,一个贴图像素为32 * 32也就是1024个像素,依次放置元素铺满地图就需要大约1900次,有了工具的辅助大大减少了机械式的工作。
音乐制作
对于像素风格游戏来说,配乐就肯定离不开 8-bit音乐。
在20世纪80年代时,游戏机的音效、音乐都依靠合成器芯片来制作,所以8-bit音乐也称为芯片音乐。早期电脑并没有如今能发出丰富声音的音响设备,只有能发出单一声音的蜂鸣器用作提示音,而程序员通过控制信号频率来让蜂鸣器发出不同的声音,用脉冲或是爆炸噪声来模拟鼓点,随着技术的发展和音乐人的努力,芯片音乐也逐渐变得丰富起来。
我使用的软件是 FamiStudio,这同样是一个免费且开源的软件,它的特点是界面简洁,当然对我来说上手难度还是有点太高了。。
我们知道空气的震动产生声波,人耳能听到的波形就是我们所理解的声音。
芯片音乐一般包括基本波形,如方波,锯齿波或三角波等。简单来讲快速制作一首8-bit音乐有以下几部分组成:
- 鼓点,也就是我们常说的“动次打次”,使用噪波
- 低音作曲部分,也就是贝斯旋律,通常用三角波、方形波
- 主旋律部分,和辅助旋律的制作
通常需要ABC三段来完成,每一段由如上几部分制作,一首完整的BGM就出来了,是不是狠简单。虽然我自认为身上还是有点音乐细胞细菌的,但掌握软件的基本使用就已经劝退我,这时游戏开发已经到了Day3,果断选择了放弃。
不过软件中自带了演示小样,我挑了比较适合的Demo直接就拿来游戏里用了~最后放张软件使用的截图给大家感受下:
到这里其实就告一段落了,游戏制作是挥洒创意的过程,如果只有代码无疑是单薄的,所以才有了上面这些尝试,我从几年前开始搜集各种小游戏素材与灵感,上面提到的软件也都是在这个过程中沉淀的,直到现在才重新发掘出来使用,制作了这个稍微完整的小游戏。
在游戏制作中游戏引擎是必不可少的,作为弱小的Web前端开发,我首选的肯定是 JavaScript 引擎,这里我主要使用的是 Phaser3 进行开发。在我的理解中,无论使用何种游戏引擎都是差不多的,只有配套的工具与生态不同,而美术音乐等资源则较为通用。
物理引擎
如果没有物理引擎,那么可以说直接失去了游戏开发的灵魂。
想象一下,当我们想控制角色起跳,代码上需要做什么事情?其实只需要设置一个弹跳力,但这样角色就会一直往天上飞去,所以又要设置一个重力,而如果往不同方向跳跃呢?就需要一个方向上的力,这个力的大小由行进速度控制:
我们通过控制三个变量设置不同数值,就能模拟物体真实运动,这背后就是物理引擎做的功劳。
在 Phaser 中自带了三种物理引擎,分别是:
Arcade:轻量级高性能AABB式物理碰撞系统(Axis-aligned Bounded Rectangles),译为轴对称盒子,只能以矩形框计算碰撞区域,精度低,运算速度快。
P2:功能更加强大,Arcade所不能实现的多边形碰撞区域、弹簧、摩擦力、碰撞材质、反弹系数等都可以实现,但必然会使运算复杂、耗费性能。
Ninja:可以实现平面、凹凸面、球面等的碰撞,物体在非平整面上碰撞时不会翻倒。
当然也可以引入外部的物理引擎比如 Matter.js,这里就不展开了。下面我们会使用到 ARCADE 物理引擎。
游戏原型案例
开始游戏制作前,先要搞个脚手架,让我们跳过造轮子的步骤,直接到 Github 上 clone
一个 phaser3 + typescript + vite 的模板:
git clone https://github.com/ourcade/phaser3-typescript-vite-template my-game
cd my-game
yarn / pnpm install
安装好依赖启动项目,就可以开始游戏的开发了。
main.js
为入口文件,在这里我们为 Phaser 进行一些配置:
import Phaser from 'phaser'
import demo from './Demo'
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO, // 可以指定 canvas或webGL渲染器
backgroundColor: '#333333',
// width: 640,
// height: 480,
scale: { // 设置了一些缩放的参数,主要使场景可以自动缩放到屏幕中看得更清楚
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
parent: 'app', // 对应我们页面的根节点
width: 640,
height: 480,
},
physics: {
default: 'arcade',
arcade: {
gravity: { y: 0 },
},
},
scene: [demo], // 场景列表,会按顺序加载
}
export default new Phaser.Game(config)
接下来我们创建 Demo.ts
,一个基本的游戏场景结构如下:
import Phaser from 'phaser'
export default class Demo extends Phaser.Scene {
constructor() {
super('Demo')
}
preload() {
// 预加载完成才会执行其它方法,可以调用load.on来捕获完成事件
this.load.on('complete', () => {
// ...TODO
})
}
create() { }
update() { }
}
其中 preload
create
update
为基本的生命周期。
现在随意创建一个 15 x 15
和 32 x 32
像素的图片:
在 Tiled 软件中编辑一个地图,比如画个掘金 Logo 和几堵墙
当然你也可以跳过地图绘制,上面导出的 JSON 数据 level.json
内容如下:
{ "compressionlevel":-1,
"editorsettings":
{
"export":
{
"target":"."
}
},
"height":15,
"infinite":false,
"layers":[
{
"data":[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2684354561, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 2684354561, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 2684354561, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 2684354561, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 2684354561, 0, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 2684354561, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 2684354561, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 2684354561, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 2684354561, 0, 0, 0, 0, 0, 0, 1, 1, 0, 2684354561, 1, 0, 0, 0, 0, 1, 2684354561, 2684354561, 1, 2684354561, 0, 0, 0, 0, 0, 0, 0, 2684354561, 1, 2684354561, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2684354561, 0, 0, 0, 0, 0, 0, 0, 0, 2684354561, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2684354561, 2684354561, 2684354561, 2684354561, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
"height":15,
"id":1,
"name":"layer01",
"opacity":1,
"type":"tilelayer",
"visible":true,
"width":20,
"x":0,
"y":0
}],
"nextlayerid":2,
"nextobjectid":1,
"orientation":"orthogonal",
"renderorder":"right-down",
"tiledversion":"1.3.4",
"tileheight":32,
"tilesets":[
{
"columns":1,
"firstgid":1,
"image":"tile.png",
"imageheight":32,
"imagewidth":32,
"margin":0,
"name":"tileset01",
"spacing":0,
"tilecount":1,
"tileheight":32,
"tilewidth":32
}],
"tilewidth":32,
"type":"map",
"version":1.2,
"width":20
}
接下来继续Demo.ts
代码的编写,其中逐行加入了注释:
import Phaser from 'phaser'
const gameOptions = {
playerGravity: 900, // 玩家重力
playerGrip: 100, // 墙壁摩擦力下的重力
playerSpeed: 200, // 行走速度
playerJump: 400, // 跳跃力
playerDoubleJump: 300, // 二段跳越力
}
export default class Demo extends Phaser.Scene {
map: any
layer: any
hero: any
canJump: boolean | undefined // 是否可跳跃
canDoubleJump: boolean | undefined // 是否可二连跳
onWall: boolean | undefined // 主角是否贴墙
constructor() {
super('Demo')
}
preload() {
this.load.tilemapTiledJSON('level', 'demo/level.json') // 加载地图
this.load.image('tile_demo', 'demo/tile.png') // 加载瓦片
this.load.image('hero_demo', 'demo/hero.png') // 加载主角贴图
}
create() {
// 解析地图
this.map = this.make.tilemap({
key: 'level',
})
// 启动物理碰撞
this.map.setCollision(1)
// 添加瓦片渲染到地图中
const tile = this.map.addTilesetImage('tileset01', 'tile_demo')
this.layer = this.map.createLayer('layer01', tile)
// 添加主角 开启 ARCADE 物理效果
this.hero = this.physics.add.sprite(420, 300, 'hero_demo')
// 设置移动速度
this.hero.body.velocity.x = gameOptions.playerSpeed
// 一些初始化
this.canJump = true
this.canDoubleJump = false
this.onWall = false
// 绑定点击输入事件,这里也可以直接用DOM事件
this.input.on('pointerdown', this.handleJump, this)
}
handleJump() {
// blocked.down 表示角色在地面,此时可以起跳,或者在墙面也可以起跳
if ((this.canJump && this.hero.body.blocked.down) || this.onWall) {
// 起跳中,应用弹跳力
this.hero.body.velocity.y = -gameOptions.playerJump
if (this.onWall) {
this.setPlayerXVelocity(true)
}
// 此时可以二段跳
this.canDoubleJump = true
} else if (this.canDoubleJump) {
// 进入二段跳,此时不可再跳跃
this.canDoubleJump = false
// this.canJump = false
// 应用二段跳的弹跳力
this.hero.body.velocity.y = -gameOptions.playerDoubleJump
}
}
update() {
// 初始化值
this.setDefaultValues()
// 处理体积碰撞 https://photonstorm.github.io/phaser3-docs/Phaser.Physics.Arcade.ArcadePhysics.html#collide__anchor
this.physics.world.collide(
this.hero,
this.layer,
(hero: any) => {
// 检查主角的碰撞 https://photonstorm.github.io/phaser3-docs/Phaser.Physics.Arcade.Body.html#blocked__anchor
const { down: blockedDown, left: blockedLeft, right: blockedRight } = hero.body.blocked
// 碰撞到任何东西时则不允许二段跳
this.canDoubleJump = false
// 玩家在地面时,则应该允许起跳
if (blockedDown) {
this.canJump = true
}
// 碰到右侧时翻转,碰到左侧时方向不需要翻转
hero.flipX = blockedRight ? true : blockedLeft ? false : hero.flipX
// 主角不在地面且贴着墙壁
if ((blockedRight || blockedLeft) && !blockedDown) {
this.onWall = true // 标记主角在墙上
hero.body.gravity.y = 0 // 去除重力
hero.body.velocity.y = gameOptions.playerGrip // 设置新的重力
}
// 改变玩家速度
this.setPlayerXVelocity(!this.onWall || blockedDown)
},
() => true, // 该回调在两个对象发生碰撞时对它们执行额外的检查,返回true才会继续上面的回调
this
)
}
// 一些初始化操作,应用在每次更新视图的时候
setDefaultValues() {
this.hero.body.gravity.y = gameOptions.playerGravity
this.onWall = false
this.setPlayerXVelocity(true)
}
// 设置玩家水平移动速度,这是向量,如果defaultDirection为false则翻转设置方向
setPlayerXVelocity(defaultDirection: boolean) {
this.hero.body.velocity.x = gameOptions.playerSpeed * (this.hero.flipX ? -1 : 1) * (defaultDirection ? 1 : -1)
}
}
游戏效果:
关于 Phaser3 官方也有一个详细中文教程可以学习,有任何想法都欢迎评论区交流~
结束
ok 那么整个游戏的制作历程就分享到这里,制作一款游戏实在不是件容易的事情,我也是花了不少时间才得以管中窥豹,真正开发一款完整的游戏实力还远远不够。文章的最后也祝愿大家在新的一年,阳没兔气(扬眉吐气),大展宏兔!
以上就是文章的全部内容了,感谢看到这里!本人知识水平有限,如有错误望不吝指正,如果觉得写得不错,对你有所帮助或启发,可以点赞收藏支持一下,也欢迎关注,我会更新更多实用的前端知识与技巧。我是茶无味de一天,希望与你共同成长~