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

Posted HullQin

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《 合 成 大 西 瓜 》 重 制 版 !( 联 机 版 在 做 了 )相关的知识,希望对你有一定的参考价值。

背景

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

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

以上是关于《 合 成 大 西 瓜 》 重 制 版 !( 联 机 版 在 做 了 )的主要内容,如果未能解决你的问题,请参考以下文章

足球队巡礼 - 英超西汉姆联

如何使用邓西百度网盘批量重命名工具对百度网盘中的文件进行批量改名删除等

34opencv入门重映射 & SURF特征点检测合辑

网页批量打印成PDF,并按条件合并成大PDF生成页码

鸿湖万联致远开发板正式合入OpenHarmony主干

鸿湖万联致远开发板正式合入OpenHarmony主干