网络日志

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

我是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 噢~我有空了会分享做游戏的相关技术。