大家好,我是公众号「线下聚会游戏」作者HullQin,开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏。
因为这需要使用物理引擎,自己手撸一个也挺累的,还得学一下物理。所以我就选了一个成熟的物理引擎:box2d,它是知名且历史悠久的物理引擎,著名游戏《愤怒的小鸟》就是基于box2d来开发的。当然,我要在浏览器中运行,需要选择js实现的版本。通过分析github的更新频率,我最终选择了box2d.ts。
该游戏还需要监听事件,直接用浏览器原生支持的dom API即可。
该游戏需要播放音效,我直接用了dom API的audio标签。
该游戏需要联机对战,我是有相关开发经验的,你可以看看我之前的文章《用86行代码写一个联机五子棋WebSocket后端》,结论是:联机对战的网页,最好用Web Socket来实现。这一次,我也使用 Web Socket。
此外,为了让两个玩家联机,肯定需要他们以某种方式联系起来,比如进入同一个房间(房间号相同)。参考文章《我做了个《联机桌游合集: UNO+斗地主+五子棋》无需下载,点开即玩!叫上朋友,即刻开局!不看广告,不做任务,享受「纯粹」的游戏!》,我之前做了一个联机游戏框架,是基于React的,实现了基本的进入房间、Web Socket通信能力,还内置了一些公共前端组件和样式。所以这次,我直接基于我的框架开发。
npm install pixi.js
然后是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里面。
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);
box2d!
初始化一个物理引擎的世界,它有一个y轴的重力加速度,我们模拟地球,设置为10。
const world = new b2.World({ x: 0, y: 10 });
注意:box2d世界中,所有的单位,都是米、千克、秒,这三个基本物理单位。并不是像素!它是真正的把物理公式代入到了引擎中。所以我们上面取了10,是因为现实中,重力加速度约等于9.8m/s2,约等于10m/s2。
但是我们展示,又是用的像素,所以需要一个Ratio,用于转换像素和米:
const Ratio = 35;
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是用于计算能否发生碰撞的一个属性。
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嘛?这里就是它的意义,你可以任意设定水果的大小,只要展示时缩放到对应大小就可以了。
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个水果了。
以上逻辑保证,点击哪里/触摸哪里,水果就创造再哪里的横坐标。
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。
但是物理引擎、渲染,都是我们手撸的!你可以随意修改参数,加你想加的功能!
这是2个浏览器,上面小的窗口,展示了对方的游戏界面,下面的大窗口,是自己的游戏界面。
双方通过Web Socket与服务器通信交换数据。
动作类游戏联机对战,最大的难题,就是实时数据同步。
world.Step物理模拟。假设我们有一个字符串str。如果str仅包含一个字符,例如str="1",则str[-1..1]返回1.但是如果str的size(length)比一个长,比如str="anythingelse",然后str[-1..1]返回""(空字符串)。为什么Ruby会这样解释字符串切片? 最佳答案 这种行为正是字符范围的工作方式。范围开始是-1,这是字符串中的最后一个字符。范围结束为1,即从开始算起的第二个位置。所以对于单字符字符串,这相当于0..1,也就是那个单个字符。对于双字符字符串,这是1..1,即第二个字符。对于三个字符的字符串,这是
在MichaelHartl的RailsTutorial中,许多示例使用expect()方法。这是cucumber步骤定义中的一个这样的例子:Then/^sheshouldseeherprofilepage$/doexpect(page).tohave_title(@user.name)end同样的例子可以写成同样的效果:Then/^sheshouldseeherprofilepage$/dopage.shouldhave_title(@user.name)end为什么要使用expect()?它增加了什么值(value)? 最佳答案
我正在阅读“Rails3Way”,在第39页,它显示了匹配:to=>重定向方法的代码示例。在该方法中存在以下代码。虽然我知道模对数字有什么作用,但我不确定下面的%是做什么的,因为路径和参数显然都不是数字。如果有人能帮助我理解%在这种情况下的用法,我将不胜感激。proc{|params|path%params} 最佳答案 这可能是String#%与其他语言中的sprintf非常相似的方法:'%05d'%10#=>"00010"它可以接受单个参数或数组:'%.3f%s'%[10.341412,'samples']#=>"10.341sa
我遇到了sinatracondition方法,但对它的工作原理感到困惑。我有一段代码:defauthuserconditiondoredirect'/login'unlessuser_logged_in?endend它检查用户是否登录了某些路由,示例路由:get'/',:auth=>:userdoerb:indexenduser_logged_in?方法定义在项目lib目录下的帮助文件中:defuser_logged_in?ifsession[:user]@user=session[:user]return@userendreturnnilend所以,问题是:conditionbloc
以VSTiTriforce为例,由Tweakbench提供。当加载到市场上的任何VST主机时,它允许主机向VSTi发送(大概是MIDI)信号。然后VSTi将处理该信号并输出由VSTi内的软件乐器创建的合成音频。例如,将A4(我相信是MIDI音符)发送到VSTi会导致它合成高于中央C的A。它将音频数据发送回VST主机,然后它可以在我的扬声器上播放或将其保存为.wav或其他一些音频文件格式。假设我有Triforce,我正在尝试用我选择的语言编写一个程序,它可以通过发送要合成的A4纸条与VSTi交互,并自动将其保存到系统上的文件?最终,我希望能够解析整个单轨MIDI文件(使用已经可用于此
我已经查看了thesedocs和谷歌,似乎无法找到.rewind的目的,以及它与.close的区别,在使用Tempfile.另外,为什么.read在倒带前返回一个空字符串?这是一个例子:file=Tempfile.new('foo')file.path#=>AuniquefilenameintheOS'stempdirectory,#e.g.:"/tmp/foo.24722.0"#Thisfilenamecontains'foo'initsbasename.file.write("helloworld")file.rewindfile.read#=>"helloworld"file.c
我正在观看来自PragProg的元编程视频,DaveThomas展示了这段代码:moduleMathclassfalseputsMath.is_even?2#=>true现在我明白这里发生了什么,但我不知道Math.is_even?的(num&1)部分究竟发生了什么?类方法。我知道这是一个按位运算,但仅此而已。有人可以向我解释那行代码是怎么回事吗?谢谢。 最佳答案 &是按位与运算符。执行(num&1)检查数字的最后一位(最低有效位)是否已设置。设置为奇数,不设置为偶数。这只是一种快速检查数字是偶数还是奇数的方法。您可以在此处查看ru
我正在阅读一些使用Rails4中关注点的代码。我看了一些文章说,如果我们想包含类方法使用模块ClassMethods,但我阅读的代码使用类似:class_methodsdodef****endend 最佳答案 ActiveSupport::Concern为模块混合的常见Ruby模式提供语法糖。当您使用modulesasmixins时你不能像在类中那样只使用self来声明类方法:moduleFoodefself.bar"HelloWorld"enddefinstance_method"HelloWorld"endendclassBaz
本文来自明道云资深研发经理孙伟,在明道云2022年秋季伙伴大会活动演讲,经校对编辑后整理为演讲精华。一、开放没有选择很多客户选择我们的一个重要原因,是明道云所能提供的产品开放能力。开放其实是没有选择的,坦白来讲,我也不希望开放,我希望客户所有的业务系统都用明道云管理,这样对我们是更有利的。但是,现实中因为企业业务的多样性与复杂性,没有任何一家平台可以做到一站式解决所有问题。为了方便客户,我们就必须开放自己,让客户用得更好。1.一个典型的业务场景需要多少个系统?我们先来看一个典型的业务场景。一个客户从网站上下了一个订单,经销商收到订单之后去向企业订货。企业订货之后如果自身不进行生产,就需要向供应
我有一个数组数组,像这样:[['1','2'],['a','b'],['x','y']]我需要将这些数组组合成一个字符串,其中包含所有三个集合的所有可能组合,仅向前。我已经看到很多以任何顺序排列的集合的所有可能组合的示例,这不是我想要的。例如,我不希望第一组中的任何元素出现在第二组之后,或者第三组中的任何元素出现在第一组或第二组之前,依此类推。因此,对于上面的示例,输出将是:['1ax','1ay','1bx','1by','2ax','2ay','2bx','2by']数组的数量和每组的长度是动态的。有人知道如何在Ruby中解决这个问题吗? 最佳答案