前言
本期将用vue3与pixijs复刻出童年在小霸王里面玩的游戏-猎鸭季节,当初玩它需要光线枪才行,非常不好瞄准,每当打中鸭子就非常激动,打不中就会有收到狗子的嘲笑,想想那时候快乐真的很简单。现在,就想办改造了一下,让他不用光线枪用鼠标也能击中它,圆童年一个梦。
废话不多说,我们先来康康展示效果怎样吧:
“
演示地址:http://jsmask.gitee.io/duck-hunt/
”
介绍
因为这个游戏大部分界面都是用pixi.js绘制出来的,本身代码量也是比较多的而且比较繁琐,不可能一次全讲完,所以本期主要讲解一下,它里面是如何进行加载,如何绘制界面,如何做游戏动画,如何进行击中判定,以及怎么适配屏幕尺寸等等。当最开始我们先来进行一些准备工作。
游戏规则
-
进入游戏每轮五回合,每回合会出现两只鸭子,和三发子弹。 -
每击中一直鸭子则奖励500分,若每轮击中全部鸭子则有特别奖励。 -
若子弹用尽或超时则鸭子都会飞走,请把握每一发子弹和珍惜时间。 -
每轮如果打中超过6只以上鸭子,才有资格晋级下一轮,最多三轮。
游戏流程
-
点击初始画面开始进入游戏界面。 -
每轮游戏开始出现猎犬过场动画。 -
猎犬过场动画后,游戏可进行射击操作。 -
每次点击鼠标,则表示发射子弹,点中鸭子则加分,点中鸭子,超时或子弹用光则鸭子加速飞走等逻辑。 -
游戏超过三轮或者未满足晋级条件,则强制退回初始画面并记录分数,可以重新开始。
主要技术
-
vite:负责整个项目的模块构建打包任务。 -
vue3:作为前端框架,方便完成一些界面的响应式、组件化等。 -
scss:负责初始加载界面的css动画,与一些界面比例调整的样式工作。 -
mitt.js:负责发布订阅的任务。 -
pixi.js:游戏引擎,游戏中绝大部分任务都在这里完成。 -
gsap.js:负责一些动画的操作。
游戏素材
这里为了缩小游戏本体尺寸也是为了更接近原作用Press Start 2P用了像素字体,声音统一做了压缩处理从wav转成mp3格式。而图片原本是一张大图这里看到的碎图是因为用了ShoeBox软件去完成了拆分,但是我没有做进一步处理图片(将它们尺寸统一等再进行TexturePackerGUI拼合),后面会讲明我用了另一种方法来处理这些动画。
开始
发布订阅
import mitt from "mitt";
const bus = {};
const emitter = mitt();
bus.$on = emitter.on;
bus.$off = emitter.off;
bus.$emit = emitter.emit;
export default bus;
因为vue3里面没有了off,所以我们使用mitt来去代替发布订阅这个任务,里面很多的状态改变的通知都借助于它来完成的。
文件结构
<template>
<div>
<Loading v-if="progress<100" :progress="progress" />
<DuckGame />
</div>
</template>
加载动画组件我之前写的一篇文章,就是拿它来完成加载动画的:WEB加载动画之像素字动画
DuckGame组件就是我们游戏的主容器了。
<template>
<div class="game" ref="canvas"></div>
</template>
new Game({
width,
height,
el: canvas.value,
resolution: 1,
onProgress: n => {
Bus.$emit("changeProgress", n);
}
}).init();
我们这里要把游戏容器传到Game中,生成一个实例,当然在你可以看到在onProgress中,发给加载动画组件通知当前的进度状况。
游戏场景
import { Container } from "pixi.js";
export default class Scene {
constructor(game) {
this.game = game;
this.stage = new Container();
this.stage.interactive = true;
this.stage.buttonMode = true;
this.stage.sortableChildren = true
this.stage.zIndex = 1
return this
}
onStart() { }
init() { }
show() {
this.stage.visible = true
}
hide() {
this.stage.visible = false
}
update(delta) {
if (!this.stage.visible) return;
}
}
游戏里所有的场景都继承了Scene,因为逻辑比较简单只涉及了开始界面和游戏界面两个场景,所有比较的简单,目前只有显示隐藏更新这些基础方法。
我们每当创建新界面就会有继承它,如开始界面:
import Scene from "./scene"
class StartScene extends Scene {
constructor(game) {
super(game)
this.topScore = null;
return this
}
}
export default StartScene
加载素材
因为我们用了vue3所以就借鸡下蛋,用了URL的方式去获取对应的素材。
export function getImageUrl(name, ext = "png") {
return new URL(`/src/assets/${name}.${ext}`, import.meta.url).href
}
然后进行配置:
const audioList = {
fire: getImageUrl("fire", "mp3"),
// ...more
}
const stage = getImageUrl("stage");
// ...more
export default {
stage,
...audioList,
// more
}
通过pixi.js中的Loader去完成加载任务,同时通知vue3加载动画组件当前的加载进度。同时还要将他们变成纹理图存储起来以方便后面pixi.js绘图使用。
export default class Game {
// ...
init() {
this.loaderTextures().then(res => {
Object.entries(res).forEach(([key, value]) => setTextures(key, value.texture))
this.render()
})
},
loaderTextures() {
const { loader, onProgress } = this;
return new Promise((resolve, reject) => {
Object.entries(assets).forEach(([key, value]) => loader.add(key, value, () => {
onProgress(loader.progress)
}))
loader.load((loader, resources) => {
onProgress(loader.progress)
resolve(resources)
})
})
},
reader(){
// 渲染界面
},
// ...
}
绘制界面
本作大部分的界面都是pixi.js中的绘图API来完成,主要是体力劳动,可以参考pixi.js官网的API来学习。这里制作简单的介绍,如下面的背景黑块绘制,和总积分绘制。
import { Text, Graphics, Container } from "pixi.js";
class StartScene extends Scene {
// ...
drawBg() {
const { width, height } = this.game;
const graphics = new Graphics();
graphics.beginFill(0x000000, 1);
graphics.drawRect(0, 0, width, height);
graphics.endFill();
this.stage.addChild(graphics)
}
drawTopScore(score = 0) {
const { width, height } = this.game;
this.topScore = new Text("top score = ".toUpperCase() + score, {
fontFamily: 'Press Start 2P',
fontSize: 24,
leading: 20,
fill: 0x66DB33,
align: 'center',
letterSpacing: 4
});
this.topScore.anchor.set(0.5, 0.5);
this.topScore.position.set(width / 2, height - 60)
this.stage.addChild(this.topScore)
}
}
export default StartScene
游戏动画
因为pixi.js并不是一个可视化的游戏引擎,所以为了更方便的制作游戏动画我们使用了gsap.js来代替。游戏里会出现的一些闪动的动画,如开始界面中的click to start the game这段文字按钮的闪动,利用SteppedEase缓动,看起来符合那个年代的味道。
import { TimelineMax } from "gsap"
let btnAni = new TimelineMax().fromTo(this.btn, { alpha: 0 }, { alpha: 1, duration: .45, immediateRender: true, ease: "SteppedEase(1)" });
btnAni.repeat(-1)
btnAni.yoyo(true);
当然涉及到更多的还有里面的帧动画,比如猎犬的搜寻,嘲笑,鸭子的飞行等等都是帧动画来完成的。pixi.js却也有帧动画执行的方案,但是我这里素材没有进一步处理所以取了个巧,还是用gsap.js的SteppedEase缓动模拟帧,这样的好处是每一帧都可以有方法去调节图片的位置去弥补图片大小不一产生的位移问题。
let dogSearchAni = new TimelineMax()
dogSearchAni
.from(dog, 0.16, { texture: getTextures("dog0"), ease: "SteppedEase(1)" })
.to(dog, 0.16, { texture: getTextures("dog1"), ease: "SteppedEase(1)" })
.to(dog, 0.16, { texture: getTextures("dog2"), ease: "SteppedEase(1)" })
.to(dog, 0.16, { texture: getTextures("dog3"), ease: "SteppedEase(1)" })
.to(dog, 0.2, { texture: getTextures("dog4"), ease: "SteppedEase(1)" })
dogSearchAni.repeat(-1)
dogSearchAni.play()
击中判定
判定有两种方式,第一种是包围盒检测,判定鼠标点击的点是否与鸭子存在重合,若重合则表示击中。第二种是pixi.js存在的pointerdown事件。这里偷了个懒,也防止一箭双雕的事件产生就用了pointerdown事件。当我们点击到鸭子之时,就改变当前该鸭子的状态表示击中。同时,我们的系统还会发出一个子弹事件,如果鸭子的isHit状态变成true并且isDie是false表示击中未死,那么就要执行显示分数,掉落死亡动画,最后销毁掉。
export default class Duck {
constructor({ dIndex = 0, x = 0, y = 0, speed = 3, direction = 1, stage, rect = [0, 0, 1200, 759] }) {
// ...
this.target = new Container();
// 点中改变状态
this.target.on("pointerdown", () => {
if (!this.isHit) this.isHit = true;
})
// 接收子弹事件
Bus.$on("sendBullet", ({ e, callback }) => {
if (this.isHit && !this.isDie) {
this.isDie = true;
this.hit();
this.duck_sound.play()
callback && callback(this)
}
})
// 接收飞走事件
Bus.$on("flyaway", () => {
this.isFlyaway = true;
})
return this;
}
move(delta) {
// 移动
}
async hit() {
// 击中
const { sprite, score, target } = this;
this.normalAni.kill();
sprite.texture = getTextures("duck_9")
sprite.width = getTextures("duck_9").width
sprite.height = getTextures("duck_9").height
showScore({
parent: this.stage,
score,
x: target.x - (this.vx < 0 ? + sprite.width : 0),
y: target.y
})
await wait(.35)
this.die()
}
die() {
// 死亡
}
fly() {
// 飞行
}
destroy() {
// 销毁
if (this.target.parent) {
this.target.parent.removeChild(this.target)
}
}
}
适配屏幕
为了让界面不变形的情况下最大程度显示出来,我用了一个取巧的方案,用了css的transform:scale+v-bind的方法,让vue计算出最大比例,然后绑定到css里面。
<script setup>
// ...
let width = 1200;
let height = 769;
const scale = `scale(${
window.innerHeight < window.innerWidth
? window.innerHeight / height
: window.innerWidth / width
})`;
</script>
<template>
<div class="game" ref="canvas"></div>
</template>
<style lang="scss" scoped>
.game {
transform: v-bind(scale);
cursor: none;
}
</style>
结语
总体来说,pixi.js还是非常强大的,处理此类的游戏再合适也不过了。如果场景界面非常多,动画也非常多的话,还是建议使用cocos creator,可以节省很多工作量。
本期的游戏也是我尽可能注意一些游戏细节做的,童年记忆从模糊也渐渐变得清晰了许多,希望各位童年也说过我长大以后要做游戏的小伙伴不忘初心,有时间把自己童年喜爱的游戏用自己的方式创作出来,也未尝不是一种技术上的锻炼和追忆童年无忧无虑生活的一种方式。
文章评论