我是 HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者 HullQin 授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加 Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。
背景
夏天又到啦,又到了吃西瓜的季节!怎么能少了《合成大西瓜》这款又好玩又解压的小游戏呢?
2021 年,这款游戏风靡一时。
2022 年,我 HullQin(点开可关注我)自己写了一款《合成大西瓜》,但是加了一点点小功能:联机对战!
《合成大西瓜》重制单机版,点击这里马上体验!
原版《合成大西瓜》截图:
技术选型
大框架决策
参考我之前的文章《H5 小游戏技术选型分析,低代码?小游戏框架?canvas 或 SVG?还能用 React?》,基于文中的小游戏技术选型决策树来分析:
玩法有创新,需要联机,不能使用无代码方案的模板。
小游戏需要素材、音效、动画、物理引擎。
自己精力够多,没有外界产品给压力,不需要赶上线时间。
因此,我的选择是:使用现有的渲染库。
具体技术实现决策
因为这是一个 2D 游戏,所以我选择了 2D 渲染库pixi.js。可以用它来渲染游戏界面、动画等。
因为这需要使用物理引擎,自己手撸一个也挺累的,还得学一下物理。所以我就选了一个成熟的物理引擎:box2d,它是知名且历史悠久的物理引擎,著名游戏《愤怒的小鸟》就是基于 box2d 来开发的。当然,我要在浏览器中运行,需要选择 js 实现的版本。通过分析 github 的更新频率,我最终选择了box2d.ts。
该游戏还需要监听事件,直接用浏览器原生支持的 dom API 即可。
该游戏需要播放音效,我直接用了 dom API 的 audio 标签。
该游戏需要联机对战,我是有相关开发经验的,你可以看看我之前的文章《用 86 行代码写一个联机五子棋 WebSocket 后端》,结论是:联机对战的网页,最好用 Web Socket 来实现。这一次,我也使用 Web Socket。
此外,为了让两个玩家联机,肯定需要他们以某种方式联系起来,比如进入同一个房间(房间号相同)。参考文章《我做了个《联机桌游合集: UNO+斗地主+五子棋》无需下载,点开即玩!叫上朋友,即刻开局!不看广告,不做任务,享受「纯粹」的游戏!》,我之前做了一个联机游戏框架,是基于 React 的,实现了基本的进入房间、Web Socket 通信能力,还内置了一些公共前端组件和样式。所以这次,我直接基于我的框架开发。
先手撸单机版
下载依赖
现在我们不必手动配置 Webpack 脚手架了,可以直接使用 vite 开发!
使用 React+ts 模版,然后把 react 这个依赖删掉,就初始化项目成功了~
然后安装 pixi 依赖:
然后是box2d.ts。作者只提供了 UMD 版本和 ts 源码,并没有发布 npm。
我们直接 copy ts 源码过来开发,这样类型提示友好,而且遇到不懂的地方直接看源码。打包时,也可以一起编译,也可以把 box2d.tx 作为 UMD 放在 html 的 head 里用 script 引入。这样每次编译的速度会快一些。
另外pixi.js
和box2d.ts
就不必考虑 tree shaking 了,因为我测试了下,即使只引入必要的功能,他们体积还是那么大,干脆二者都用 UMD 引入固定版本吧,方便浏览器做缓存。
编写画布逻辑
参考app.ts
和main.ts
。
import { Application } from 'pixi.js';
const Width = 704;
const Height = 1408;
const app = new Application({
width: Width,
height: Height,
antialias: true,
backgroundColor: 0xffe89d,
});
document.getElementById('root')!.appendChild(app.view);
复制代码
这样,就设置了 canvas 的宽、高、背景色,并开启了反锯齿选项。然后把这个 canvas 添加到了 id 为 root 的元素的 children 里面。
加载图片资源
先定义好图片资源的常量:
name 表示图片名字,也是图片的地址,将会去这个路径下载图片资源。
radius 是个自定义的参数,表示它在游戏中的半径。
imgRadius 也是个自定义的参数,表示它的图片的半径。因为图片比例可能不合适,我们可能要缩放图片,所以定义在这里,方便修改参数。imgRadius 不能改,就是图片的真实半径像素,可能会修改的是 radius。
const Fruits = [
{ name: '/fruits/fruit_1.png', radius: 26, imgRadius: 26 },
{ name: '/fruits/fruit_2.png', radius: 39, imgRadius: 39 },
{ name: '/fruits/fruit_3.png', radius: 54, imgRadius: 54 },
{ name: '/fruits/fruit_4.png', radius: 59.5, imgRadius: 59.5 },
{ name: '/fruits/fruit_5.png', radius: 76, imgRadius: 76 },
{ name: '/fruits/fruit_6.png', radius: 91.5, imgRadius: 91.5 },
{ name: '/fruits/fruit_7.png', radius: 100, imgRadius: 93 },
{ name: '/fruits/fruit_8.png', radius: 115, imgRadius: 129 },
{ name: '/fruits/fruit_9.png', radius: 130, imgRadius: 154 },
{ name: '/fruits/fruit_10.png', radius: 140, imgRadius: 151 },
{ name: '/fruits/fruit_11.png', radius: 150, imgRadius: 202 },
];
复制代码
是使用pixi.js
的 Loader 来加载的,加载完毕后,可以执行一个回调函数(假设我们提前定义了回调函数是init
)。
import { Loader } from '@pixi/loaders';
const images = Fruits.map((i) => i.name);
document.getElementById('root')!.appendChild(app.view);
Loader.shared.add(images).load(init);
复制代码
构造物理引擎世界
在初始化函数 init 中,要做什么呢?
当然是要构造一个属于我们的物理引擎世界!使用box2d
!
初始化一个物理引擎的世界,它有一个 y 轴的重力加速度,我们模拟地球,设置为 10。
const world = new b2.World({ x: 0, y: 10 });
复制代码
注意:box2d 世界中,所有的单位,都是米、千克、秒,这三个基本物理单位。并不是像素!它是真正的把物理公式代入到了引擎中。所以我们上面取了 10,是因为现实中,重力加速度约等于 9.8m/s2,约等于 10m/s2。
但是我们展示,又是用的像素,所以需要一个Ratio
,用于转换像素和米:
创造墙壁
然后,我们需要创造墙壁,是一个长方形,我们用 ChainShape 创造一个闭环:
const createWall = () => {
const wallBodyDef = new b2.BodyDef();
const wallFixtureDef = new b2.FixtureDef();
wallBodyDef.type = b2.staticBody;
wallFixtureDef.density = 0;
wallFixtureDef.friction = 0.2;
wallFixtureDef.restitution = 0.3;
wallFixtureDef.filter.groupIndex = -20;
wallFixtureDef.shape = new b2.ChainShape().CreateLoop([
{ x: 0, y: 0 / Ratio },
{ x: 0, y: Height / Ratio },
{ x: Width / Ratio, y: Height / Ratio },
{ x: Width / Ratio, y: 0 / Ratio },
]);
const wallBody = world.CreateBody(wallBodyDef);
wallBody.CreateFixture(wallFixtureDef);
wallBody.SetUserData({ type: -1 });
};
复制代码
其中 density 是墙壁的密度,它不需用动,所以不需要密度。friction 是摩擦力。restitution 是弹性数值(符合物理规律的弹性是 0-1,0 表示没弹性,1 表示碰撞时不会有任何动量损失,如果你设置的比 1 大,就不能量守恒啦,会越撞越快!)groupIndex 是用于计算能否发生碰撞的一个属性。
创造水果
设置一个 fruitId,每次有新水果,Id 要自增。
fruits 则存储了本局所有的水果。key 就是 Id。这里使用了对象,而非数组,是因为相同水果碰撞后,某水果就消失了,这样数组就不连续了,不太方便,我们也不希望水果的 Id 发生改变。所以就用了对象。水果消失时,delete 就好。
生成水果时,纵坐标时固定的,横坐标可以传入,不传则位于中间。横坐标需要做个极限判断,以防它超出我们的墙壁。
import { Sprite } from '@pixi/sprite';
import { Loader } from '@pixi/loaders';
let fruitId = 0;
const fruitDefaultY = 204 / Ratio;
const fruits: {[key: string]: {body: b2Body, sprite: Sprite}} = {};
// 定义好所有种类水果的物理性质
const fruitBodyDef = new b2.BodyDef();
fruitBodyDef.type = b2.dynamicBody;
fruitBodyDef.position.Set(Width / 2 / Ratio, fruitDefaultY);
const fruitFixtureDefs = Fruits.map((fruit, index) => {
const fixtureDef = new b2.FixtureDef();
fixtureDef.density = 0.1;
fixtureDef.friction = 0.2;
fixtureDef.restitution = 0.3;
fixtureDef.shape = new b2.CircleShape(fruit.radius / Ratio);
fixtureDef.filter.groupIndex = 1;
return fixtureDef;
});
// 生成一个水果
const createFruit = (id: number, x = Width / 2) => {
let newX = x;
if (x < 5) newX = 5;
if (x > Width - 5) newX = Width - 5;
const fruit = Fruits[id];
const fruitBody = world.CreateBody(fruitBodyDef);
fruitBody.SetSleepingAllowed(true);
fruitBody.SetPositionXY(newX / Ratio, fruitDefaultY);
fruitBody.CreateFixture(fruitFixtureDefs[id]);
fruitBody.SetUserData({ type: id, id: fruitId });
const sprite = new Sprite();
sprite.anchor.set(0.5);
sprite.x = -299;
sprite.y = -299;
sprite.texture = Loader.shared.resources[fruit.name].texture!;
sprite.scale.set(fruit.radius / Fruits[id].imgRadius);
app.stage.addChild(sprite);
fruits[fruitId++] = { body: fruitBody, sprite };
};
复制代码
设置 SetSleepingAllowed 是为了提高性能。SetUserData 存了我们的自定义数据给水果。
生成水果时,body 只是物理引擎中记录的数据。我们还需要展示给用户,需要用pixi.js
的 Sprite 来实现。
初始,先把 Sprite 定义到看不到的位置(-299,-299),之后物理引擎模拟后,再把它放到正确的位置,这是为了避免水果重叠时,物理引擎会闪现移动水果,避免重叠,这样用户体验会闪烁,所以初始先隐藏水果是最好的。毕竟,可能再过 0.17 秒,它就出现啦,不必担心这一点时间的损失。
注意 sprite.scale.set,这是设置了图片的缩放。记得上面定义的 radius 和 imgRadius 嘛?这里就是它的意义,你可以任意设定水果的大小,只要展示时缩放到对应大小就可以了。
增加点击事件
我们要兼容 PC 端 click 和移动端端 touchend,来创造水果:
const canvas = document.getElementsByTagName('canvas')[0];
canvas.addEventListener('touchend', (event) => {
const { changedTouches } = event;
if (changedTouches.length !== 1) return;
const left = parseFloat(getComputedStyle(rootElement).marginLeft);
const { clientX } = changedTouches[0];
createFruit(Math.floor(3.99 * Math.random()), (clientX - left) / 0.625);
});
canvas.addEventListener('click', (event) => {
if ('ontouchend' in window) return;
const { offsetX } = event;
createFruit(Math.floor(3.99 * Math.random()), offsetX);
});
复制代码
其中,针对TouchEvent
是可以直接用changedTouches[0].globalX
这个属性的,但是这个似乎不是标准的属性,所以我用 clienX 换算了一下。
if ('ontouchend' in window) return;
这句话是为了防止再移动端,同时触发 click 事件和 touchend 事件。这样可能一次就扔 2 个水果了。
以上逻辑保证,点击哪里/触摸哪里,水果就创造再哪里的横坐标。
让画面动起来
生成好基本的墙壁和水果后,我们就可以开始模拟我们的物理世界啦!
需要在 init 函数中调用一次 loop(),之后 loop 就会递归调用自己。
const TimeStep = 1 / 120;
const VelocityIterations = 10;
const PositionIterations = 10;
const loop = () => {
world.Step(TimeStep, VelocityIterations, PositionIterations);
world.Step(TimeStep, VelocityIterations, PositionIterations);
world.Step(TimeStep, VelocityIterations, PositionIterations);
Object.keys(fruits).forEach((id) => {
const fruit = fruits[id];
const { body, sprite } = fruit;
const { x, y } = body.GetPosition();
const angle = body.GetAngle();
sprite.x = x * Ratio;
sprite.y = y * Ratio;
sprite.rotation = angle;
});
requestAnimationFrame(loop);
};
复制代码
你知道requestAnimationFrame吗?这个在按帧渲染的场合(动画更新频繁)非常有用!它的意思是:告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
每次浏览器绘制,都要执行一遍 loop 函数,loop 作用是:
调用world.Setp
,使当前的物理世界模拟走过 TimeStep 秒。TimeStep 越小,越精确,后面两个参数是循环次数,越多越精确。当然太多次循环,性能也会有所损耗。
这里我们连续模拟 3 次 1/120 秒,再渲染一次,是比模拟 1 次 1/40 秒渲染一次更精确的,因为计算量更大了。亲测,误差更小了,更真实了,也很流畅。如果模拟 1 次 1/120 秒再渲染一次,会感觉画面卡卡的,很慢。
现在,游戏已经可以玩啦!
相同水果碰撞检测
import * as b2 from '../b2';
b2.ContactListener.prototype.PreSolve = (contact) => {
const a = contact.GetFixtureA().GetBody().GetUserData();
const b = contact.GetFixtureB().GetBody().GetUserData();
if (a.type !== b.type || a.type >= 10) return;
const minId = Math.min(a.id, b.id);
const maxId = Math.max(a.id, b.id);
const contactedFruit = contactedFruits.get(minId);
if (!contactedFruit) {
if (mergingFruitSet.has(minId) || mergingFruitSet.has(maxId)) return;
contactedFruits.set(minId, maxId);
mergingFruitSet.add(minId);
mergingFruitSet.add(maxId);
contact.SetEnabled(false);
return;
}
if (contactedFruit === maxId) {
contact.SetEnabled(false);
}
};
复制代码
如果遇到相同的 2 个水果,就contact.SetEnabled(false);
,表明他们不会再碰撞了,并且记录下来。
之后在 world.Step 之后,判断一下碰撞的水果是哪些,把下面的水果变大,上面的水果删掉,就相当于:2 个小水果合并成一个大水果啦!
const doWithContactedFruits = () => {
contactedFruits.forEach((maxId, minId) => {
let top = fruits[maxId];
let bottom = fruits[minId];
if (top.body.GetPosition().y > bottom.body.GetPosition().y) {
const mid = top;
top = bottom;
bottom = mid;
}
bottom.body.DestroyFixture(bottom.body.GetFixtureList()!);
const data = bottom.body.GetUserData();
bottom.body.CreateFixture(fruitFixtureDefs[data.type + 1]);
bottom.body.SetUserData({ ...data, type: data.type + 1 });
mergingFruitSet.delete(minId);
mergingFruitSet.delete(maxId);
delete fruits[top.body.GetUserData().id];
world.DestroyBody(top.body);
app.stage.removeChild(top.sprite);
const newFruit = Fruits[data.type + 1];
bottom.sprite.texture = Loader.shared.resources[newFruit.name].texture!;
bottom.sprite.scale.set(newFruit.radius / newFruit.imgRadius);
});
contactedFruits.clear();
};
复制代码
loop 函数增加这个doWithContactedFruits
函数的调用:
const loop = () => {
world.Step(TimeStep, VelocityIterations, PositionIterations);
doWithContactedFruits();
world.Step(TimeStep, VelocityIterations, PositionIterations);
doWithContactedFruits();
world.Step(TimeStep, VelocityIterations, PositionIterations);
doWithContactedFruits();
// ...
复制代码
至此,简易的《合成大西瓜》单机版就做完啦!
它目前是个初版:无动画、无音效、瞬间合成、随机出现水果;基于 pixi.js、box2d.ts 和 vite。
但是物理引擎、渲染,都是我们手撸的!你可以随意修改参数,加你想加的功能!
源码 + 体验地址
Github: https://github.com/HullQin/make-watermelon
体验地址: https://game.hullqin.cn/dxg
待优化
合成不应该是瞬间的,应该要持续一小会儿,让两个水果慢慢靠近,再炸掉。要展示动画。要播放音效。
【6 月 17 日的版本中已完成该优化】鼠标点击后不应该随机生成。应该像俄罗斯方块那样,先展示当前要下落的水果,再提示下一个水果,这样可以提高技术成分,降低运气成分。
再搞搞联机版
内测画面抢先看:
这是 2 个浏览器,上面小的窗口,展示了对方的游戏界面,下面的大窗口,是自己的游戏界面。
双方通过 Web Socket 与服务器通信交换数据。
动作类游戏联机对战,最大的难题,就是实时数据同步。
解决数据同步方案 v1
第一个版本,我运用了 2 个机制来展现对方的画面:
通过 Web Socket 传输所有水果的 ID、类型、坐标、移动速度。获取后,渲染在界面上。每 100ms 同步一次,可再动态调整。
构造一个对方的物理引擎世界,基于 Web Socket 获得的信息,继续模拟,使画面连续。
但是仅靠上面 2 个机制来模拟对方的画面,有时还是一卡一卡的,画面不连续。而且 100ms 同步一次,带宽消耗挺高的。
解决数据同步方案 v2
我思考了 v1 方案效果差的原因,得出 2 个痛点:
水果的自转速度没有传输,自转速度会影响碰撞后的方向,导致本地模拟和对面模拟的结果有差异。所以自转速度也应该纳入数据同步的一部分。
两个设备性能有差异时,不能依赖帧率来进行物理模拟。应该按照时间戳来模拟。这样哪怕浏览器的帧率有波动,双方的「时间」也应该是一致的。所以 loop 函数在联机对战中,需要修改,使之结合时间戳来做world.Step
物理模拟。
此外每次全量同步水果数据,当水果变多,传输包体积太大,也会影响性能。
因此,基于以上的不足,改进后的方案 v2 如下:
通过 Web Socket 传输当前游戏时间戳、所有水果的 ID、类型、坐标、移动速度、自转速度。获取后,渲染在界面上。每 1000ms 同步一次。(减少了同步频率,节约带宽)
构造一个对方的物理引擎世界,基于 Web Socket 获得的信息,继续模拟,使画面连续。
world.Step 频率随着毫秒时间戳变化,而不是固定的每次 requestAnimationFrame 时执行 3 次模拟。保证物理世界模拟的速度跟浏览器性能无关,也保证两位玩家时间同步。
解决数据同步方案 v3
此外,我给出了可选的 v3 方案,将来我会在 v2 和 v3 中挑选 1 个:
每次下落水果时,把当前游戏的时间戳、下落的水果类型和横坐标传输给服务器,设置批量发送机制,每 1000ms 至多发送一次。(带宽消耗降到最低)
每 5000ms 同步一次全量数据,避免极端情况两端物理模拟的差异。(如果将来实验发现没有差异,本步骤可以取消)
其它与方案 v2 相同。
最后,我还需要点时间,继续优化数据同步机制,争取把这个联机版《合成大西瓜》做出来,送给大家!
敬请期待!求关注~
写在最后
我是 HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者 HullQin 授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加 Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。
评论