草庐IT

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

HullQin 2023-03-28 原文
大家好,我是公众号「线下聚会游戏」作者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 噢~我有空了会分享做游戏的相关技术。

有关《 合 成 大 西 瓜 》 重 制 版 !( 联 机 版 在 做 了 )的更多相关文章

  1. ruby - 为什么 Ruby 返回 `str[-1..1]` 它做了什么? - 2

    假设我们有一个字符串str。如果str仅包含一个字符,例如str="1",则str[-1..1]返回1.但是如果str的size(length)比一个长,比如str="anythingelse",然后str[-1..1]返回""(空字符串)。为什么Ruby会这样解释字符串切片? 最佳答案 这种行为正是字符范围的工作方式。范围开始是-1,这是字符串中的最后一个字符。范围结束为1,即从开始算起的第二个位置。所以对于单字符字符串,这相当于0..1,也就是那个单个字符。对于双字符字符串,这是1..1,即第二个字符。对于三个字符的字符串,这是

  2. ruby-on-rails - expect() 在 rspec/cucumber 中做了什么? - 2

    在MichaelHartl的RailsTutorial中,许多示例使用expect()方法。这是cucumber步骤定义中的一个这样的例子:Then/^sheshouldseeherprofilepage$/doexpect(page).tohave_title(@user.name)end同样的例子可以写成同样的效果:Then/^sheshouldseeherprofilepage$/dopage.shouldhave_title(@user.name)end为什么要使用expect()?它增加了什么值(value)? 最佳答案

  3. ruby-on-rails - % 在下面的代码中做了什么? - 2

    我正在阅读“Rails3Way”,在第39页,它显示了匹配:to=>重定向方法的代码示例。在该方法中存在以下代码。虽然我知道模对数字有什么作用,但我不确定下面的%是做什么的,因为路径和参数显然都不是数字。如果有人能帮助我理解%在这种情况下的用法,我将不胜感激。proc{|params|path%params} 最佳答案 这可能是String#%与其他语言中的sprintf非常相似的方法:'%05d'%10#=>"00010"它可以接受单个参数或数组:'%.3f%s'%[10.341412,'samples']#=>"10.341sa

  4. ruby - Sinatra::Base.condition 实际上做了什么? - 2

    我遇到了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

  5. ruby - 我将如何以编程方式与 VST(i) 插件交互以合成音频? - 2

    以VSTiTriforce为例,由Tweakbench提供。当加载到市场上的任何VST主机时,它允许主机向VSTi发送(大概是MIDI)信号。然后VSTi将处理该信号并输出​​由VSTi内的软件乐器创建的合成音频。例如,将A4(我相信是MIDI音符)发送到VSTi会导致它合成高于中央C的A。它将音频数据发送回VST主机,然后它可以在我的扬声器上播放或将其保存为.wav或其他一些音频文件格式。假设我有Triforce,我正在尝试用我选择的语言编写一个程序,它可以通过发送要合成的A4纸条与VSTi交互,并自动将其保存到系统上的文件?最终,我希望能够解析整个单轨MIDI文件(使用已经可用于此

  6. ruby-on-rails - .rewind 方法对 ruby​​ 中的 Tempfile 做了什么? - 2

    我已经查看了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

  7. Ruby:代码片段:(num & 1) == 0 究竟做了什么? - 2

    我正在观看来自PragProg的元编程视频,DaveThomas展示了这段代码:moduleMathclassfalseputsMath.is_even?2#=>true现在我明白这里发生了什么,但我不知道Math.is_even?的(num&1)部分究竟发生了什么?类方法。我知道这是一个按位运算,但仅此而已。有人可以向我解释那行代码是怎么回事吗?谢谢。 最佳答案 &是按位与运算符。执行(num&1)检查数字的最后一位(最低有效位)是否已设置。设置为奇数,不设置为偶数。这只是一种快速检查数字是偶数还是奇数的方法。您可以在此处查看ru

  8. ruby-on-rails - class_methods 在关注中做了什么? - 2

    我正在阅读一些使用Rails4中关注点的代码。我看了一些文章说,如果我们想包含类方法使用模块ClassMethods,但我阅读的代码使用类似:class_methodsdodef****endend 最佳答案 ActiveSupport::Concern为模块混合的常见Ruby模式提供语法糖。当您使用modulesasmixins时你不能像在类中那样只使用self来声明类方法:moduleFoodefself.bar"HelloWorld"enddefinstance_method"HelloWorld"endendclassBaz

  9. 为了开放互联,明道云做了十件事 - 2

    本文来自明道云资深研发经理孙伟,在明道云2022年秋季伙伴大会活动演讲,经校对编辑后整理为演讲精华。一、开放没有选择很多客户选择我们的一个重要原因,是明道云所能提供的产品开放能力。开放其实是没有选择的,坦白来讲,我也不希望开放,我希望客户所有的业务系统都用明道云管理,这样对我们是更有利的。但是,现实中因为企业业务的多样性与复杂性,没有任何一家平台可以做到一站式解决所有问题。为了方便客户,我们就必须开放自己,让客户用得更好。1.一个典型的业务场景需要多少个系统?我们先来看一个典型的业务场景。一个客户从网站上下了一个订单,经销商收到订单之后去向企业订货。企业订货之后如果自身不进行生产,就需要向供应

  10. ruby - 在 Ruby 中将数组的数组组合成所有可能的组合,仅向前 - 2

    我有一个数组数组,像这样:[['1','2'],['a','b'],['x','y']]我需要将这些数组组合成一个字符串,其中包含所有三个集合的所有可能组合,仅向前。我已经看到很多以任何顺序排列的集合的所有可能组合的示例,这不是我想要的。例如,我不希望第一组中的任何元素出现在第二组之后,或者第三组中的任何元素出现在第一组或第二组之前,依此类推。因此,对于上面的示例,输出将是:['1ax','1ay','1bx','1by','2ax','2ay','2bx','2by']数组的数量和每组的长度是动态的。有人知道如何在Ruby中解决这个问题吗? 最佳答案

随机推荐