写点什么

《 合 成 大 西 瓜 》 重 制 版 !( 联 机 版 在 做 了 )

作者:HullQin
  • 2022 年 8 月 25 日
    广东
  • 本文字数:7690 字

    阅读完需:约 25 分钟

《 合 成 大 西 瓜 》 重 制 版 !( 联 机 版 在 做 了 )

我是 HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者 HullQin 授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加 Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。

背景

夏天又到啦,又到了吃西瓜的季节!怎么能少了《合成大西瓜》这款又好玩又解压的小游戏呢?


2021 年,这款游戏风靡一时。


2022 年,我 HullQin(点开可关注我)自己写了一款《合成大西瓜》,但是加了一点点小功能:联机对战!


《合成大西瓜》重制单机版,点击这里马上体验!


原版《合成大西瓜》截图:


技术选型

大框架决策

参考我之前的文章《H5 小游戏技术选型分析,低代码?小游戏框架?canvas 或 SVG?还能用 React?》,基于文中的小游戏技术选型决策树来分析:



  1. 玩法有创新,需要联机,不能使用无代码方案的模板。

  2. 小游戏需要素材、音效、动画、物理引擎。

  3. 自己精力够多,没有外界产品给压力,不需要赶上线时间。


因此,我的选择是:使用现有的渲染库。

具体技术实现决策

因为这是一个 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 依赖:


npm install pixi.js
复制代码


然后是box2d.ts。作者只提供了 UMD 版本和 ts 源码,并没有发布 npm。


我们直接 copy ts 源码过来开发,这样类型提示友好,而且遇到不懂的地方直接看源码。打包时,也可以一起编译,也可以把 box2d.tx 作为 UMD 放在 html 的 head 里用 script 引入。这样每次编译的速度会快一些。


另外pixi.jsbox2d.ts就不必考虑 tree shaking 了,因为我测试了下,即使只引入必要的功能,他们体积还是那么大,干脆二者都用 UMD 引入固定版本吧,方便浏览器做缓存。

编写画布逻辑

参考app.tsmain.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,用于转换像素和米:


const Ratio = 35;
复制代码

创造墙壁

然后,我们需要创造墙壁,是一个长方形,我们用 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 个机制来展现对方的画面:


  1. 通过 Web Socket 传输所有水果的 ID、类型、坐标、移动速度。获取后,渲染在界面上。每 100ms 同步一次,可再动态调整。

  2. 构造一个对方的物理引擎世界,基于 Web Socket 获得的信息,继续模拟,使画面连续。


但是仅靠上面 2 个机制来模拟对方的画面,有时还是一卡一卡的,画面不连续。而且 100ms 同步一次,带宽消耗挺高的。

解决数据同步方案 v2

我思考了 v1 方案效果差的原因,得出 2 个痛点:


  1. 水果的自转速度没有传输,自转速度会影响碰撞后的方向,导致本地模拟和对面模拟的结果有差异。所以自转速度也应该纳入数据同步的一部分。

  2. 两个设备性能有差异时,不能依赖帧率来进行物理模拟。应该按照时间戳来模拟。这样哪怕浏览器的帧率有波动,双方的「时间」也应该是一致的。所以 loop 函数在联机对战中,需要修改,使之结合时间戳来做world.Step物理模拟。


此外每次全量同步水果数据,当水果变多,传输包体积太大,也会影响性能。


因此,基于以上的不足,改进后的方案 v2 如下:


  1. 通过 Web Socket 传输当前游戏时间戳、所有水果的 ID、类型、坐标、移动速度、自转速度。获取后,渲染在界面上。每 1000ms 同步一次。(减少了同步频率,节约带宽)

  2. 构造一个对方的物理引擎世界,基于 Web Socket 获得的信息,继续模拟,使画面连续。

  3. world.Step 频率随着毫秒时间戳变化,而不是固定的每次 requestAnimationFrame 时执行 3 次模拟。保证物理世界模拟的速度跟浏览器性能无关,也保证两位玩家时间同步。

解决数据同步方案 v3

此外,我给出了可选的 v3 方案,将来我会在 v2 和 v3 中挑选 1 个:


  1. 每次下落水果时,把当前游戏的时间戳、下落的水果类型和横坐标传输给服务器,设置批量发送机制,每 1000ms 至多发送一次。(带宽消耗降到最低)

  2. 每 5000ms 同步一次全量数据,避免极端情况两端物理模拟的差异。(如果将来实验发现没有差异,本步骤可以取消)

  3. 其它与方案 v2 相同。


最后,我还需要点时间,继续优化数据同步机制,争取把这个联机版《合成大西瓜》做出来,送给大家!


敬请期待!求关注~

写在最后

我是 HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者 HullQin 授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加 Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。

发布于: 刚刚阅读数: 4
用户头像

HullQin

关注

公众号【线下聚会游戏】 2020.10.07 加入

game.hullqin.cn 我做了一些联机桌游网页:支持2-10人联机的UNO、2-4人联机的斗地主、2人联机的五子棋。无需下载,点开即玩!叫上朋友,即刻开局!不看广告,不做任务,享受「纯粹」的游戏!

评论

发布
暂无评论
《 合 成 大 西 瓜 》 重 制 版 !( 联 机 版 在 做 了 )_CSS_HullQin_InfoQ写作社区