大家好,欢迎来到IT知识分享网。
这个简单的教程将引导您了解如何使用 Three.js 创建俄罗斯方块游戏。
每天分享最新软件开发,Devops,敏捷,测试以及项目管理最新,最热门的文章,每天花3分钟学习何乐而不为,希望大家点赞,评论,加关注,你的支持是我最大的动力。下方抖音有我介绍自动化测试,以及google cloud 相关视频课程,欢迎观看。
想想我们玩俄罗斯方块的方式。当块移动时,我们可以自由地变换和旋转它。制作块的立方体清晰地连接起来,并且它们在代码中的表示也应该是直观的。另一方面,当我们尝试完成一个切片(在 2D 中,一行)并且我们成功时,立方体被移除,此时作为它们的原点的块并不重要。事实上,这无关紧要——一个块中的一些盒子可能会被移除,而另一些则不会。
追踪一个盒子的起源需要不断地分割和合并几何图形,相信我,那将是一个疯狂的混乱。在原始的 2D 俄罗斯方块中,有时正方形的颜色是原始块的指示符。然而,在 3D 中,我们需要一种快速的方式来显示 z 轴,而颜色非常适合这一点。
在我们的游戏中,立方体在动态时将连接,而在非动态时将连接。
添加静态块
让我们从移动块接触地板(或另一个块)的那一刻开始。移动块(具有几个立方体的合并几何)被转换为不再移动的静态分离立方体。将这些立方体保存在 3D 数组中很方便。
Tetris.staticBlocks = []; Tetris.zColors = [ 0x6666ff, 0x66ffff, 0xcc68EE, 0x, 0x66ff66, 0x9966ff, 0x00ff66, 0x66EE33, 0x003399, 0x, 0xFFA500, 0x99ff00, 0xee1289, 0x71C671, 0x00BFFF, 0x, 0x, 0x9966ff ]; Tetris.addStaticBlock = function(x,y,z) { if(Tetris.staticBlocks[x] === undefined) Tetris.staticBlocks[x] = []; if(Tetris.staticBlocks[x][y] === undefined) Tetris.staticBlocks[x][y] = []; var mesh = THREE.SceneUtils.createMultiMaterialObject(new THREE.CubeGeometry( Tetris.blockSize, Tetris.blockSize, Tetris.blockSize), [ new THREE.MeshBasicMaterial({color: 0x000000, shading: THREE.FlatShading, wireframe: true, transparent: true}), new THREE.MeshBasicMaterial({color: Tetris.zColors[z]}) ] ); mesh.position.x = (x - Tetris.boundingBoxConfig.splitX/2)*Tetris.blockSize + Tetris.blockSize/2; mesh.position.y = (y - Tetris.boundingBoxConfig.splitY/2)*Tetris.blockSize + Tetris.blockSize/2; mesh.position.z = (z - Tetris.boundingBoxConfig.splitZ/2)*Tetris.blockSize + Tetris.blockSize/2; mesh.overdraw = true; Tetris.scene.add(mesh); Tetris.staticBlocks[x][y][z] = mesh; };
这里有很多要解释的。
颜色和材料
Tetris.zColors 保留了指示立方体在 z 轴上位置的颜色列表。我想要一个好看的立方体,所以它应该有一个颜色和轮廓边框。我将使用 Three.js 教程中不太流行的东西——multiMaterials。Three.js SceneUtils 中有一个函数,它接受一个几何体和一个材质数组(注意括号 [])。如果您查看 Three.js 源代码:
createMultiMaterialObject : function ( geometry, materials ) { var i, il = materials.length, group = new THREE.Object3D(); for ( i = 0; i < il; i ++ ) { var object = new THREE.Mesh( geometry, materials[ i ] ); group.add( object ); } return group; },
这是一个非常简单的 hack,可以为每种材料创建一个网格。使用纯 WebGL 有更好的方法来实现相同的结果(fe 调用 draw 两次,一次使用 gl.LINES,第二次使用 gl.something),但此函数的通常用途是例如同时合并纹理和材质时间 – 不是不同类型的绘图。
在 3D 空间中的位置
现在,为什么这个位置看起来像这样?
mesh.position.x = (x - Tetris.boundingBoxConfig.splitX/2)*Tetris.blockSize + Tetris.blockSize/2;
我们在 init 上的板中心放置在 (0,0,0) 点。这不是一个很好的位置,因为这意味着一些立方体将具有负位置,而其他立方体将具有正位置。在我们的例子中,最好指定一个对象的角。此外,我们希望将框的位置视为从 1 到 6 或至少 0 到 5 的离散值。 Three.js(以及 WebGL、OpenGL 和其他所有内容)使用其自己的单位,这些单位与米或像素。如果你还记得,在配置中,我们输入了一个值
Tetris.blockSize = boundingBoxConfig.width/boundingBoxConfig.splitX;
那是负责转换的。总结一下:
// transform 0-5 to -3 - +2 (x - Tetris.boundingBoxConfig.splitX/2) // scale to Three.js units *Tetris.blockSize // we specify cube center, not a corner - we have to shift position + Tetris.blockSize/2
不错的测试
我们的游戏仍然非常静态,但您可以打开控制台并运行:
var i = 0, j = 0, k = 0, interval = setInterval(function() {if(i==6) {i=0;j++;} if(j==6) {j=0;k++;} if(k==6) {clearInterval(interval); return;} Tetris.addStaticBlock(i,j,k); i++;},30)
它应该用立方体填充板子的动画。
保持分数
一个保持分数的小实用函数:
Tetris.currentPoints = 0; Tetris.addPoints = function(n) { Tetris.currentPoints += n; Tetris.pointsDOM.innerHTML = Tetris.currentPoints; Cufon.replace('#points'); }
准备
首先,创建一个新文件来保存我们的块对象并将其包含在 index.html 中。该文件应以:
window.Tetris = window.Tetris || {}; // equivalent to if(!window.Tetris) window.Tetris = {};
这样,即使文件解析顺序受到某种干扰(这不太可能,顺便说一句),您也永远不会覆盖现有对象或使用未定义的变量。此时,您可能还想替换var Tetris = {};我们主文件中的“”声明。
在继续之前,我们需要一个效用函数。
Tetris.Utils = {}; Tetris.Utils.cloneVector = function (v) { return {x: v.x, y: v.y, z: v.z}; };
要理解为什么我们需要它,我们必须谈谈 JS 中的变量。如果我们使用一个数字,它总是按值传递。这意味着写作:
var a = 5; var b = a;
将数字 5 放在 b 中,但无论如何它都不会与 a 相关。但是,在使用对象时:
var a = (x: 5}; var b = a;
b 是对对象的引用。使用 bx = 6; 将写入 a 引用的同一个对象。
这就是为什么我们需要一种方法来创建向量的副本。简单的 v1 = v2 意味着我们的记忆中只有一个向量。但是,如果我们直接访问向量的数字部分并进行克隆,我们将有两个向量并且操作它们将是独立的。
最后的准备工作是定义形状。
Tetris.Block = {}; Tetris.Block.shapes = [ [ {x: 0, y: 0, z: 0}, {x: 1, y: 0, z: 0}, {x: 1, y: 1, z: 0}, {x: 1, y: 2, z: 0} ], [ {x: 0, y: 0, z: 0}, {x: 0, y: 1, z: 0}, {x: 0, y: 2, z: 0}, ], [ {x: 0, y: 0, z: 0}, {x: 0, y: 1, z: 0}, {x: 1, y: 0, z: 0}, {x: 1, y: 1, z: 0} ], [ {x: 0, y: 0, z: 0}, {x: 0, y: 1, z: 0}, {x: 0, y: 2, z: 0}, {x: 1, y: 1, z: 0} ], [ {x: 0, y: 0, z: 0}, {x: 0, y: 1, z: 0}, {x: 1, y: 1, z: 0}, {x: 1, y: 2, z: 0} ] ];
请注意,每个形状的第一个立方体都是 (0,0,0)。这非常重要,将在下一节中解释。
形状生成
描述块的三个值:基本形状、位置和旋转。在这一点上,我们应该提前考虑我们想要如何检测碰撞。
根据我的经验,我可以看出游戏中的碰撞检测总是或多或少是假的。一切都与性能有关——几何形状被简化,特定情况下的碰撞首先被排除在外,一些碰撞根本不考虑,碰撞响应几乎总是不准确的。没关系——如果它看起来很自然,没人会注意到,我们节省了大量宝贵的 CPU 周期。
那么,俄罗斯方块最简单的碰撞检测是什么?所有形状都是轴对齐的立方体,中心位于指定的一组点中。我有 99% 的把握为棋盘上的每个位置保留一组值 [FREE, MOVING, STATIC] 是处理它的最佳方式。这样,如果我们想要移动一个形状并且它需要的空间已经被占用了——我们就会发生碰撞。复杂性:O(形状中的立方体数)<=> O(1)。嘘!
现在,我知道轮换非常复杂,我们应该尽可能避免它。这就是我们将块的基本形状保持为旋转形式的原因。这样我们可以只应用位置(这很简单)并快速检查我们是否有碰撞。在我们的情况下实际上并不重要,但在更复杂的游戏中会如此。没有足够小的游戏可以以懒惰的方式进行编程。
关于位置和旋转——这两个都在 Three.js 中使用。然而,问题是我们在 Three.js 和我们的开发板上使用了不同的单元。为了使我们的代码简单,我们将单独存储位置。旋转在任何地方都是相同的,所以我们将使用内置的。
首先,我们随机取一个形状并创建一个副本。这就是我们需要 cloneVector 函数的原因。
Tetris.Block.position = {}; Tetris.Block.generate = function() { var geometry, tmpGeometry; var type = Math.floor(Math.random()*(Tetris.Block.shapes.length)); this.blockType = type; Tetris.Block.shape = []; for(var i = 0; i < Tetris.Block.shapes[type].length; i++) { Tetris.Block.shape[i] = Tetris.Utils.cloneVector(Tetris.Block.shapes[type][i]); }
现在我们需要连接所有的立方体以作为一个形状。
有一个 Three.js 函数 – 它需要一个几何图形和一个网格并将它们合并。这里实际发生的是内部顶点数组的合并。它考虑了合并几何的位置。这就是为什么我们需要第一个立方体为 (0,0,0) 的原因。网格有一个位置,但几何没有 – 它总是被认为是 (0,0,0)。可以为两个网格编写合并函数,但它比保持形状更复杂,不是吗?
geometry = new THREE.CubeGeometry(Tetris.blockSize, Tetris.blockSize, Tetris.blockSize); for(var i = 1 ; i < Tetris.Block.shape.length; i++) { tmpGeometry = new THREE.Mesh(new THREE.CubeGeometry(Tetris.blockSize, Tetris.blockSize, Tetris.blockSize)); tmpGeometry.position.x = Tetris.blockSize * Tetris.Block.shape[i].x; tmpGeometry.position.y = Tetris.blockSize * Tetris.Block.shape[i].y; THREE.GeometryUtils.merge(geometry, tmpGeometry); }
通过合并几何,我们可以使用教程前面的双材质技巧。
Tetris.Block.mesh = THREE.SceneUtils.createMultiMaterialObject(geometry, [ new THREE.MeshBasicMaterial({color: 0x000000, shading: THREE.FlatShading, wireframe: true, transparent: true}), new THREE.MeshBasicMaterial({color: 0xff0000}) ]);
我们必须为我们的块设置初始位置和旋转(x,y 为棋盘中心,z 为任意数字)。
// initial position Tetris.Block.position = {x: Math.floor(Tetris.boundingBoxConfig.splitX/2)-1, y: Math.floor(Tetris.boundingBoxConfig.splitY/2)-1, z: 15}; Tetris.Block.mesh.position.x = (Tetris.Block.position.x - Tetris.boundingBoxConfig.splitX/2)*Tetris.blockSize/2; Tetris.Block.mesh.position.y = (Tetris.Block.position.y - Tetris.boundingBoxConfig.splitY/2)*Tetris.blockSize/2; Tetris.Block.mesh.position.z = (Tetris.Block.position.z - Tetris.boundingBoxConfig.splitZ/2)*Tetris.blockSize + Tetris.blockSize/2; Tetris.Block.mesh.rotation = {x: 0, y: 0, z: 0}; Tetris.Block.mesh.overdraw = true; Tetris.scene.add(Tetris.Block.mesh); }; // end of Tetris.Block.generate()
如果需要,可以从控制台调用 Tetris.Block.generate()。
移动
移动一个方块其实很简单。对于旋转,我们使用 Three.js 内部,我们必须将角度转换为弧度。
Tetris.Block.rotate = function(x,y,z) { Tetris.Block.mesh.rotation.x += x * Math.PI / 180; Tetris.Block.mesh.rotation.y += y * Math.PI / 180; Tetris.Block.mesh.rotation.z += z * Math.PI / 180; };
位置也很简单: Three.js 需要一个考虑块大小的位置,而我们的副本不需要。我们的娱乐有一个简单的地板命中检查;稍后将被删除。
Tetris.Block.move = function(x,y,z) { Tetris.Block.mesh.position.x += x*Tetris.blockSize; Tetris.Block.position.x += x; Tetris.Block.mesh.position.y += y*Tetris.blockSize; Tetris.Block.position.y += y; Tetris.Block.mesh.position.z += z*Tetris.blockSize; Tetris.Block.position.z += z; if(Tetris.Block.position.z == 0) Tetris.Block.hitBottom(); };
再次点击并创建
hitBottom 有什么用?记住?如果一个块生命周期已经结束,我们应该将其转换为静态立方体,将其从场景中移除并生成一个新的。
Tetris.Block.hitBottom = function() { Tetris.Block.petrify(); Tetris.scene.removeObject(Tetris.Block.mesh); Tetris.Block.generate(); };
我们已经有了 generate() 和 removeObject() 是一个 Three.js 用于删除未使用的网格的函数。幸运的是,之前我们为静态立方体编写了一个函数,现在我们将在 petrify() 中使用它。
Tetris.Block.petrify = function() { var shape = Tetris.Block.shape; for(var i = 0 ; i < shape.length; i++) { Tetris.addStaticBlock(Tetris.Block.position.x + shape[i].x, Tetris.Block.position.y + shape[i].y, Tetris.Block.position.z + shape[i].z); } };
使用了 Tetris.Block.shape 的简写 – 它提高了代码的清晰度和性能,所以每次合适的时候都使用这种技术。在此函数中,您可以了解为什么保持旋转形状和分离位置是一个好主意。多亏了这一点,我们的代码阅读起来会很愉快,并且通过碰撞检测,它会变得更加重要。
将点连接
好的,现在我们有了块所需的所有功能,让我们在需要的地方挂钩它们。我们需要在开始时生成一个块,因此将 Tetris.start() 更改为:
Tetris.start = function() { document.getElementById("menu").style.display = "none"; Tetris.pointsDOM = document.getElementById("points"); Tetris.pointsDOM.style.display = "block"; Tetris.Block.generate(); // add this line Tetris.animate(); };
每走一步,我们都应该将积木向前移动一步,所以在
Tetris.animate() 我们采取行动并将其更改为:
while(Tetris.cumulatedFrameTime > Tetris.gameStepTime) { Tetris.cumulatedFrameTime -= Tetris.gameStepTime; Tetris.Block.move(0,0,-1);
键盘
老实说:我讨厌键盘事件。键码是无意义的,它们对于 keydown 和 keypress 是不同的。没有很好的方法来轮询键盘状态,在第二次按键事件重复比前两次快 10 倍之后,等等。如果您考虑具有大量键盘交互的严肃游戏,您几乎肯定会构建某种包装器所有这些废话。你可以试试KeyboardJS,它看起来不错。我将使用 vanilla JS 来展示总体思路。为了调试它,我使用了 console.log(keycode)。找到正确的代码有很大帮助。
window.addEventListener('keydown', function (event) { var key = event.which ? event.which : event.keyCode; switch(key) { case 38: // up (arrow) Tetris.Block.move(0, 1, 0); break; case 40: // down (arrow) Tetris.Block.move(0, -1, 0); break; case 37: // left(arrow) Tetris.Block.move(-1, 0, 0); break; case 39: // right (arrow) Tetris.Block.move(1, 0, 0); break; case 32: // space Tetris.Block.move(0, 0, -1); break; case 87: // up (w) Tetris.Block.rotate(90, 0, 0); break; case 83: // down (s) Tetris.Block.rotate(-90, 0, 0); break; case 65: // left(a) Tetris.Block.rotate(0, 0, 90); break; case 68: // right (d) Tetris.Block.rotate(0, 0, -90); break; case 81: // (q) Tetris.Block.rotate(0, 90, 0); break; case 69: // (e) Tetris.Block.rotate(0, -90, 0); break; } }, false);
如果您现在尝试玩游戏,您应该能够移动和旋转一个方块。不会有碰撞检测,但是当它撞到地面时,它会被移除并且新的方块会出现在船上。因为我们不对存储的形状应用旋转,所以静态版本可能会以不同的方式旋转。
板对象
我们将从一个新的类开始来存储我们的 3D 空间信息。我们需要很少的“const”、“enum”值。它们实际上既不是 const 也不是 enum,因为 JS 中没有这样的东西,但是JS 1.8.5 中有一个新功能 – freeze。您可以创建一个对象并保护它免受任何进一步的修改。它在所有可能运行 WebGL 的浏览器中得到广泛支持,并将为我们提供类似枚举的对象。
window.Tetris = window.Tetris || {}; Tetris.Board = {}; Tetris.Board.COLLISION = {NONE:0, WALL:1, GROUND:2}; Object.freeze(Tetris.Board.COLLISION); Tetris.Board.FIELD = {EMPTY:0, ACTIVE:1, PETRIFIED:2}; Object.freeze(Tetris.Board.FIELD); We will use field enum to store state of our board in fields array. On game start we need to initialize it as empty. Tetris.Board.fields = []; Tetris.Board.init = function(_x,_y,_z) { for(var x = 0; x < _x; x++) { Tetris.Board.fields[x] = []; for(var y = 0; y < _y; y++) { Tetris.Board.fields[x][y] = []; for(var z = 0; z < _z; z++) { Tetris.Board.fields[x][y][z] = Tetris.Board.FIELD.EMPTY; } } } };
Tetris.Board.init() 应该在游戏中出现任何块之前调用。我从 Tetris.init 中调用它,因为我们可以轻松地提供板尺寸作为参数:
// add anywhere in Tetris.init Tetris.Board.init(boundingBoxConfig.splitX, boundingBoxConfig.splitY, boundingBoxConfig.splitZ);
我们还应该修改 Tetris.Block.petrify 函数,以便它将信息存储在我们的新数组中。
Tetris.Block.petrify = function () { var shape = Tetris.Block.shape; for (var i = 0; i < shape.length; i++) { Tetris.addStaticBlock(Tetris.Block.position.x + shape[i].x, Tetris.Block.position.y + shape[i].y, Tetris.Block.position.z + shape[i].z); Tetris.Board.fields[Tetris.Block.position.x + shape[i].x][Tetris.Block.position.y + shape[i].y][Tetris.Block.position.z + shape[i].z] = Tetris.Board.FIELD.PETRIFIED; } };
碰撞检测
俄罗斯方块中有两种主要的碰撞类型。第一个是墙碰撞,当一个活动块在 x/y 轴上移动或旋转时(例如在一个级别上)撞到墙或另一个块。第二个是地面碰撞,当一个块在 z 轴上移动并撞击地板或另一个块并且其生命周期结束时发生。
我们将从板墙碰撞开始,这很容易。为了使代码更好(更快),我再次使用了速记。
Tetris.Board.testCollision = function (ground_check) { var x, y, z, i; // shorthands var fields = Tetris.Board.fields; var posx = Tetris.Block.position.x, posy = Tetris.Block.position.y, posz = Tetris.Block.position.z, shape = Tetris.Block.shape; for (i = 0; i < shape.length; i++) { // 4 walls detection for every part of the shape if ((shape[i].x + posx) < 0 || (shape[i].y + posy) < 0 || (shape[i].x + posx) >= fields.length || (shape[i].y + posy) >= fields[0].length) { return Tetris.Board.COLLISION.WALL; }
现在,如何处理块与块碰撞?我们已经在数组中存储了石化块,所以我们可以检查块是否与任何现有的立方体相交。您可能想知道为什么 testCollision 有 ground_check 作为参数。这是一个简单观察的结果,即以几乎与地面和墙壁碰撞相同的方式检测块与块碰撞。唯一的区别是 z 轴上的移动会导致地面撞击。
if (fields[shape[i].x + posx][shape[i].y + posy][shape[i].z + posz - 1] === Tetris.Board.FIELD.PETRIFIED) { return ground_check ? Tetris.Board.COLLISION.GROUND : Tetris.Board.COLLISION.WALL; }
我们还将测试 z 轴上的位置是否不等于 0。这意味着我们的移动方块下方没有立方体,但它到达了地面,无论如何都应该被石化。
if((shape[i].z + posz) <= 0) { return Tetris.Board.COLLISION.GROUND; } } };
碰撞反应
没那么糟糕,是吗?现在让我们用我们拥有的信息做一些事情。我们将从最简单的地方开始,检测丢失的游戏。我们可以通过在创建新块后立即测试是否存在碰撞来做到这一点。如果它撞到地面,则没有进一步玩的意义。
在计算块位置后添加到 Tetris.Block.generate:
if (Tetris.Board.testCollision(true) === Tetris.Board.COLLISION.GROUND) { Tetris.gameOver = true; Tetris.pointsDOM.innerHTML = "GAME OVER"; Cufon.replace('#points'); }
运动也很简单。在我们改变一个位置后,我们称之为碰撞检测,将有关 z 轴运动的信息作为参数传递。
如果发生墙壁碰撞,移动是不可能的,我们应该撤消它。我们可以添加几行来减去位置,但我很懒,我更喜欢再次调用 move 函数,但使用倒置的参数。它永远不会与 z 轴移动一起使用,因此我们可以将零作为 z 传递。
如果形状碰到地面,我们已经有一个应该调用的函数 hitBottom()。它将从游戏中移除活动形状,修改棋盘状态并创建新形状。
// add instead of ground level detection from part 3 var collision = Tetris.Board.testCollision((z != 0)); if (collision === Tetris.Board.COLLISION.WALL) { Tetris.Block.move(-x, -y, 0); // laziness FTW } if (collision === Tetris.Board.COLLISION.GROUND) { Tetris.Block.hitBottom(); }
如果此时运行游戏,您会注意到旋转的形状不是永久的。当它撞到地面时,它会返回初始旋转。这是因为我们将旋转应用于 Three.js 网格(如 Tetris.Block.mesh.rotation),但我们不使用它来获取基于立方体的形状表示的坐标。为了解决这个问题,我们需要上一节快速的数学课。
3D 数学
免责声明:如果您害怕数学或没有时间,您实际上可以跳过这部分。了解引擎内部发生了什么很重要,但稍后我们将为此使用 Three.js 函数。
考虑一个三元素向量(表示 3D 空间中的位置)。为了在欧几里得空间中变换这样的向量,我们必须添加另一个向量。它可以表示为:
\[\begin{matrix}x\\y\\z\\\end{matrix}\ + \begin{matrix}\delta x\\\delta y\\\delta z\\\end{matrix} = \begin{matrix}x'\\ y'\\ z'\\\end{matrix} \]
这很简单。当我们想旋转一个向量时,问题就出现了。围绕单个轴的旋转会影响三个坐标中的两个(如果您不相信,请检查),并且方程式并不是那么简单。幸运的是,几乎所有计算机生成的图形都使用了一种方法,包括 Three.js、WebGL、OpenGL 和 GPU 本身。
如果您还记得高中时,将向量乘以矩阵将得到另一个向量。在此基础上进行了许多转换。最简单的一种是中性变换(使用单位矩阵),它只显示一般概念,并用作其他变换的基础。
\[\begin{matrix}x\\y\\z\\w\\\end{matrix}\ * \begin{matrix} 1 & 0 & 0 & 0\\0 & 1 & 0 & 0\\0 & 0 & 1 & 0\\0 & 0 & 0 & 1\end{matrix}\ = \begin{matrix}x\\y\\z\\w\\\end{matrix}\]
为什么我们使用 4×4 矩阵和 4 元素向量而不是 3×3 和 3 元素?它用于通过向量启用翻译:
\[\begin{matrix}x\\y\\z\\w\\\end{matrix}\ * \begin{matrix} 1 & 0 & 0 & \delta x\\0 & 1 & 0 & \delta y\\0 & 0 & 1 & \delta z\\0 & 0 & 0 & 1\end{matrix}\ = \begin{matrix}x'\\y'\\z'\\w'\\\end{matrix}\]
这是一个很好的数学技巧,可以让所有方程变得更容易。它还有助于解决数值错误,并使我们能够使用更高级的概念,例如四元数。
缩放也很简单:
\[\begin{matrix}x\\y\\z\\w\\\end{matrix}\ * \begin{matrix} sx & 0 & 0 & 0\\ 0 & sy & 0 & 0\\ 0 & 0 & sz & 0\\ 0 & 0 & 0 & 1 \end{matrix}= \begin{matrix}x * sx\\y * sy\\z * sz\\w'\\\end{matrix}\]
旋转有三个矩阵,每个轴一个。
对于 x 轴
\[ \begin{matrix} 1 & 0 & 0 & 0\\ 0 & cos \alpha & -sin \alpha & 0\\ 0 & sin \alpha & cos \alpha & 0\\ 0 & 0 & 0 & 1 \end{matrix}\]
对于 y 轴
\[ \begin{matrix} cos \alpha & 0 & sin \alpha & 0\\ 0 & 1 & 0 & 0\\ -sin \alpha & 0 & cos \alpha & 0\\ 0 & 0 & 0 & 1 \end{matrix}\]
对于 z 轴
\[ \begin{matrix} cos \alpha & -sin \alpha & 0 & 0\\ sin \alpha & cos \alpha & 0 & 0\\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1 \end{matrix}\]
矩阵变换的另一个好处是我们可以通过将两个变换的矩阵相乘来轻松组合两个变换。如果你想围绕所有三个轴旋转,你可以将三个矩阵相乘,得到一个叫做变换矩阵的东西。它将很容易地转换一个表示位置的向量。
幸运的是,大多数时候您不必在数学库上工作。Three.js 已经有一个内置的数学库,我们将使用它。
返回旋转
要在 Three.js 中旋转一个形状,我们需要创建一个旋转矩阵并将其与形状的每个向量相乘。我们将再次使用 cloneVector 来确保创建的形状独立于存储为模式的形状。
// append to Tetris.Block.rotate() var rotationMatrix = new THREE.Matrix4(); rotationMatrix.setRotationFromEuler(Tetris.Block.mesh.rotation); for (var i = 0; i < Tetris.Block.shape.length; i++) { Tetris.Block.shape[i] = rotationMatrix.multiplyVector3( Tetris.Utils.cloneVector(Tetris.Block.shapes[this.blockType][i]) ); Tetris.Utils.roundVector(Tetris.Block.shape[i]); }
旋转矩阵和我们对棋盘的表示存在一个问题。字段表示为由整数索引的数组,而矩阵向量乘法的结果可能是浮点数。JavaScript 对浮点数不是很好,几乎可以肯定它会产生像 1.000001 或 2. 这样的位置。这就是为什么我们需要一个舍入函数。
Tetris.Utils.roundVector = function(v) { v.x = Math.round(v.x); v.y = Math.round(v.y); v.z = Math.round(v.z); };
当我们旋转我们的形状时,检查是否发生碰撞非常简单。我通过再次调用该函数使用了相同的技巧来撤消旋转,但使用了反转的参数。请注意,撤消移动时永远不会发生碰撞。如果需要,您可以添加一个附加参数,以便在不需要时不会再次检查它。
// append to Tetris.Block.rotate() if (Tetris.Board.testCollision(false) === Tetris.Board.COLLISION.WALL) { Tetris.Block.rotate(-x, -y, -z); // laziness FTW }
完成切片和计分
这个函数会很长但是很简单。要检查切片是否完成,我会计算占用字段的最大数量并检查每个切片(在 z 轴上移动)是否已满。这样我可以改变电路板的大小,这个功能应该仍然有效。试着以这样的方式思考你所有的功能:如果有什么改变,让你的代码灵活。
Tetris.Board.checkCompleted = function() { var x,y,z,x2,y2,z2, fields = Tetris.Board.fields; var rebuild = false; var sum, expected = fields[0].length*fields.length, bonus = 0; for(z = 0; z < fields[0][0].length; z++) { sum = 0; for(y = 0; y < fields[0].length; y++) { for(x = 0; x < fields.length; x++) { if(fields[x][y][z] === Tetris.Board.FIELD.PETRIFIED) sum++; } }
当切片已满时,我们应该将其移除并移动所有后续切片。为了确保我们不会跳过移位的切片,我们将 z 减小一次。为了使游戏更有趣,如果一次完成多个切片,则会获得奖励积分。
if(sum == expected) { bonus += 1 + bonus; // 1, 3, 7, 15... for(y2 = 0; y2 < fields[0].length; y2++) { for(x2 = 0; x2 < fields.length; x2++) { for(z2 = z; z2 < fields[0][0].length-1; z2++) { Tetris.Board.fields[x2][y2][z2] = fields[x2][y2][z2+1]; // shift } Tetris.Board.fields[x2][y2][fields[0][0].length-1] = Tetris.Board.FIELD.EMPTY; } } rebuild = true; z--; } } if(bonus) { Tetris.addPoints(1000 * bonus); }
现在,即使我们处理了棋盘信息,我们仍然需要对 Three.js 的几何图形进行更改。我们不能在前面的循环中这样做,因为如果一次完成多个切片,它可以重建几何两次甚至更多。此循环检查每个 Tetris.Board.fields 与相应的 Tetris.staticBlocks 在需要时添加和删除几何图形。
if(rebuild) { for(var z = 0; z < fields[0][0].length-1; z++) { for(var y = 0; y < fields[0].length; y++) { for(var x = 0; x < fields.length; x++) { if(fields[x][y][z] === Tetris.Board.FIELD.PETRIFIED && !Tetris.staticBlocks[x][y][z]) { Tetris.addStaticBlock(x,y,z); } if(fields[x][y][z] == Tetris.Board.FIELD.EMPTY && Tetris.staticBlocks[x][y][z]) { Tetris.scene.removeObject(Tetris.staticBlocks[x][y][z]); Tetris.staticBlocks[x][y][z] = undefined; } } } } } };
音频 API
使用HTML5添加音频非常简单。让我们从将 <audio> 元素添加到 index.html 开始。
<audio id="audio_theme" src="music/tetris.mp3" preload="auto"></audio> <audio id="audio_move" src="music/move.mp3" preload="auto"></audio> <audio id="audio_collision" src="music/collision.mp3" preload="auto"></audio> <audio id="audio_gameover" src="music/gameover.mp3" preload="auto"></audio> <audio id="audio_score" src="music/cash.mp3" preload="auto"></audio>
在 JS 中使用这些文件也很容易。首先创建一个对象来存储你的声音:
// before Tetris.init() Tetris.sounds = {};
要调用音频 API,我们必须检索这些 DOM 元素。
// in Tetris.init() Tetris.sounds["theme"] = document.getElementById("audio_theme"); Tetris.sounds["collision"] = document.getElementById("audio_collision"); Tetris.sounds["move"] = document.getElementById("audio_move"); Tetris.sounds["gameover"] = document.getElementById("audio_gameover"); Tetris.sounds["score"] = document.getElementById("audio_score");
有许多方法,您可以创建自己的音频播放器,但就我们的目的而言,play() 和 pause() 就足够了。您可能会猜到应该在哪里添加音乐:
- Tetris.sounds[“theme”].play() – 在 Tetris.init() 中,在声音对象初始化之后。
- Tetris.sounds[“theme”].pause() – 在 Tetris.start() 中。
- else {Tetris.sounds[“move”].play();} – 在 Tetris.Block.move() 中,如果没有地面碰撞。
- 俄罗斯方块.sounds[“碰撞”].play(); – 在 Tetris.Block.move() 中,如果有地面碰撞。
- Tetris.sounds[“score”].play(); – 在俄罗斯方块.addPoints() 中。
- Tetris.sounds[“gameover”].play(); – 在 Tetris.Block.generate() 中,我们测试输掉的游戏。
结论
这就是所有的人!我们的俄罗斯方块现在功能齐全。我希望这是一种学习 Three.js 的有趣方式。这里没有涉及许多主题,例如高级几何图形、着色器、灯光、骨骼动画等。我只是想表明创建游戏并不总是需要它们。
如果你想了解更多,你可能应该从现在开始使用纯 WebGL。您可以从本教程开始。另请查看Brandon Jones的“Building the Game” 。
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://yundeesoft.com/58845.html