四、等距地图
现在你已经知道了制作基于磁贴游戏的基本知识,下面我们来添加一个很酷的新功能:等轴测投影。等距投影是一种浅 3D 效果,其中游戏世界看起来旋转了 45 度,并以 30 度角从上方观看。图 4-1 显示了典型的等距地图布局。
图 4-1。等轴测投影是一种固定透视的 3D 效果
您的地图不是由正方形组成,而是由代表立方体的形状组成,从某个角度看是侧面的。它看起来是 3D 的,但是等轴测 3D 和真正的 3D 有两个很大的区别:
-
等轴测三维具有永久固定的透视。
-
等距 3D 没有地平线,所以游戏世界可以看起来无限延伸到世界的边缘之外。
等轴测地图是许多游戏流派的标准,例如策略和基于地图的冒险和角色扮演游戏。他们的优势在于,像 2D 地图一样,他们给玩家一个一致的游戏世界鸟瞰图,所以很容易策划和计划你的下一步行动。但是 3D 透视可以给你比普通 2D 地图更大的空间沉浸感。
在这一章中,你将学习如何制作一个等轴测游戏地图的所有基础知识,包括如何做以下事情:
-
在等距世界中移动游戏角色。
-
准确深度排序精灵。
-
在等轴精灵之间进行碰撞检测。
-
使用指针选择等轴测图子画面,并将它们在屏幕上的位置与其贴图数组索引位置相匹配。
-
如何使用地图编辑器设置和渲染一个等轴测的游戏世界?
让我们来看看是如何做到的!
等距基础
你可能会惊讶地发现,要制作一个等距游戏世界,你唯一需要知道的新东西就是一点简单的数学。看看图 4-2 ,用两种方式渲染同一个游戏地图。
图 4-2。同一地图的卡氏与等轴测渲染
两个地图都是使用相同的地图阵列数据创建的。左边的例子使用笛卡尔坐标渲染地图。这些就是我们所知的普通 x 和 y 坐标,它们与轴平面成直角对齐。右边的例子使用等角坐标渲染地图。这两个地图之间只有一个区别:等距地图中的图块只是被拉伸和旋转了 45 度。这是同一张地图,只是从不同的角度看。这意味着您可以通过简单的数学转换将任何普通的笛卡尔平铺地图转换为等距平铺地图。
这里是你需要知道的在平面笛卡尔地图和三维等距地图之间转换 x 和 y 点的唯一数学方法。要将笛卡尔 x 和 y 点转换为等轴测点,请使用以下公式:
isoX = cartX - cartY;
isoY = (cartX + cartY) / 2;
要将等轴测点转换为笛卡尔坐标,请使用以下公式:
cartX = (2 * isoY + isoX) / 2;
cartY = (2 * isoY - isoX) / 2;
仅此而已!你将在本章学习的代码的其余部分,本质上只是给你一个在这两个坐标系之间转换的简单方法,使用这些公式。
制作笛卡尔单幅图块地图
在我们开始进入等轴测投影的世界之前,让我们暂时回到基础,看看如何制作图 4-2 中的笛卡尔瓷砖地图。这也是一个基本的平铺地图渲染系统的良好模型,如果您以后需要,您可以将其扩展为功能更全的系统。这是本书生成地图的源代码中 cartesian.js 文件中 setup 函数的代码——注释一步一步地描述了代码是如何工作的。
**//Create the `world` container that defines our isometric**
**//tile-based world**
let world = g.group();
**//Set the Cartesian dimensions of each tile, in pixels**
world.cartTilewidth = 32;
world.cartTileheight = 32;
**//Define the width and height of the world, in tiles**
world.widthInTiles = 8;
world.heightInTiles = 8;
**//Create the world layers**
world.layers = [
**//The environment layer. `2` represents the walls,**
**//`1` represents the floors**
[
2, 2, 2, 2, 2, 2, 2, 2,
2, 1, 1, 1, 1, 1, 1, 2,
2, 1, 2, 1, 1, 2, 1, 2,
2, 1, 1, 1, 1, 2, 2, 2,
2, 1, 1, 1, 1, 1, 1, 2,
2, 2, 2, 1, 2, 1, 1, 2,
2, 1, 1, 1, 1, 1, 1, 2,
2, 2, 2, 2, 2, 2, 2, 2
],
**//The character layer. `3` represents the game character**
**//`0` represents an empty cell which won't contain any**
**//sprites**
[
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 3, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
]
];
**//Build the game world by looping through each**
**//of the layers arrays one after the other**
world.layers.forEach(layer => {
**//Loop through each array element**
layer.forEach((gid, index) => {
**//If the cell isn't empty (0) then create a sprite**
if (gid !== 0) {
**//Find the column and row that the sprite is on and also**
**//its x and y pixel values that match column and row position**
let column, row, x, y;
column = index % world.widthInTiles;
row = Math.floor(index / world.widthInTiles);
x = column * world.cartTilewidth;
y = row * world.cartTileheight;
**//Next, create a different sprite based on what its**
**//`gid` number is**
let sprite;
switch (gid) {
**//The floor**
case 1:
sprite = g.rectangle(world.cartTilewidth, world.cartTileheight, 0xCCCCFF);
break;
**//The walls**
case 2:
sprite = g.rectangle(world.cartTilewidth, world.cartTileheight, 0x99CC00);
break;
**//The character**
case 3:
sprite = g.rectangle(world.cartTilewidth, world.cartTileheight, 0xFF0000);
}
**//Position the sprite using the calculated `x` and `y` values**
**//that match its column and row in the tile map**
sprite.x = x;
sprite.y = y;
**//Add the sprite to the `world` container**
world.addChild(sprite);
}
});
});
注意
上面代码中的 rectangle 函数只显示给定宽度、高度和颜色的矩形。
现在让我们看看如何在等轴测视图中显示相同的地图。
制作等轴测图
为了创建新的等轴测地图,我们需要添加两件东西。第一个是一个等轴正方形精灵,我们可以用它来显示每个瓷砖。第二种是将笛卡尔坐标(正常的 x 和 y 坐标)转换为等距坐标的方法。
等距精灵
一个等轴平铺子画面只是一个旋转了 45 度并被压缩到一半高度的正方形,如图 4-3 所示。
图 4-3。使用菱形图块作为绘制等轴测图的基础
这里有一个名为 isoRectangle 的函数可以创建这样一个精灵。它是使用 PixiJS 渲染系统绘制的,但是您可以使用任何允许您绘制形状的图形库来实现相同的效果,包括 Canvas API。
function isoRectangle(width, height, fillStyle) {
**//Figure out the `halfHeight` value**
let halfHeight = height / 2;
**//Draw the flattened and rotated square (diamond shape)**
let rectangle = new PIXI.Graphics();
rectangle.beginFill(fillStyle);
rectangle.moveTo(0, 0);
rectangle.lineTo(width, halfHeight);
rectangle.lineTo(0, height);
rectangle.lineTo(-width, halfHeight);
rectangle.lineTo(0, 0);
rectangle.endFill();
**//Generate a texture from the rectangle**
let texture = rectangle.generateTexture();
**//Use the texture to create a sprite**
let sprite = new PIXI.Sprite(texture);
**//Return the sprite to the main program**
return sprite;
}
现在我们有了一种生成等距精灵的方法,我们需要某种方法将笛卡尔的 x 和 y 坐标转换为等距坐标。
计算出等距坐标
你还记得我在这一章开始时给你看的把笛卡尔的 x 和 y 点转换成等距点的简单转换公式吗?
isoX = cartX - cartY;
isoY = (cartX + cartY) / 2;
下面是我们如何使用这个公式将 isoX 和 isoY 属性添加到 isoRectangle 函数创建的 sprite 对象中。
sprite.isoX = sprite.x – sprite.y;
sprite.isoY = (sprite.x + sprite.y) / 2;
但是为了让事情变得简单,让我们创建一个函数,将这些新的 isoX 和 isoY 属性添加到任何一个 sprite 中。同时,让我们捕获精灵的笛卡尔坐标和尺寸,如 cartX、cartY、cartWidth 和 cart height——以防我们在以后需要访问它们(剧透:我们会的!).下面是 addIsoProperties 函数来完成这项工作。
function addIsoProperties(sprite, x, y, width, height){
**//Cartisian (flat 2D) properties**
sprite.cartX = x;
sprite.cartY = y;
sprite.cartWidth = width;
sprite.cartHeight = height;
**//Add a getter/setter for the isometric properties**
Object.defineProperties(sprite, {
isoX: {
get() {
return this.cartX - this.cartY;
},
enumerable: true,
configurable: true
},
isoY: {
get() {
return (this.cartX + this.cartY) / 2;
},
enumerable: true,
configurable: true
},
});
}
只需向该函数提供任何具有普通 x、y、width 和 height 属性的 sprite,它就会为您添加 isoX、isoY 和其他笛卡尔属性。
剩下的就简单了!让我们通过将第一个示例中的原始笛卡尔瓷砖贴图渲染为新的等轴测贴图来了解一下这有多简单。下面是 isometric.js 文件中设置函数的代码。您会注意到它与第一个示例相同,只是添加了您刚刚学习的 isoRectangle 和 addIsoProperties 函数。
**//Create the `world` container that defines our isometric tile-based world**
world = g.group();
**//Define the size of each tile and the size of the tile map**
world.cartTilewidth = 32;
world.cartTileheight = 32;
world.widthInTiles = 8;
world.heightInTiles = 8;
**//Create the world layers**
world.layers = [
**//The environment layer. `2` represents the walls,**
**//`1` represents the floors**
[
2, 2, 2, 2, 2, 2, 2, 2,
2, 1, 1, 1, 1, 1, 1, 2,
2, 1, 2, 1, 1, 2, 1, 2,
2, 1, 1, 1, 1, 2, 2, 2,
2, 1, 1, 1, 1, 1, 1, 2,
2, 2, 2, 1, 2, 1, 1, 2,
2, 1, 1, 1, 1, 1, 1, 2,
2, 2, 2, 2, 2, 2, 2, 2
],
**//The character layer. `3` represents the game character**
**//`0` represents an empty cell which won't contain any**
**//sprites**
[
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 3, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
]
];
**//Build the game world by looping through each of the arrays**
world.layers.forEach(layer => {
**//Loop through each array element**
layer.forEach((gid, index) => {
**//If the cell isn't empty (0) then create a sprite**
if (gid !== 0) {
**//Find the column and row that the sprite is on and also**
**//its x and y pixel values.**
let column, row, x, y;
column = index % world.widthInTiles;
row = Math.floor(index / world.widthInTiles);
x = column * world.cartTilewidth;
y = row * world.cartTileheight;
**//Next, create a different sprite based on what its `gid` number is**
let sprite;
switch (gid) {
**//The floor**
case 1:
**//Create a sprite using an isometric rectangle**
sprite = isoRectangle(world.cartTilewidth, world.cartTileheight, 0xCCCCFF);
break;
**//The walls**
case 2:
sprite = isoRectangle(world.cartTilewidth, world.cartTileheight, 0x99CC00);
break;
**//The character**
case 3:
sprite = isoRectangle(world.cartTilewidth, world.cartTileheight, 0xFF0000);
}
**//Add these properties to the sprite**
addIsoProperties(sprite, x, y, world.cartTilewidth, world.cartTileheight);
**//Set the sprite's `x` and `y` pixel position based on its**
**//isometric coordinates**
sprite.x = sprite.isoX;
sprite.y = sprite.isoY;
**//Add the sprite to the `world` container**
world.addChild(sprite);
}
});
});
**//Position the world inside the canvas**
let canvasOffset = (g.canvas.width / 2) - world.cartTilewidth;
world.x += canvasOffset;
最后两行在画布中使等轴测世界居中。
这应该向您证明,任何普通的正方形平铺地图都可以重新渲染为等轴测世界,而无需更改任何基于平铺的底层逻辑。
使用等轴测特性
现在让我们把我们的新技能向前推进一步,看看如何从地图数组索引号转换到等距坐标——反之亦然。在本章的源文件中运行 pointer.html 文件,并在等轴测图上移动鼠标指针。你会注意到文本显示告诉你指针所在的地图的行和列,它匹配的数组索引号,以及相应的笛卡尔 x 和 y 位置,如图 4-4 所示。
图 4-4。将地图阵列索引位置和笛卡尔坐标与等距位置相关联
例如,将指针移动到红色方块上,您将看到在该位置显示的两个地图图层的 gid 编号。您还会看到显示了与图块位置匹配的行号和列号。此外,如果这是一张普通的 2D 地图,显示屏还会告诉您鼠标指针在该位置的笛卡尔坐标 x 和 y 位置。有用的东西!
这基本上是您处理等轴测地图的小小“Hello World”。如果你习惯于做这种转换,像角色移动和碰撞这样更高级的功能将会很好地就位。
这里没有什么新的东西,除了我们使用鼠标和指针对象的 x 和 y 坐标来帮助我们获得这些值。为了从等距坐标转换到笛卡尔坐标,代码只是使用我在本章开始时向您展示的相同公式:
cartX = (2 * isoY + isoX) / 2;
cartY = (2 * isoY - isoX) / 2;
我们需要做的唯一额外改进是补偿画布显示区域中任何可能的等轴测地图的 x/y 偏移。下面是完成这一切的代码。
pointer.cartX =
(((2 * pointer.y + pointer.x) - (2 * world.y + world.x)) / 2)
- (world.cartTilewidth / 2);
pointer.cartY =
(((2 * pointer.y - pointer.x) - (2 * world.y - world.x)) / 2)
+ (world.cartTileheight / 2);
注意
你可能使用的任何场景图或游戏引擎都会有某种指针对象,它会给你这些 x 和 y 值。只要在你正在使用的技术中识别它,并把这个代码应用到它上面。
有了这些值,我们现在可以很容易地计算瓷砖的行和列的位置。
column = Math.floor(pointer.cartX / world.cartTilewidth);
row = Math.floor(this.cartY / world.cartTileheight);
最后,映射数组索引号:
index = column + (row * world.widthInTiles);
为了让我们的生活更简单,让我们使用一个名为 makeIsoPointer 的函数将这些属性添加到指针对象中。这将让我们在需要的时候从指针中获取这些值。
function makeIsoPointer(pointer, world) {
Object.defineProperties(pointer, {
**//The isometric's world's Cartesian coordinates**
cartX: {
get() {
let x =
(((2 * this.y + this.x) - (2 * world.y + world.x)) / 2)
- (world.cartTilewidth / 2);
return x;
},
enumerable: true,
configurable: true
},
cartY: {
get() {
let y =
(((2 * this.y - this.x) - (2 * world.y - world.x)) / 2)
+ (world.cartTileheight / 2);
return y
},
enumerable: true,
configurable: true
},
**//The tile's column and row in the array**
column: {
get() {
return Math.floor(this.cartX / world.cartTilewidth);
},
enumerable: true,
configurable: true
},
row: {
get() {
return Math.floor(this.cartY / world.cartTileheight);
},
enumerable: true,
configurable: true
},
**//The tile's index number in the array**
index: {
get() {
let index = {};
**//Convert pixel coordinates to map index coordinates**
index.x = Math.floor(this.cartX / world.cartTilewidth);
index.y = Math.floor(this.cartY / world.cartTileheight);
**//Return the index number**
return index.x + (index.y * world.widthInTiles);
},
enumerable: true,
configurable: true
},
});
}
现在,您可以像这样将这些属性添加到指针中:
makeIsoPointer(g.pointer, world);
您可以在文本字段中显示它们,如下所示:
message.content = `
cartX: ${Math.floor(g.pointer.cartX)}
cartY: ${Math.floor(g.pointer.cartY)}
column: ${g.pointer.column}
row: ${g.pointer.row}
index: ${g.pointer.index}
layer 1 gid: ${world.layers[0][Math.floor(g.pointer.index)]}
layer 2 gid: ${world.layers[1][Math.floor(g.pointer.index)]}
`;
这是本章源代码的 pointer.js 文件中唯一的新代码,它产生图 4-4 中的输出——其余代码与我们前面的例子相同。
我们使用等轴测地图的所有核心技能都已经到位,所以让我们看看如何开始使用它们来制作游戏。
在一个等距的世界中移动
接下来让我们看看如何在这个等距地图上移动游戏角色。运行章节源文件中的 movement.html 文件,使用键盘箭头键在迷宫中移动红色方块,如图 4-5 所示。文本字段显示红色方块当前所在的地图阵列索引号。
图 4-5。使用箭头键移动红色方块
不要惊慌,你已经知道如何做了!所有代码所做的就是将你在前一章学到的基于图块的地图移动技术与我们新的等距技巧相融合。为了向您证明这一点,让我们浏览一下添加到现有代码库中的新代码。
首先,你需要定义什么是“玩家”角色。在这个例子中,它是红色的正方形。所以让我们在绘制平铺地图的 switch 语句中将其作为 player 对象引用。
case 3:
sprite = isoRectangle(world.cartTilewidth, world.cartTileheight, 0xFF0000);
**player = sprite;**
In the setup function we also need to define keyboard arrow keys and the player object’s direction property so that we can move it around the map. Let’ s add the code to do this, which is identical to code we used to accomplish the same thing in the previous chapter.
**//Create the keyboard objects**
leftArrow = g.keyboard(37);
upArrow = g.keyboard(38);
rightArrow = g.keyboard(39);
downArrow = g.keyboard(40);
**//Assign the key `press` actions**
player.direction = "none";
leftArrow.press = () => player.direction = "left";
upArrow.press = () => player.direction = "up";
rightArrow.press = () => player.direction = "right";
downArrow.press = () => player.direction = "down";
leftArrow.release = () => player.direction = "none";
upArrow.release = () => player.direction = "none";
rightArrow.release = () => player.direction = "none";
downArrow.release = () => player.direction = "none";
下一步是向游戏循环中添加代码,使用键盘输入来移动玩家。这基本上是我们用来在 2D 地图上移动精灵的相同代码,有一个重要的变化。代码不是直接处理 sprite 的 x 和 y 屏幕位置值,而是处理 sprite 的 cartX 和 cartY 属性。精灵的位置、速度和屏幕边界都使用 cartX 和 cartY 更新。只有在最后一步,新计算的 isoX 和 isoY 属性才用于设置 sprite 的屏幕位置。
为什么呢?因为用 cartX 和 cartY 做所有的逻辑和定位计算意味着你可以编写与你为普通的 2D 地图编写的代码完全相同的代码。这意味着没有什么新的东西要学!这是来自游戏循环的代码,它完成了所有这些工作,并告诉你精灵占据了哪个地图数组索引位置。
**//Change the player character's velocity if it's centered over a grid cell**
if (Math.floor(player.cartX) % world.cartTilewidth === 0
&& Math.floor(player.cartY) % world.cartTileheight === 0) {
switch (player.direction) {
case "up":
player.vy = -2;
player.vx = 0;
break;
case "down":
player.vy = 2;
player.vx = 0;
break;
case "left":
player.vx = -2;
player.vy = 0;
break;
case "right":
player.vx = 2;
player.vy = 0;
break;
case "none":
player.vx = 0;
player.vy = 0;
break;
}
}
**//Update the player's Cartesian position based on its velocity**
player.cartY += player.vy;
player.cartX += player.vx;
**//Add world boundaries**
let top = 0,
bottom = (world.heightInTiles * world.cartTileheight),
left = 0,
right = (world.widthInTiles * world.cartTilewidth);
**//Prevent the player from crossing any of the world boundaries**
**//Top**
if (player.cartY < 0) {
player.cartY = top;
}
**//Bottom**
if (player.cartY + player.cartHeight > bottom) {
player.cartY = bottom - player.cartHeight;
}
**//Left**
if (player.cartX < left) {
player.cartX = left;
}
**//Right**
if (player.cartX + player.cartWidth > right) {
player.cartX = right - player.cartWidth;
}
**//Position the sprite's sceen `x` and `y` position**
**//using its isometric coordinates**
player.x = player.isoX;
player.y = player.isoY;
**//Get the player's index position in the map array**
player.index = g.getIndex(
player.cartX, player.cartY,
world.cartTilewidth, world.cartTileheight, world.widthInTiles
);
**//Display the player's x, y and index values**
message.content = `index: ${player.index}`;
既然我们可以在游戏世界中移动精灵,下一步就是添加碰撞检测。
等距碰撞检测
为了使基于图块的碰撞检测在等轴测世界中工作,我们需要对您在前一章中学习的 getPoints 函数进行一些修改。我们需要使用它的 cartX、cartY、cartWidth 和 cartHeight 值,而不是使用 sprite 的普通 x、y、width 和 height 屏幕值。这里有一个名为 getIsoPoints 的新函数实现了这一点。
function getIsoPoints(s) {
let ca = s.collisionArea;
if (ca !== undefined) {
return {
topLeft: {
x: s.cartX + ca.x,
y: s.cartY + ca.y
},
topRight: {
x: s.cartX + ca.x + ca.width,
y: s.cartY + ca.y
},
bottomLeft: {
x: s.cartX + ca.x,
y: s.cartY + ca.y + ca.height
},
bottomRight: {
x: s.cartX + ca.x + ca.width,
y: s.cartY + ca.y + ca.height
}
};
}
else {
return {
topLeft: {
x: s.cartX,
y: s.cartY
},
topRight: {
x: s.cartX + s.cartWidth - 1,
y: s.cartY
},
bottomLeft: {
x: s.cartX,
y: s.cartY + s.cartHeight - 1
},
bottomRight: {
x: s.cartX + s.cartWidth - 1,
y: s.cartY + s.cartHeight - 1
}
};
}
}
(记住,ca 指的是 sprite 的碰撞区域,这个你在第 3 章学过。)
现在我们需要在 hitTestTile 函数中用 getIsoPoints 替换旧的 getPoints。我们还需要使用精灵的 cartX 和 cartY 值来计算它的中心点。让我们使用一个名为 hitTestIsoTile 的 hitTestTile 新版本来实现这一点。这里是 hitTestIsoTile 的一个精简版本,突出显示了新的更新代码。
function hitTestIsoTile(sprite, mapArray, gidToCheck, world, pointsToCheck) {
//...
switch (pointsToCheck) {
case "center":
let point = {
center: {
**x: s.cartX + ca.x + (ca.width / 2),**
**y: s.cartY + ca.y + (ca.height / 2)**
}
};
sprite.collisionPoints = point;
collision.hit = Object.keys(sprite.collisionPoints).some(checkPoints);
break;
case "every":
sprite.collisionPoints = **getIsoPoints(sprite)**;
collision.hit = Object.keys(sprite.collisionPoints).every(checkPoints);
break;
case "some":
sprite.collisionPoints = **getIsoPoints(sprite)**;
collision.hit = Object.keys(sprite.collisionPoints).some(checkPoints);
break;
}
//...
}
这些是我们的旧 hitTestTile 函数需要做的唯一更改。你可以在本章的源文件中找到完整的 hitTestIsoTile 函数。
我们现在可以在游戏循环中使用 hitTestIsoTile 来检查这样的冲突:
**//Get a reference to the wall map array**
wallMapArray = world.layers[0];
**//Use `hitTestIsoTile` to check for a collision**
let playerVsGround = hitTestIsoTile(player, wallMapArray, 1, world, "every");
**//If there's a collision, prevent the player from moving.**
**//Subtract its velocity from its position and then set its velocity to zero**
if (!playerVsGround.hit) {
player.cartX -= player.vx;
player.cartY -= player.vy;
player.vx = 0;
player.vy = 0;
}
这与我们在第 3 章的迷宫游戏原型中用来检查碰撞的基本代码相同。没什么新东西可学!
深度分层
到目前为止,在这些例子中,我们只是使用扁平的菱形精灵来构建我们的等距世界。但是在大多数游戏中,你会希望为你的精灵使用真实的三维形状。运行本章源文件中的 depthLayering.html 文件,获得这样一个等距世界的工作示例,如图 4-6 所示。
图 4-6。使用 3D 精灵构建一个等轴测世界
这是相同的地图,并使用与前面的例子相同的键盘控制和碰撞。新的是,瓷砖是由透明的立方体图像和正确的深度排序。深度排序意味着看起来更靠近观察者的精灵显示在那些更远的精灵前面。
按 z 属性值对 Sprite 进行排序
我们需要在代码中添加两个新东西来进行适当的等距深度排序。首先,我们的每个精灵都需要一个名为 z 的新属性,它决定了它的深度层。
sprite.z = depthLayer;
较低级别地图图层上的精灵应该比较高级别地图图层上的精灵具有更低的 z 值。您将在代码示例的开头看到这个值是如何被找到并分配给精灵的。
接下来,您需要根据这个新的 z 值对精灵进行排序。因为大多数显示系统根据精灵在显示列表中出现的顺序将它们绘制到屏幕上,所以您可以通过更改它们在列表中的顺序来更改它们的深度层。显示列表只是一个精灵数组,所以这意味着您只需要一个自定义的 JavaScript 数组排序方法来根据 z 值对数组中的精灵重新排序。
这里有一个名为 byDepth 的自定义排序函数就是这样做的。它通过将精灵的笛卡尔 x 和 y 位置相加,并乘以其 z 值来计算出每个精灵的等距深度。然后,它根据深度将数组中的每对相邻子画面移动到较低或较高的索引位置。
function byDepth(a, b) {
**//Calculate the depths of `a` and `b`**
**//(add `1` to `a.z` and `b.x` to avoid multiplying by 0)**
a.depth = (a.cartX + a.cartY) * (a.z + 1);
b.depth = (b.cartX + b.cartY) * (b.z + 1);
**//Move sprites with a lower depth to a higher position in the array**
if (a.depth < b.depth) {
**//Move the sprite down one position**
return -1;
} else if (a.depth > b.depth) {
**//Move the sprite up one position**
return 1;
} else {
**//Keep the sprite in the same position**
return 0;
}
}
返回值-1 表示 sprite 将在数组中下移一位,值 1 表示将上移一位。零值意味着精灵将保持其当前位置。要使用 byDepth 函数,请将其作为 JavaScript 的数组排序方法的参数提供给任何表示 sprite 显示列表的数组。许多场景图和游戏引擎使用一个名为 children 的数组来定义显示列表,因此您可以按深度对 children 数组进行排序,如下所示:
world.children.sort(byDepth);
现在让我们看看如何在实践中使用它。
分层三维等距子画面
depthLayering.js 示例文件使用由三个立方体图像组成的 tileset,如图 4-7 所示。
图 4-7。包含 3 个等距 sprite 图像的 tileset
绿色立方体代表迷宫墙壁,红色立方体代表玩家角色,蓝色瓷砖代表地板。每个精灵都在它自己的地图层中。
world.layers = [
**//The floor layer**
[
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1
],
**//The wall layer**
[
2, 2, 2, 2, 2, 2, 2, 2,
2, 0, 0, 0, 0, 0, 0, 2,
2, 0, 2, 0, 0, 2, 0, 2,
2, 0, 0, 0, 0, 2, 2, 2,
2, 0, 0, 0, 0, 0, 0, 2,
2, 2, 2, 0, 2, 0, 0, 2,
2, 0, 0, 0, 0, 0, 0, 2,
2, 2, 2, 2, 2, 2, 2, 2
],
**//The player layer**
[
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 3, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
]
];
为了帮助绘制这张地图,我们需要初始化一个新的 z 值来帮助我们追踪每一层的深度。z 值被初始化为零,并随着每个新图层更新 1。在每个新的 sprite 被创建后,z 值被分配给 sprite 自己的 z 属性,这样当循环结束时,我们可以正确地对它进行深度排序。
**//The `z` index**
let z = 0;
**//Build the game world by looping through each of the arrays**
world.layers.forEach(layer => {
**//Loop through each array element**
layer.forEach((gid, index) => {
**//If the cell isn't empty (0) then create a sprite**
if (gid !== 0) {
**//Find the column and row that the sprite is on and also**
**//its x and y pixel values**
let column, row, x, y;
column = index % world.widthInTiles;
row = Math.floor(index / world.widthInTiles);
x = column * world.cartTilewidth;
y = row * world.cartTileheight;
**//Next, create a different sprite based on what its**
**//`gid` number is**
let sprite;
switch (gid) {
**//The floor**
case 1:
sprite = g.sprite(g.frame("img/isoTileset.png", 128, 0, 64, 64));
break;
**//The walls**
case 2:
sprite = g.sprite(g.frame("img/isoTileset.png", 0, 0, 64, 64));
break;
**//The player**
case 3:
sprite = g.sprite(g.frame("img/isoTileset.png", 64, 0, 64, 64));
player = sprite;
break;
}
**//Add the isometric properties to the sprite**
addIsoProperties(sprite, x, y, world.cartTilewidth, world.cartTileheight);
**//Set the sprite's `x` and `y` pixel position based on its**
**//isometric coordinates**
sprite.x = sprite.isoX;
sprite.y = sprite.isoY;
**//Add the new `z` depth property to the sprite**
sprite.z = z;
**//Add the sprite to the `world` container**
world.addChild(sprite);
}
});
**//Add `1` to `z` for each new layer**
z += 1;
});
**//Move the player into the environment's depth layer**
player.z = 1;
**//Sort the world by depth**
world.children.sort(byDepth);
上面代码的最后两行很重要。循环运行后,地板精灵的 z 值为 0,墙壁的 z 值为 1,玩家角色(红色立方体)的 z 值为 2。然而,我们希望玩家角色与墙壁处于相同的深度水平,所以我们需要手动将玩家的 z 属性设置为 1。如果我们让它的初始值为 2,玩家看起来会漂浮在墙壁上方的一层上。代码做的最后一件事是对世界对象的子数组进行深度排序,以便将玩家精灵组织到正确的层中。
更新深度
每当任何精灵改变他们的位置,他们需要再次进行深度排序。在这个例子的游戏循环中,玩家精灵使用键盘四处移动。每次移动时,世界对象的子数组中的所有精灵都需要重新排序。
if (player.vx !== 0 || player.vy !== 0) {
world.children.sort(byDepth);
}
对数组进行排序是一件计算量很大的事情,所以只有在绝对必要的时候才需要这样做。这就是为什么上面的代码只在玩家精灵的速度改变时才这样做。
用地图编辑器制作等轴测图
如果您正在制作一个大而复杂的等轴测地图,那么使用像 Tiled Editor 这样的工具要比手动编程地图图层阵列容易得多。地图编辑器内置了对等轴测地图的支持,我们可以使用它的输出轻松生成任何类型的等轴测世界。我们只需要以正确的方式配置地图编辑器,并对第 2 章中的 makeTiledWorld 函数做一些小的修改。
运行 cubes.html,如图 4-8 所示,作为一个借助地图编辑器设计的游戏原型的例子。它的运行非常像我们之前的例子,包括键盘移动、碰撞和深度分层。
图 4-8。使用地图编辑器构建的等距游戏原型
让我们看看如何使用 Tiled Editor 来自己构建这样的东西。
配置和构建地图
在开始创建地图编辑器贴图之前,准备一个包含要使用的等轴测贴图的 sprite 表。非常重要的是,记下精灵的等距尺寸。以下是你需要知道的像素尺寸:
-
tile width:sprite 的宽度,从其左边缘到右边缘。
-
tileheight:瓷砖的基底区域的高度。这只是一个被压扁的菱形的高度,它定义了等轴测精灵站立的基础。通常是 tilewidth 值的一半。
图 4-9 说明了如何找到这些值。
图 4-9。tilewidth 和 tileheight 属性值
这些属性是地图编辑器使用的属性名,您可以在地图编辑器生成的 JSON 数据文件中访问它们。
注意
属性名 tilewidth 和 tileheight 是 Tiled Editor 为 JSON 文件使用和生成的。因此,为了保持一致性,我保持了相同的名称和大小写。
现在,您可以使用这些值在地图编辑器中创建新的等轴测图。打开地图编辑器,从主菜单中选择文件➤新建。在“新建地图”对话框中,选择“等轴测”作为方向,并使用上面描述的 tilewidth 和 tileheight 值作为宽度和高度。图 4-10 显示了一个例子。
图 4-10。在地图编辑器中创建新的等轴测地图
但是我们还没完呢!我们还需要弄清楚另外三个值:
-
tileDepth:等距 sprite 的总高度,以像素为单位。图 4-11 显示了该值。
图 4-11。tileDepth 属性描述等轴测精灵的总高度
-
cartWidth:每个平铺网格单元的笛卡尔宽度,以像素为单位。
-
cartHeight:每个平铺网格单元的笛卡尔高度,以像素为单位。
您需要将这些值作为自定义属性添加到切片编辑器的地图属性面板中。图 4-12 显示了这应该是什么样子。
图 4-12。添加自定义地图属性
当地图编辑器生成 JSON 地图数据时,您将能够在属性字段中访问这些值。
"properties":
{
"cartTileheight":"32",
"cartTilewidth":"32",
"tileDepth":"64"
},
在前面的代码示例中,您将看到我们需要如何使用这些值来精确绘制等轴测地图。
现在你已经设置好了所有的地图属性,使用你的等轴测图来构建你的世界,就像你在第 2 章中所做的一样。图 4-13 展示了一个编辑器平铺工作空间的例子。
图 4-13。在地图编辑器中设计等轴测地图
你可以在图 4-13 中看到,我给了红色立方体一个自定义名称属性,其值为“player”,我还使用两个层构建了地图:playerLayer 只包含红色立方体,wallLayer 包含所有的迷宫墙。
完成地图设计后,将其导出为 JSON 文件,现在就可以用它来编写游戏代码了。
makeIsoTiledWorld 函数
function makeIsoTiledWorld(jsonTiledMap, tileset) {
**//Create a group called `world` to contain all the layers, sprites**
**//and objects from the `tiledMap`. The `world` object is going to be**
**//returned to the main game program**
let tiledMap = PIXI.loader.resources[jsonTiledMap].data;
**//A. You need to add three custom properties to your Tiled Editor**
**//map: `cartTilewidth`,`cartTileheight` and `tileDepth`.**
**//Check to make sure that these custom properties exist**
if (!tiledMap.properties.cartTilewidth
&& !tiledMap.properties.cartTileheight
&& !tiledMao.properties.tileDepth) {
throw new Error(
"Set custom cartTilewidth, cartTileheight and tileDepth
map properties in Tiled Editor"
);
}
**//Create the `world` container**
let world = new PIXI.Container();
**//B. Set the `tileHeight` to the `tiledMap`'s `tileDepth` property**
**//so that it matches the pixel height of the sprite tile image**
world.tileheight = parseInt(tiledMap.properties.tileDepth);
world.tilewidth = tiledMap.tilewidth;
**//C. Define the Cartesian dimesions of each tile**
world.cartTileheight = parseInt(tiledMap.properties.cartTileheight);
world.cartTilewidth = parseInt(tiledMap.properties.cartTilewidth);
**//D. Calculate the `width` and `height` of the world, in pixels**
**//using the `world.cartTileHeight` and `world.cartTilewidth`**
**//values**
world.worldWidth = tiledMap.width * world.cartTilewidth;
world.worldHeight = tiledMap.height * world.cartTileheight;
**//Get a reference to the world's height and width in**
**//tiles, in case you need to know this later (you will!)**
world.widthInTiles = tiledMap.width;
world.heightInTiles = tiledMap.height;
**//Create an `objects` array to store references to any**
**//named objects in the map. Named objects all have**
**//a `name` property that was assigned in Tiled Editor**
world.objects = [];
**//The optional spacing (padding) around each tile**
**//This is to account for spacing around tiles**
**//that's commonly used with texture atlas tilesets. Set the**
**//`spacing` property when you create a new map in Tiled Editor**
let spacing = tiledMap.tilesets[0].spacing;
**//Figure out how many columns there are on the tileset.**
**//This is the width of the image, divided by the width**
**//of each tile, plus any optional spacing thats around each tile**
let numberOfTilesetColumns =
Math.floor(
tiledMap.tilesets[0].imagewidth / (tiledMap.tilewidth + spacing)
);
**//E. A `z` property to help track which depth level the sprites are on**
let z = 0;
**//Loop through all the map layers**
tiledMap.layers.forEach(tiledLayer => {
**//Make a group for this layer and copy**
**//all of the layer properties onto it**
let layerGroup = new PIXI.Container();
Object.keys(tiledLayer).forEach(key => {
**//Add all the layer's properties to the group, except the**
**//width and height (because the group will work those our for**
**//itself based on its content)**
if (key !== "width" && key !== "height") {
layerGroup[key] = tiledLayer[key];
}
});
**//Translate `opacity` to `alpha`**
layerGroup.alpha = tiledLayer.opacity;
**//Add the group to the `world`**
world.addChild(layerGroup);
**//Push the group into the world's `objects` array**
**//So you can access it later**
world.objects.push(layerGroup);
**//Is this current layer a `tilelayer`?**
if (tiledLayer.type === "tilelayer") {
**//Loop through the `data` array of this layer**
tiledLayer.data.forEach((gid, index) => {
let tileSprite, texture, mapX, mapY, tilesetX, tilesetY,
mapColumn, mapRow, tilesetColumn, tilesetRow;
**//If the grid id number (`gid`) isn't zero, create a sprite**
if (gid !== 0) {
**//Figure out the map column and row number that we're on, and then**
**//calculate the grid cell's x and y pixel position**
mapColumn = index % world.widthInTiles;
mapRow = Math.floor(index / world.widthInTiles);
**//F. Use the Cartesian values to find the**
**//`mapX` and `mapY` values**
mapX = mapColumn * world.cartTilewidth;
mapY = mapRow * world.cartTileheight;
**//Figure out the column and row number that the tileset**
**//image is on, and then use those values to calculate**
**//the x and y pixel position of the image on the tileset**
tilesetColumn = ((gid - 1) % numberOfTilesetColumns);
tilesetRow = Math.floor((gid - 1) / numberOfTilesetColumns);
tilesetX = tilesetColumn * world.tilewidth;
tilesetY = tilesetRow * world.tileheight;
**//Compensate for any optional spacing (padding) around the tiles if**
**//there is any. This bit of code accumlates the spacing offsets from the**
**//left side of the tileset and adds them to the current tile's position**
if (spacing > 0) {
tilesetX
+= spacing + (spacing * ((gid - 1) % numberOfTilesetColumns));
tilesetY
+= spacing + (spacing * Math.floor((gid - 1) / numberOfTilesetColumns));
}
**//Use the above values to create the sprite's image from**
**//the tileset image**
texture = g.frame(
tileset, tilesetX, tilesetY,
world.tilewidth, world.tileheight
);
**//I've dedcided that any tiles that have a `name` property are important**
**//and should be accessible in the `world.objects` array**
let tileproperties = tiledMap.tilesets[0].tileproperties,
key = String(gid - 1);
**//If the JSON `tileproperties` object has a sub-object that**
**//matches the current tile, and that sub-object has a `name` property,**
**//then create a sprite and assign the tile properties onto**
**//the sprite**
if (tileproperties[key] && tileproperties[key].name) {
**//Make a sprite**
tileSprite = new PIXI.Sprite(texture);
**//Copy all of the tile's properties onto the sprite**
**//(This includes the `name` property)**
Object.keys(tileproperties[key]).forEach(property => {
tileSprite[property] = tileproperties[key][property];
});
**//Push the sprite into the world's `objects` array**
**//so that you can access it by `name` later**
world.objects.push(tileSprite);
}
**//If the tile doesn't have a `name` property, just use it to**
**//create an ordinary sprite (it will only need one texture)**
else {
tileSprite = new PIXI.Sprite(texture);
}
**//G. Add isometric properties to the sprite**
addIsoProperties(
tileSprite, mapX, mapY,
world.cartTilewidth, world.cartTileheight
);
**//H. Use the isometric position to add the sprite to the world**
tileSprite.x = tileSprite.isoX;
tileSprite.y = tileSprite.isoY;
tileSprite.z = z;
**//Make a record of the sprite's index number in the array**
**//(We'll use this for collision detection later)**
tileSprite.index = index;
**//Make a record of the sprite's `gid` on the tileset.**
**//This will also be useful for collision detection later**
tileSprite.gid = gid;
**//Add the sprite to the current layer group**
layerGroup.addChild(tileSprite);
}
});
}
**//Is this layer an `objectgroup`?**
if (tiledLayer.type === "objectgroup") {
tiledLayer.objects.forEach(object => {
**//We're just going to capture the object's properties**
**//so that we can decide what to do with it later.**
**//Get a reference to the layer group the object is in**
object.group = layerGroup;
**//Push the object into the world's `objects` array**
world.objects.push(object);
});
}
**//I. Add 1 to the z index (the first layer will have a z index of `1`)**
z += 1;
});
**//Search functions**
world.getObject = (objectName) => {
let searchForObject = () => {
let foundObject;
world.objects.some(object => {
if (object.name && object.name === objectName) {
foundObject = object;
return true;
}
});
if (foundObject) {
return foundObject;
} else {
throw new Error("There is no object with the property name: " + objectName);
}
};
**//Return the search function**
return searchForObject();
};
world.getObjects = (objectNames) => {
let foundObjects = [];
world.objects.forEach(object => {
if (object.name && objectNames.indexOf(object.name) !== -1) {
foundObjects.push(object);
}
});
if (foundObjects.length > 0) {
return foundObjects;
} else {
throw new Error("I could not find those objects");
}
return foundObjects;
};
**//That's it, we're done!**
**//Finally, return the `world` object back to the game program**
return world;
}
有了这个新的 makeIsoTiledWorld 函数,我们可以在地图编辑器中绘制我们创建的地图,并访问它包含的所有精灵。让我们来看看下一步该怎么做。
构建游戏世界
使用 makeIsoTiledWorld 函数就像我们最初的 makeTiledWorld 函数一样,所以没有什么新东西需要学习。你需要记住的唯一一件事是,如果你改变任何精灵的位置或 z 属性,你需要使用我们的自定义 byDepth 函数重新排序精灵。在这种情况下,我们所有的精灵都在每个地图层容器的子数组中,所以你需要像这样对它们进行深度排序:
mapLayer.children.sort(byDepth);
除此之外,没什么新东西可学了。这是 cubes.js 文件的完整应用程序代码,如图 4-8 所示。它加载了在地图编辑器中创建的等距图,增加了键盘交互性、碰撞和精确的深度排序。您以前在其他上下文中见过所有这些代码,注释解释了细节。
**//The files we want to load**
let thingsToLoad = [
"img/cubes.png",
"img/cubes.json"
];
**//Create a new Hexi instance, and start it**
let g = hexi(512, 512, setup, thingsToLoad);
**//Scale the canvas to the maximum browser dimensions**
g.scaleToWindow();
**//Declare variables used in more than one function**
let world, leftArrow, upArrow,
rightArrow, downArrow, message, wallLayer,
player, wallMapArray;
**//Start Hexi**
g.start();
function setup() {
**//Make the world from the Tiled JSON data**
world = makeIsoTiledWorld(
"img/cubes.json",
"img/cubes.png"
);
**//Add the world to the `stage`**
g.stage.addChild(world);
**//Position the world inside the canvas**
let canvasOffset = (g.canvas.width / 2) - world.tilewidth / 2;
world.x += canvasOffset;
world.y = 0;
**//Get the objects we need from the world**
player = world.getObject("player");
wallLayer = world.getObject("wallLayer");
**//Add the player to the wall layer and set it at**
**//the same depth level as the walls**
wallLayer.addChild(player);
player.z = 0;
wallLayer.children.sort(byDepth);
**//Initialize the player's velocity to zero**
player.vx = 0;
player.vy = 0;
**//Make a text object**
message = g.text("", "16px Futura", "black");
message.setPosition(5, 0);
**//Create the keyboard objects**
leftArrow = g.keyboard(37);
upArrow = g.keyboard(38);
rightArrow = g.keyboard(39);
downArrow = g.keyboard(40);
**//Assign the key `press` actions**
player.direction = "none";
leftArrow.press = () => player.direction = "left";
upArrow.press = () => player.direction = "up";
rightArrow.press = () => player.direction = "right";
downArrow.press = () => player.direction = "down";
leftArrow.release = () => player.direction = "none";
upArrow.release = () => player.direction = "none";
rightArrow.release = () => player.direction = "none";
downArrow.release = () => player.direction = "none";
**//Set the game state to `play`**
g.state = play;
}
function play() {
**//Change the player character's velocity if it's centered over a grid cell**
if (Math.floor(player.cartX) % world.cartTilewidth === 0
&& Math.floor(player.cartY) % world.cartTileheight === 0) {
switch (player.direction) {
case "up":
player.vy = -2;
player.vx = 0;
break;
case "down":
player.vy = 2;
player.vx = 0;
break;
case "left":
player.vx = -2;
player.vy = 0;
break;
case "right":
player.vx = 2;
player.vy = 0;
break;
case "none":
player.vx = 0;
player.vy = 0;
break;
}
}
**//Update the player's Cartesian position, based on its velocity**
player.cartY += player.vy;
player.cartX += player.vx;
**//Wall collision**
**//Get a reference to the wall map array**
wallMapArray = wallLayer.data;
**//Use `hitTestIsoTile` to check for a collision**
let playerVsGround = hitTestIsoTile(player, wallMapArray, 0, world, "every");
**//If there's a collision, prevent the player from moving.**
**//Subtract its velocity from its position and then set its velocity to zero**
if (!playerVsGround.hit) {
player.cartX -= player.vx;
player.cartY -= player.vy;
player.vx = 0;
player.vy = 0;
}
**//Add world boundaries**
let top = 0,
bottom = (world.heightInTiles * world.cartTileheight),
left = 0,
right = (world.widthInTiles * world.cartTilewidth);
**//Prevent the player from crossing any of the world boundaries**
**//Top**
if (player.cartY < 0) {
player.cartY = top;
}
**//Bottom**
if (player.cartY + player.cartHeight > bottom) {
player.cartY = bottom - player.cartHeight;
}
**//Left**
if (player.cartX < left) {
player.cartX = left;
}
**//Right**
if (player.cartX + player.cartWidth > right) {
player.cartX = right - player.cartWidth;
}
**//Position the sprite's screen `x` and `y` position**
**//using its isometric coordinates**
player.x = player.isoX;
player.y = player.isoY;
**//Get the player's index position in the map array**
player.index = g.getIndex(
player.cartX, player.cartY,
world.cartTilewidth, world.cartTileheight, world.widthInTiles
);
**//Depth sort the sprites if the player is moving**
if (player.vx !== 0 || player.vy !== 0) {
wallLayer.children.sort(byDepth);
}
**//Display the player's x, y and index values**
message.content = `index: ${player.index}`;
}
摘要
无论您的游戏是使用笛卡尔还是等轴测投影显示,使用基于图块的游戏背后的基本逻辑是相同的。正如您在本章中看到的,如果您知道如何绘制地图、添加键盘交互性以及使用笛卡尔坐标进行碰撞检测,您可以将相同的逻辑应用于等轴测地图,并添加两个简单的小转换公式。您还学习了如何将这些公式应用于指针位置坐标,以便您可以准确地选择屏幕上的等轴精灵。此外,您还学习了如何使用地图编辑器创建和绘制等轴测图,并使用 z 值对这些精灵进行精确的深度排序,以便它们显示在正确的深度层中。这些都是你开始制作各种各样的等距游戏所需的基本技能,从策略游戏到地牢爬虫。
既然你已经知道了如何创建各种交互式的基于磁贴的游戏环境,那么你如何才能创建能够在这些环境中智能导航的游戏角色呢?这就是下一章的内容:寻路。
版权属于:月萌API www.moonapi.com,转载请注明出处