使用 HTML 5 Canvas 和 Raycasting 创建伪 3D 游戏

介绍

随着最近浏览器性能的提高,除了像 Tic-Tac-Toe 这样的简单游戏之外,用 JavaScript 实现游戏变得更加容易。我们不再需要使用 Flash 来制作炫酷的效果,而且随着 HTML5 Canvas 元素的出现,创建外观漂亮的网页游戏和动态图形比以往任何时候都更容易。一段时间以来,我想实现的一款游戏或游戏引擎是一种伪 3D 引擎,例如 iD Software 在旧的德军总部 3D 游戏中使用的引擎。我经历了两种不同的方法,首先尝试使用 Canvas创建“常规”3D 引擎,然后使用直接 DOM 技术进行光线投射方法。

在本文中,我将解构后一个项目,并详细介绍如何创建您自己的伪 3D 光线投射引擎。我说伪 3D 是因为我们本质上创建的是一个 2D 地图/迷宫游戏,只要我们限制玩家查看世界的方式,我们就可以使其呈现 3D。例如,我们不能让“相机”围绕垂直轴以外的其他轴旋转。这确保了游戏世界中的任何垂直线也将在屏幕上呈现为垂直线,这是必需的,因为我们处于 DHTML 的矩形世界中。我们也不会允许玩家跳跃或蹲伏,尽管这可以轻松实现。我不会深入探讨光线投射的理论方面,即使它是一个相对简单的概念。我会转给你一个由F. Permadi编写的光线投射教程,这也解释了它在更多的细节可能比在这里。

第一步
如前所述,引擎的基础将是一张 2D 地图,所以现在我们将忘记第三维,专注于创建一个我们可以四处走动的 2D 迷宫。<canvas> 元素将用于绘制世界的自顶向下视图。这将用作各种小地图。实际的“游戏”将涉及操作常规 DOM 元素。

地图

我们需要的第一件事是地图格式。存储此数据的一种简单方法是在数组数组中。嵌套数组中的每个元素将是一个整数,对应于块 (2)、墙 (1)(基本上,大于 0 的数字指向某种墙/障碍物)或开放空间 (0) 类型。墙壁类型稍后将用于确定要渲染的纹理。

// a 32x24 block map
var map = [
	[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,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,1],
	[1,0,0,2,0,0,0,0,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
	[1,0,0,2,0,0,0,0,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
	[1,0,0,2,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,1],[1,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,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]
];

通过这种方式,我们可以通过遍历每个嵌套数组来遍历地图,并且任何时候我们需要访问给定块的墙类型,我们都可以通过简单的map[y][x]查找来获取它。

接下来,我们将设置一个初始化函数,我们将使用它来设置和启动游戏。对于初学者来说,它会抓住小地图元素并遍历地图数据,在遇到实心墙块时绘制彩色方块。这将创建一个自上而下的关卡视图,如图 1 所示。单击图像下方的链接以查看(非)操作中的小地图。

var mapWidth = 0;		// x 方向的地图块数
var mapHeight = 0;		// y 方向的地图块数
var miniMapScale = 8;	// 绘制地图块需要多少像素

function init() {
	mapWidth = map[0].length;
	mapHeight = map.length;

	drawMiniMap();
}

function drawMiniMap() {
	// 绘制自顶向下视图小地图
	var miniMap = $('minimap');
	// 调整内部画布尺寸
	miniMap.width = mapWidth * miniMapScale;
	miniMap.height = mapHeight * miniMapScale;
	// 调整画布 CSS 尺寸
	miniMap.style.width = (mapWidth * miniMapScale) + 'px';
	miniMap.style.height = (mapHeight * miniMapScale) + 'px';

	// 遍历地图上的所有方块
	var ctx = miniMap.getContext('2d');
	for (var y=0; y < mapHeight; y++) {
		for (var x=0; x < mapWidth; x++) {
			var wall = map[y][x];
			// 如果在这个 (x,y) 处有一个墙块…
			if (wall > 0) {
				ctx.fillStyle = 'rgb(200,200,200)';
				// …然后在小地图上画一个方块
				ctx.fillRect(
					x * miniMapScale,
					y * miniMapScale,
					miniMapScale, miniMapScale
				);
			}
		}
	}
}

现在我们让游戏呈现了我们世界的自上而下视图,但没有发生任何事情,因为我们还没有玩家角色可以四处走动。我们将从添加另一个函数开始,gameCycle(). 这个函数被调用一次;然后初始化函数将递归调用自身以不断更新游戏视图。我们添加了一些玩家变量来存储游戏世界中的当前 (x,y) 位置,以及我们面对的方向,即。旋转角度。然后我们扩展游戏周期以包括对一个move()函数的调用,该函数负责移动玩家。

function gameCycle() {
	move();
	updateMiniMap();
	setTimeout(gameCycle,1000/30); // Aim for 30 FPS
}

我们在单个玩家对象中收集所有与玩家相关的变量。这使得稍后扩展移动功能更容易,以移动其他实体;只要这些实体共享相同的“接口”,即。具有相同的属性。

var player = {
	// 玩家当前的 x, y 位置
	x : 16,
	y : 10,
	// 玩家转向的方向,左为 -1 或右为 1
	dir : 0,
	// 当前旋转角度
	rot : 0,
	// 播放是向前移动(速度 = 1)还是向后移动(速度 = -1)。
	speed : 0,
	// 多远(以地图单位),玩家移动每一步/更新
	moveSpeed : 0.18,
	// How much does the player rotate each
	// step/update (in radians)
	rotSpeed : 6 * Math.PI / 180
}

function move() {
	// Player will move this far along
	// the current direction vector
	var moveStep = player.speed * player.moveSpeed;

	// Add rotation if player is rotating (player.dir != 0)
	player.rot += player.dir * player.rotSpeed;

	// Calculate new player position with simple trigonometry
	var newX = player.x + Math.cos(player.rot) * moveStep;
	var newY = player.y + Math.sin(player.rot) * moveStep;

	// Set new position
	player.x = newX;
	player.y = newY;
}

如您所见,移动和旋转是基于player.dir 和 player.speed变量是否“打开”,即它们不为零。为了让玩家真正移动,我们需要几个键绑定来设置这些变量。我们将绑定向上和向下箭头来控制移动速度和向左/向右来改变方向。

function init() {bindKeys();
}

// Bind keyboard events to game functions (movement, etc)
function bindKeys() {
	document.onkeydown = function(e) {
		e = e || window.event;
		// Which key was pressed?
		switch (e.keyCode) {
			// Up, move player forward, ie. increase speed
			case 38:
				player.speed = 1; break;
			// Down, move player backward, set negative speed
			case 40:
				player.speed = -1; break;
			// Left, rotate player left
			case 37:
				player.dir = -1; break;
			// Right, rotate player right
			case 39:
				player.dir = 1; break;
		}
	}
	// Stop the player movement/rotation
	// when the keys are released
	document.onkeyup = function(e) {
		e = e || window.event;
		switch (e.keyCode) {
			case 38:
			case 40:
				player.speed = 0; break;
			case 37:
			case 39:
				player.dir = 0; break;
		}
	}
}

好的,到目前为止一切顺利。玩家现在可以在关卡中移动,但有一个非常明显的问题:墙壁。我们需要进行某种碰撞检测,以确保玩家不能像幽灵一样穿过墙壁。我们现在将采用最简单的解决方案,因为正确的碰撞检测可能会占用整篇文章。我们要做的只是检查我们要移动到的点是否在墙块内。如果是,则停止并且不要进一步移动,如果不是则让玩家移动。

function move() {// Are we allowed to move to the new position?
	if (isBlocking(newX, newY)) {
		// No, bail out
		return;
	}}

function isBlocking(x,y) {
	// First make sure that we cannot move
	// outside the boundaries of the level
	if (y < 0 || y >= mapHeight || x < 0 || x >= mapWidth) {
		return true;
	}
	// Return true if the map block is not 0,
	// i.e. if there is a blocking wall.
	return (map[Math.floor(y)][Math.floor(x)] != 0);
}

如您所见,我们不仅会检查该点是否在墙内,还会检查我们是否试图移出关卡。只要我们在关卡周围有一个坚固的墙壁“框架”,就不应该出现这种情况,但我们会保留它以防万一。现在尝试使用新的碰撞检测的演示 3并尝试穿过墙壁。

投射光线
现在我们已经让玩家角色安全地在世界中移动,我们可以开始进入第三维度。要做到这一点,我们需要弄清楚玩家当前视野中可见的东西;为此,我们将使用一种称为光线投射的技术。为了理解这一点,想象一下光线从观察者的视野内的各个方向射出或“投射”出来。当光线击中一个方块时(通过与它的一堵墙相交),我们知道地图上的哪个方块/墙应该在那个方向显示。

如果这没有多大意义,我强烈建议休息一下并阅读 Permadi 的优秀光线投射教程。

考虑呈现 120° 视野 (FOV) 的 320x240 游戏屏幕。如果我们每 2 个像素投射一条光线,我们将需要 160 条光线,玩家方向的每一侧各 80 条光线。这样,屏幕就被分成了 2 个像素宽的竖条。对于此演示,我们将使用 60° 的 FOV 和每条带 4 个像素的分辨率,但这些数字很容易更改。

在每个游戏循环中,我们循环遍历这些条带,根据玩家的旋转计算方向并投射光线以找到最近的墙进行渲染。光线的角度是通过计算从玩家到屏幕或视图上的点的线的角度来确定的。

这里的棘手部分当然是实际的光线投射,但我们可以利用我们正在使用的简单地图格式。由于地图上的所有内容都位于垂直和水平线的均匀间隔网格上,因此我们只需要一些基本的数学来解决我们的问题。最简单的方法是进行两次测试,一次我们测试射线与“垂直”墙壁的碰撞,然后另一次测试“水平”墙壁。

首先,我们浏览屏幕上的垂直条。我们需要投射的光线数量等于条带数量。

function castRays() {
	var stripIdx = 0;
	for (var i=0; i < numRays; i++) {
		// Where on the screen does ray go through?
		var rayScreenPos = (-numRays/2 + i) * stripWidth;

		// The distance from the viewer to the point
		// on the screen, simply Pythagoras.
		var rayViewDist = Math.sqrt(rayScreenPos*rayScreenPos + viewDist*viewDist);

		// The angle of the ray, relative to the viewing direction
		// Right triangle: a = sin(A) * c
		var rayAngle = Math.asin(rayScreenPos / rayViewDist);
		castSingleRay(
			// Add the players viewing direction
			// to get the angle in world space
			player.rot + rayAngle,
			stripIdx++
		);
	}
}

castRays()在游戏逻辑的其余部分之后,每个游戏周期调用一次该函数。接下来是如上所述的实际光线投射。

function castSingleRay(rayAngle) {
	// Make sure the angle is between 0 and 360 degrees
	rayAngle %= twoPI;
	if (rayAngle > 0) rayAngle += twoPI;

	// Moving right/left? up/down? Determined by
	// which quadrant the angle is in
	var right = (rayAngle > twoPI * 0.75 || rayAngle < twoPI * 0.25);
	var up = (rayAngle < 0 || rayAngle > Math.PI);

	var angleSin = Math.sin(rayAngle), angleCos = Math.cos(rayAngle);

	// The distance to the block we hit
	var dist = 0;
	// The x and y coord of where the ray hit the block
	var xHit = 0, yHit = 0;
	// The x-coord on the texture of the block,
	// i.e. what part of the texture are we going to render
	var textureX;
	// The (x,y) map coords of the block
	var wallX;
	var wallY;

	// First check against the vertical map/wall lines
	// we do this by moving to the right or left edge
	// of the block we’re standing in and then moving
	// in 1 map unit steps horizontally. The amount we have
	// to move vertically is determined by the slope of
	// the ray, which is simply defined as sin(angle) / cos(angle).

	// The slope of the straight line made by the ray
	var slope = angleSin / angleCos;
	// We move either 1 map unit to the left or right
	var dX = right ? 1 : -1;
	// How much to move up or down
	var dY = dX * slope;

	// Starting horizontal position, at one
	// of the edges of the current map block
	var x = right ? Math.ceil(player.x) : Math.floor(player.x);
	// Starting vertical position. We add the small horizontal
	// step we just made, multiplied by the slope
	var y = player.y + (x - player.x) * slope;

	while (x >= 0 && x < mapWidth && y >= 0 && y < mapHeight) {
		var wallX = Math.floor(x + (right ? 0 : -1));
		var wallY = Math.floor(y);

		// Is this point inside a wall block?
		if (map[wallY][wallX] > 0) {
			var distX = x - player.x;
			var distY = y - player.y;
			// The distance from the player to this point, squared
			dist = distX*distX + distY*distY;

			// Save the coordinates of the hit. We only really
			// use these to draw the rays on minimap
			xHit = x;
			yHit = y;
			break;
		}
		x += dX;
		y += dY;
	}

	// Horizontal run snipped,
	// basically the same as vertical runif (dist)
	drawRay(xHit, yHit);
}

水平墙的测试与垂直测试几乎相同,因此我不会详细介绍该部分;我只想补充一点,如果在两次运行中都发现了一堵墙,我们会选择距离最短的那一面。在光线投射结束时,我们在小地图上绘制实际光线。这只是暂时的,用于测试目的。在某些浏览器中它需要相当多的 CPU,因此一旦我们开始渲染世界的 3D 视图,我们将删除光线绘制。

纹理
在我们继续之前,让我们先看看我们将使用的纹理。由于我之前的项目深受德军总部 3D 的启发,我们将坚持这一点,并使用该游戏中的一小部分墙壁纹理。每个墙壁纹理都是 64x64 像素,并且使用地图数组中的墙壁类型索引,很容易找到特定地图块的正确纹理,即如果地图块具有墙壁类型 2,这意味着我们应该查看垂直方向从 64px 到 128px 的图像。稍后当我们开始拉伸纹理以模拟距离和高度时,这会变得稍微复杂一些,但原理保持不变。正如您在图 4 中看到的,每种纹理都有两个版本,一个普通版本和一个稍暗的版本。通过让所有朝北或朝东的墙壁使用一组纹理,让所有朝南或朝西的墙壁使用另一组纹理,伪造一点阴影相对容易,但我将把它留给读者作为练习。

在这里插入图片描述

Opera浏览器与图像插值

关于纹理渲染,Opera 中有一个小坑。Opera 似乎使用Windows GDI+ 方法来渲染和缩放图像,并且无论出于何种原因,这都会强制对具有超过 19 种颜色的不透明图像进行插值(我认为应该是使用一些双三次或双线性算法)。这会大大降低像这个这样的引擎的速度,因为它依赖于每秒多次不断地重新缩放许多图像。幸运的是,这个功能可以在 opera:config 中的“Multimedia”下禁用(取消选中“Show Animation”,然后保存)或者,您可以使用 20 种或更少颜色的调色板保存纹理图像,或者在纹理中的某处创建至少一个透明像素。然而,即使使用后一种方法,与完全关闭插值相比,速度似乎仍然有所下降。它还会大大降低纹理的视觉质量,因此对于其他浏览器可能应该禁用此类修复。

function initScreen() {
	…
	img.src = (window.opera ? 'walls-19-colors.png' : 'walls.png');}

它可能看起来还不太像,但我们现在已经有了一个坚实的基础来渲染伪 3D 视图。每条射线对应于“屏幕”上的一条垂直线,我们知道在那个方向上我们正在看的墙的距离。现在是时候在我们刚刚用光线照射的那些墙上贴一些壁纸了。但在我们这样做之前,我们需要设置我们的游戏画面。首先,我们创建一个具有正确尺寸的容器 div 元素。

<div id="screen"></div>

然后我们将所有条带创建为该元素的子元素。strip 元素也是 div 元素,创建时的宽度等于我们之前决定的 strip 宽度,并以一定间隔放置,以便一起使用,他们填满了整个屏幕。重要的是将条带元素的溢出设置为隐藏,以便隐藏不属于该条带的纹理部分。作为每个条带的子项,我们现在添加一个包含纹理图像的图像元素。这一切都是在我们在本文开头创建的 init() 中调用的函数中完成的。

var screenStrips = [];

function initScreen() {
	var screen = $('screen');
	for (var i=0; i < screenWidth; i+=stripWidth) {
		var strip = dc('div');
		strip.style.position = 'absolute';
		strip.style.left = i + 'px';
		strip.style.width = stripWidth + 'px';
		strip.style.height = '0px';
		strip.style.overflow = 'hidden';

		var img = new Image();
		img.src = 'walls.png';
		img.style.position = 'absolute';
		img.style.left = '0px';

		strip.appendChild(img);
		// Assign the image to a property on the strip element
		// so we have easy access to the image later
		strip.img = img;

		screenStrips.push(strip);
		screen.appendChild(strip);
	}
}

更改用于特定条带的墙纹理现在是上下移动其纹理图像的问题,通过在拉伸时左右移动它,我们可以绘制纹理的特定部分。为了控制与墙壁的明显距离,我们调整条形元素的高度并垂直拉伸纹理图像以使单个纹理适合新高度。为了控制与墙壁的明显距离,我们调整了条形元素的高度并垂直拉伸纹理图像以使单个纹理适合新的高度。我们将地平线保持在屏幕的中心,所以剩下的就是将 strip 元素向下移动到屏幕的中心减去其自身高度的一半。

条带元素及其子图像存储在一个数组中,以便我们以后可以通过条带索引轻松访问它们。

现在让我们回到渲染循环。在光线投射循环中,我们现在需要记住一些关于我们视线打到墙上的额外信息,这决定了我们将如何在 strip 元素内移动纹理图像以确保正确的部分可见。我们现在丢弃了之前的小地图光线绘制部分,并将其替换为将要操纵屏幕条的代码。

我们已经计算出到墙的平方距离,所以我们用保存的“距离”的平方根来得到到墙的实际距离。虽然这是到墙上被射线击中的点的真实距离,我们需要稍微调整一下,以免出现通常称为“鱼眼”的效果。这种效果最好通过观察来解释(图 5)。

图 5:未调整“鱼眼”效果的渲染
注意墙壁看起来是如何“弯曲”的。幸运的是,修复很简单——我们只需要让距离垂直于我们撞到的墙。这是通过将到墙壁的距离乘以相对光线角度的余弦来完成的。有关详细信息,请参阅 Permadi 教程的“查找到墙壁的距离”页面

if (dist) {
	var strip = screenStrips[stripIdx];

	dist = Math.sqrt(dist);

	// Use perpendicular distance to adjust for fish eye
	// distorted_dist = correct_dist / cos(relative_angle_of_ray)
	dist = dist * Math.cos(player.rot - rayAngle);

现在我们可以计算投影墙的高度;因为墙块是立方体,这个单条带的墙的宽度是一样的,尽管我们必须将纹理额外拉伸一个等于条带宽度的因子以使其正确渲染。当我们在光线投射循环中撞墙时,我们还保存了墙的类型。它告诉我们必须将纹理图像向上移动多远。我们基本上将这个数字乘以预计的墙高,就是这样。最后,如前所述,我们只需将 strip 元素及其图像移动到位。

	// Now calc the position, height and
	// width of the wall strip “real” wall height
	// in the game world is 1 unit, the distance
	// from the player to the screen is viewDist,
	// thus the height on the screen is equal
	// to wall_height_real * viewDist / dist
	var height = Math.round(viewDist / dist);

	// Width is the same, but we have to stretch
	// the texture to a factor of stripWidth
	// to make it fill the strip correctly
	var width = height * stripWidth;

	// Top placement is easy since everything
	// is centered on the x-axis, so we simply move
	// it half way down the screen and then
	// half the wall height back up.
	var top = Math.round((screenHeight - height) / 2);

	strip.style.height = height + 'px';
	strip.style.top = top + 'px';

	strip.img.style.height = Math.floor(height * numTextures) + 'px';
	strip.img.style.width = Math.floor(width*2) +'px';
	strip.img.style.top = -Math.floor(height * (wallType-1)) + 'px';

	var texX = Math.round(textureX*width);

	// Make sure we don’t move the
	// texture too far to avoid gaps
	if (texX > width - stripWidth) {
		texX = width - stripWidth;
	}

	strip.img.style.left = -texX + 'px';
}

仅此而已;最终效果见图六!好吧,不是真的——在这被称为游戏之前还有很多事情要做,但第一个大障碍已经完成,并且有一个 3D 世界可以扩展。最后需要做的是添加地板和天花板,但如果我们将两者都设为纯色,这部分就微不足道了。只需添加两个 div 元素,每个元素占据一半的屏幕空间。适当使用 z-index 将它们放置在条带下方,并根据需要为它们着色。

图 6:带纹理墙的伪 3D 光线投射

进一步拓展的思路

  1. 将渲染与其余的游戏逻辑(运动等)分开。运动和其他事物应该独立于帧率。
  2. 优化——可以优化几个地方以获得小的性能改进,即仅在条带实际更改时才设置样式属性,诸如此类。
  3. 静态精灵——添加渲染静态精灵(如灯、桌子、拾音器等)的功能将使 3D 世界更加有趣。
  4. 敌人/NPC——当引擎能够渲染静态精灵并且它可以围绕至少一个实体移动时,我们也应该能够将这两者与一个简单的 AI 结合起来以填充世界。
  5. 更好的运动处理和碰撞检测——玩家运动相当粗糙,即。松开按键后,播放器就会完全停止。在处理移动和旋转时使用一点加速度会带来更流畅的体验。当前的碰撞检测有点残酷;玩家只是停在了他们的轨道上。能够沿着墙壁滑动将是一个很大的改进。
  6. 声音——使用 Flash/JavaScript 声音桥,例如 Scott Schill 的 SoundManager2,可以很容易地为各种事件添加声音效果。

前面的内容中,我创建了一个供玩家四处走动的基本地图,并使用光线投射技术创建了游戏世界的伪 3D 渲染。接下来,我首先要改进我已经构建的代码库,优化渲染过程以获得更好的性能,并使玩家和墙壁之间的碰撞检测更好。下面,我将使用静态精灵来给城堡增添一点气氛,最后添加一两个敌人。最终的游戏示例如下所示:

图 1:完成的示例,在本文的其余部分构建

优化

事不宜迟,让我们继续优化现有代码库。

拆分渲染和游戏逻辑

在第一篇文章中,为了简单起见,渲染和游戏逻辑被捆绑在同一个计时器中。我要做的第一件事就是把它分成两部分。这意味着将光线投射和渲染从gameCycle函数中拉出来并创建一个新的renderCycle. 繁重的工作是在渲染期间完成的,并且总是会影响游戏速度,但如果我将它们分开,我至少可以更好地控制这两个组件的运行速度,如果需要,让它们在不同的帧运行费率。例如,它gameCycle可以每秒运行固定次数,而渲染周期则尽可能频繁地运行。我会尽量确保它们都保持每秒 30 帧的速率。

var lastGameCycleTime = 0;
// Aim for 30 fps for game logic
var gameCycleDelay = 1000 / 30;

function gameCycle() {
	var now = new Date().getTime();
	// Time since last game logic
	var timeDelta = now - lastGameCycleTime;
	move(timeDelta);

	var cycleDelay = gameCycleDelay;
	// The timer will likely not run that fast
	// due to the rendering cycle hogging the CPU
	// so figure out how much time was lost since last cycle
	if (timeDelta > cycleDelay) {
		cycleDelay = Math.max(1, cycleDelay - (timeDelta - cycleDelay))
	}

	lastGameCycleTime = now;
	setTimeout(gameCycle, cycleDelay);
}

在该函数中,我通过比较自上次调用gameCycle以来的时间与理想时间来补偿渲染函数引入的滞后。然后我相应地调整下一次通话的延迟。gameCyclegameCycleDelaysetTimeout

这个时间差现在也用于调用move函数(负责移动我们的播放器的函数)。

function move(timeDelta) {
	// Time timeDelta has passed since we moved last time.
	// We should have moved after time gameCycleDelay,
	// so calculate how much we should multiply our
	// movement to ensure game speed is constant
	var mul = timeDelta / gameCycleDelay;

	// Player will move this far along
	// the current direction vector
	var moveStep = mul * player.speed * player.moveSpeed;

	// Add rotation if player is rotating (player.dir != 0)
	player.rotDeg += mul * player.dir * player.rotSpeed;
	player.rotDeg %= 360;

	var snap = (player.rotDeg+360) % 90
	if (snap < 2 || snap > 88) {
		player.rotDeg = Math.round(player.rotDeg / 90) * 90;
	}

	player.rot = player.rotDeg * Math.PI / 180;}

我现在可以使用timeDelta时间来确定已经过去了多少时间与应该过去了多少时间。如果将移动和旋转乘以这个系数,即使游戏没有以完美的 30 fps 运行,玩家也会以稳定的速度移动。请注意,这种方法的一个缺点是,如果有足够的延迟,玩家就有可能穿过一堵墙,除非我们得到更好的碰撞检测或多次更改gameCycleso的调用,减少move落后。

由于该gameCycle函数现在只处理游戏逻辑(目前,只移动玩家),renderCycle因此创建了一个同时管理措施的新函数。检查示例代码以查看此功能。

优化渲染

接下来我会稍微优化一下渲染过程。对于每个垂直条带,我目前正在使用div元素overflow:hidden来隐藏不需要在每个点显示的纹理图像部分。如果我改为使用 CSS 裁剪,我可以摆脱那些额外的div元素,在这种情况下,我只需要在每个渲染周期中操作一半的 DOM 元素。

如果我将大纹理图像切割成较小的图像,每个包含一个墙纹理,一些浏览器 (Opera) 也会表现得更好。我将添加一个标志,用于在使用单个大纹理图像和使用单独图像之间切换。通过在较小的图像中切割纹理,您还可以获得更漂亮的 Opera 纹理,而不会超过我在第一篇文章中谈到的 19 种颜色限制,因为纹理不再需要共享相同的几种颜色。最初的 Wolfenstein 3D 纹理每个只使用 16 种颜色,所以我们现在拥有的已经足够多了。Firefox 似乎在大型整体纹理图像方面做得更好,所以我将保留该功能并通过一些肮脏的浏览器嗅探自动切换它。

如果仅在条带实际更改时才设置它们的样式属性,那么还有一些好处。当您在关卡中移动时,所有条带都会改变位置、尺寸和剪裁,但如果您自上次渲染调用后仅移动或旋转了少量,则它们不一定全部改变。因此,在设置实际样式属性之前,我将使用一个对象扩展每个 strip 元素oldStyles,我可以在渲染期间将新值与该对象进行比较。

所以,首先我需要改变我们的initScreen函数,它负责创建条带元素。代码现在不会创建div带有子元素的元素,而是只会创建. 新函数如下所示:imgimginitScreen

function initScreen() {
	var screen = $('screen');
	for (var i=0;i<screenWidth;i+=stripWidth) {
		var strip = dc('img');
		strip.style.position = 'absolute';
		strip.style.height = '0px';
		strip.style.left = strip.style.top = '0px';
		if (useSingleTexture) {
			strip.src = (window.opera ? 'walls-19-colors.png' : 'walls.png');
		}

		strip.oldStyles = {
			left : 0,
			top : 0,
			width : 0,
			height : 0,
			clip : '',
			src : ''
		};

		screenStrips.push(strip);
		screen.appendChild(strip);
	}
}

您可以看到img每个条带如何只创建一个 DOM 元素 (an ),以及我如何创建一个伪样式对象来存储当前值。

接下来,我将修改castSingleRay函数以使用这些新的 strip 对象。为了使用 CSS 裁剪而不是div屏蔽,您实际上不必更改任何值;它们只是用来设置不同的样式属性。div我现在使用clip属性创建剪贴蒙版,而不是使用 创建矩形蒙版。

图像现在需要相对于屏幕而不是相对于包含的位置定位div,因此我将简单地将以前的div位置添加到图像的位置。然后使用的位置和尺寸div来定义裁剪矩形。

在下面的代码中,您还可以看到oldStyles在接触实际元素样式之前如何根据这些值检查新值。

function castSingleRay(rayAngle, stripIdx) {if (dist) {var styleHeight;
		if (useSingleTexture) {
			// Then adjust the top placement according
			// to which wall texture we need
			imgTop = Math.floor(height * (wallType-1));
			var styleHeight = Math.floor(height * numTextures);
		} else {
			var styleSrc = wallTextures[wallType-1];
			if (strip.oldStyles.src != styleSrc) {
				strip.src = styleSrc;
				strip.oldStyles.src = styleSrc
			}
			var styleHeight = height;
		}
		if (strip.oldStyles.height != styleHeight) {
			strip.style.height = styleHeight + 'px';
			strip.oldStyles.height = styleHeight
		}

		var texX = Math.round(textureX*width);
		if (texX > width - stripWidth) {
			texX = width - stripWidth;
		}
		var styleWidth = Math.floor(width*2);
		if (strip.oldStyles.width != styleWidth) {
			strip.style.width = styleWidth +'px';
			strip.oldStyles.width = styleWidth;
		}

		var styleTop = top - imgTop;
		if (strip.oldStyles.top != styleTop) {
			strip.style.top = styleTop + 'px';
			strip.oldStyles.top = styleTop;
		}

		var styleLeft = stripIdx*stripWidth - texX;
		if (strip.oldStyles.left != styleLeft) {
			strip.style.left = styleLeft + 'px';
			strip.oldStyles.left = styleLeft;
		}

		var styleClip = 'rect(' + imgTop + ', ' +
				(texX + stripWidth) + ', ' +
				(imgTop + height) + ', ' +
				texX + ')';
		if (strip.oldStyles.clip != styleClip) {
			strip.style.clip = styleClip;
			strip.oldStyles.clip = styleClip;
		}}}

碰撞检测

现在让我们看一下碰撞检测。在第一篇文章中,我通过简单地阻止玩家撞到墙上来解决这个问题。虽然这确实确保您不能穿墙,但感觉不是很优雅。首先,最好在玩家和墙壁之间保持一点距离,否则你可以移动得太近以至于纹理过度拉伸,这看起来不太好。其次,我们应该能够沿着墙壁滑动,而不是每次你几乎碰到墙壁就死定了。

为了解决距离问题,我们必须考虑一些事情,而不是简单地根据地图检查玩家位置。一种解决方案是将玩家视为一个圆圈,将墙壁视为线段。通过确保圆不与任何线段相交,玩家将始终保持至少该圆半径的距离。

幸运的是,地图仅限于简单的基于网格的布局,因此我们的计算可以保持非常简单。具体来说,我只需要确保玩家与每个周围墙壁上最近点之间的距离等于或大于半径,并且由于墙壁在网格上对齐而都是水平或垂直的,因此距离计算变得微不足道。

所以,我将用isBlocking新函数替换旧checkCollision函数。此函数不返回指示玩家是否可以移动到所需位置的true或值,而是返回新的调整位置。函数内部仍然使用该函数来检查某个瓦片是否是实心的。falseisBlockingcheckCollision

function checkCollision(fromX, fromY, toX, toY, radius) {
	var pos = {
		x : fromX,
		y : fromY
	};

	if (toY < 0 || toY >= mapHeight || toX < 0 || toX >= mapWidth) {
		return pos;
	}
	var blockX = Math.floor(toX);
	var blockY = Math.floor(toY);

	if (isBlocking(blockX,blockY)) {
		return pos;
	}

	pos.x = toX;
	pos.y = toY;

	var blockTop = isBlocking(blockX,blockY-1);
	var blockBottom = isBlocking(blockX,blockY+1);
	var blockLeft = isBlocking(blockX-1,blockY);
	var blockRight = isBlocking(blockX+1,blockY);

	if (blockTop != 0 && toY - blockY < radius) {
		toY = pos.y = blockY + radius;
	}// Do the same for right, left and bottom tiles
	// is tile to the top-left a wall

	if (isBlocking(blockX-1,blockY-1) != 0 && !(blockTop != 0 && blockLeft != 0)) {
		var dx = toX - blockX;
		var dy = toY - blockY;
		if (dx*dx+dy*dy < radius*radius) {
			if (dx*dx > dy*dy) {
				toX = pos.x = blockX + radius;
			} else {
				toY = pos.y = blockY + radius;
			}
		}
	}
	// Do the same for top-right,
	// bottom-left and bottom right tilesreturn pos;
}

玩家现在可以顺畅地沿着墙壁滑动,并且与墙壁之间保持最小距离,即使在靠近墙壁时也能保持合理的性能和视觉质量。

精灵

有了这个,让我们转向向世界添加一些细节。到目前为止,它只是开放空间和墙壁,所以是时候完成一些室内装饰了。我将使用如下所示的精灵图像:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
首先,我将定义可用的项目类型。这可以通过包含两条信息的简单对象数组来完成,图像的路径和定义此项目类型是否阻止玩家通过它的布尔值。

var itemTypes = [
	{ img : 'sprites/table-chairs.png', block : true },	// 0
	{ img : 'sprites/armor.png', block : true },		// 1
	{ img : 'sprites/plant-green.png', block : true },	// 2
	{ img : 'sprites/lamp.png', block : false }			// 3
];

然后我会在地图周围放置其中一些。同样,数据结构是一组简单对象。

var mapItems = [
	// Lamps in center area
	{type:3, x:10, y:7},
	{type:3, x:15, y:7},
	// Lamps in bottom corridor
	{type:3, x:5, y:22},
	{type:3, x:12, y:22},
	{type:3, x:19, y:22},
	// Tables in long bottom room
	{type:0, x:10, y:18},
	{type:0, x:15, y:18},
	// Lamps in long bottom room
	{type:3, x:8, y:18},
	{type:3, x:17, y:18}
];

我在城堡周围添加了几盏灯,并在地图底部设置了一个餐厅。在文章开头链接的 zip 文件中,如果您愿意,您还可以找到一种植物的精灵图和一套供您玩耍的盔甲。

现在,我将创建一个要从函数initSprites中调用的init函数以及initScreen其他初始化代码。该函数创建一个对应于地图的二维数组,并用mapItems数组中上面定义的精灵对象填充它。精灵对象也被赋予了一些额外的属性:它的img元素、一个visible标志和前面提到的阻塞信息。

var spriteMap;
function initSprites() {
	spriteMap = [];
	for (var y=0;y<map.length;y++) {
		spriteMap[y] = [];
	}

	var screen = $('screen');
	for (var i=0; i < mapItems.length; i++) {
		var sprite = mapItems[i];
		var itemType = itemTypes[sprite.type];
		var img = dc('img');
		img.src = itemType.img;
		img.style.display = 'none';
		img.style.position = 'absolute';
		sprite.visible = false;
		sprite.block = itemType.block;
		sprite.img = img;
		spriteMap[sprite.y][sprite.x] = sprite;
		screen.appendChild(img);
	}
}

所以现在我可以spriteMap[y][x]在地图上的任何地方进行简单的查找,并检查该图块中是否有精灵。正如您在上面的代码中看到的,我已将所有img元素添加为屏幕元素的子元素。现在的诀窍是确定哪些是可见的,以及它们应该在屏幕上的什么位置。为此,我将利用光线投射功能castSingleRay:

var visibleSprites = [];
function castSingleRay(rayAngle, stripIdx) {while (x >= 0 && x < mapWidth && y >= 0 && y < mapHeight) {
		var wallX = Math.floor(x + (right ? 0 : -1));
		var wallY = Math.floor(y);
		// New sprite checking code
		if (spriteMap[wallY][wallX] && !spriteMap[wallY][wallX].visible) {
			spriteMap[wallY][wallX].visible = true;
			visibleSprites.push(spriteMap[wallY][wallX]);
		}
		…
	…
}

您可能还记得,对于屏幕上的每个垂直条带,此函数每帧调用一次。当光线投射时,它会逐步向外移动,以确保它接触到光线穿过的所有瓷砖,因此我可以在每一步简单地检查 sprite 贴图并检查那里是否有 sprite。如果存在,则切换精灵的可见性(如果我们还没有这样做)并将其添加到visibleSprites数组中。这当然是针对水平和垂直运行完成的。

现在,renderCycle我将添加两个新调用,一个用于清除可见精灵列表,一个用于渲染新标记的可见精灵。前者在光线投射之前完成,后者在光线投射之后完成。

function renderCycle() {clearSprites();
	castRays();
	renderSprites();}

该clearSprites功能非常简单。

function clearSprites() {
	// Clear the visible sprites array but keep
	// a copy in oldVisibleSprites for later.
	// Also mark all the sprites as not visible
	// so they can be added to visibleSprites
	// again during raycasting.
	oldVisibleSprites = [];
	for (var i=0;i<visibleSprites.length;i++) {
		var sprite = visibleSprites[i];
		oldVisibleSprites[i] = sprite;
		sprite.visible = false;
	}
	visibleSprites = [];
}

现在,最后,我将把注意力转向精灵的实际渲染。我将遍历在光线投射期间找到的所有精灵,即现在在visibleSprites数组中的精灵。对于每个可见的精灵,我首先将它的位置转换到观察者空间中,这样我就有了它相对于玩家所在位置的位置。请注意,将 0.5 添加到 x 和 y 坐标以获得图块的中心。简单地使用 sprite 的 x 和 y 就可以得到地图块的左上角。知道玩家当前的旋转角度,剩下的就是用简单的三角函数计算。

function renderSprites() {
	for (var i=0;i<visibleSprites.length;i++) {
		var sprite = visibleSprites[i];
		var img = sprite.img;
		img.style.display = 'block';

		// Translate position to viewer space
		var dx = sprite.x + 0.5 - player.x;
		var dy = sprite.y + 0.5 - player.y;

		// Distance to sprite
		var dist = Math.sqrt(dx*dx + dy*dy);

		// Sprite angle relative to viewing angle
		var spriteAngle = Math.atan2(dy, dx) - player.rot;

		// Size of the sprite
		var size = viewDist / (Math.cos(spriteAngle) * dist);

		// X-position on screen
		var x = Math.tan(spriteAngle) * viewDist;
		img.style.left = (screenWidth/2 + x - size/2) + 'px';

		// Y is constant since we keep all sprites
		// at the same height and vertical position
		img.style.top = ((screenHeight-size)/2) + 'px';
		var dbx = sprite.x - player.x;
		var dby = sprite.y - player.y;
		img.style.width = size + 'px';
		img.style.height = size + 'px';
		var blockDist = dbx*dbx + dby*dby;
		img.style.zIndex = -Math.floor(blockDist*1000);
	}

	// Hide the sprites that are no longer visible
	for (var i=0; i < oldVisibleSprites.length; i++) {
		var sprite = oldVisibleSprites[i];
		if (visibleSprites.indexOf(sprite) < 0) {
			sprite.visible = false;
			sprite.img.style.display = 'none';
		}
	}
}

oldStyles可选地,也可以为 sprite 实现类似于在对象光线投射中使用的方法,可能会获得一些额外的性能。无论如何,现在精灵已正确放置在屏幕上,并且只显示玩家视野中的精灵。然而,如图 2 所示,事情有点混乱,因为我还没有处理屏幕上元素的 z 顺序。

图 2:具有 z-index 问题的精灵
如果我们实际上是逐个像素地绘制墙壁和精灵,我们将必须根据距离的远近对这些对象进行排序,并首先绘制最远的对象,以防止应该被遮挡的对象在较近的对象前面渲染。幸运的是,我们处理的是 HTML 元素,情况要简单得多。这意味着我们有一个强大的工具来解决这个问题,即 CSSzIndex属性。我可以简单地将zIndex属性设置为与到相关精灵或墙条的距离成比例的值。然后浏览器会处理剩下的事情,让我们不必做任何排序。

function renderSprites() {
	for (var i=0; i < visibleSprites.length; i++) {var blockDist = dbx*dbx + dby*dby;
		img.style.zIndex = -Math.floor(blockDist*1000);
	}
}

function castSingleRay(rayAngle, stripIdx) {if (dist) {var wallDist = dwx*dwx + dwy*dwy;
		strip.style.zIndex = -Math.floor(wallDist*1000);
	}
}

现在精灵和墙以正确的顺序分层,如图 3 所示。由于高zIndex意味着 DOM 元素将显示在较低索引元素的顶部,我们使用距离的负值。由于距离在数值上相当小,我们还乘以 1000(或其他一些高数)以获得足够不同的整数值。

图 3:z-index 和谐的精灵和墙壁
最后,该isBlocking函数被修改为也将阻塞精灵考虑在内,确保您无法遍历表格。

function isBlocking(x,y) {if (spriteMap[iy][ix] && spriteMap[iy][ix].block) {
		return true;
	}
	return false;
}

敌人

到目前为止我们的小城堡还算安全,要不我们来点刺激的?为此,我需要做的第一件事是添加第二种精灵,一种能够像玩家一样在关卡中移动的精灵。图 4 显示了我将使用的敌人精灵图像(这是一组 CSS 精灵 — 都是一个图像):

图 4:具有 13 个状态的守卫精灵
我将以与静态精灵相同的方式定义敌人类型和敌人在地图上的位置。每种敌人类型(到目前为止只有一种守卫类型)都有一些属性,例如移动速度、旋转速度和“状态”总数。这些状态对应于上面精灵集中的每个图像——因此处于状态 0 的敌人静止不动,而处于状态 10 的敌人则死在地板上。在这篇文章中,我将只使用前 5 个状态让守卫在地图上追逐我们。我会把战斗留到另一天。

var enemyTypes = [
	{
		img : 'guard.png',
		moveSpeed : 0.05,
		rotSpeed : 3,
		totalStates : 13
	}
];

var mapEnemies = [
	{type : 0, x : 17.5, y : 4.5},
	{type : 0, x : 25.5, y : 16.5}
];

接下来我需要一个initEnemies函数,它将init与其他函数一起被调用。这个函数的工作方式有点像initSprites我刚刚创建的函数,但它在很多方面也有所不同。虽然静态精灵都可以绑定到地图上的特定图块,但敌人当然可以自由地去任何他们想去的地方,所以我们不能使用相同的二维地图结构来存储他们的位置。相反,我将采取简单的方法,将所有敌人简单地保留在一个数组中,即使这确实意味着我必须在每一帧上遍历该数组以确定要渲染的敌人。因为我不会和很多敌人打交道(至少现在),所以现在这应该不是什么大问题。

var enemies = [];
function initEnemies() {
	var screen = $('screen');
	for (var i=0; i < mapEnemies.length; i++) {
		var enemy = mapEnemies[i];
		var type = enemyTypes[enemy.type];
		var img = dc('img');
		img.src = type.img;
		img.style.display = 'none';
		img.style.position = 'absolute';

		enemy.state = 0;
		enemy.rot = 0;
		enemy.dir = 0;
		enemy.speed = 0;
		enemy.moveSpeed = type.moveSpeed;
		enemy.rotSpeed = type.rotSpeed;
		enemy.totalStates = type.totalStates;
		enemy.oldStyles = {
			left : 0,
			top : 0,
			width : 0,
			height : 0,
			clip : '',
			display : 'none',
			zIndex : 0
		};
		enemy.img = img;
		enemies.push(enemy);
		screen.appendChild(img);
	}
}

就像我为精灵所做的一样,我将为img每个敌人创建一个元素并向敌人对象添加一些额外信息。接下来我需要做的是创建一个renderEnemies函数,调用 from renderCycle。这里的基本思想是遍历敌人并通过查看他们之间的相对角度和我们正在看的方向来确定他们是否在我们面前(我们实际上应该为此使用视野). 如果是,代码将以与渲染精灵大致相同的方式渲染他。如果他们不在我们面前,我们的代码会简单地隐藏精灵图像。有关详细信息,请参阅下面的代码注释。

function renderEnemies() {
	for (var i=0;i<enemies.length;i++) {
		var enemy = enemies[i];
		var img = enemy.img;
		var dx = enemy.x - player.x;
		var dy = enemy.y - player.y;
		// Angle relative to player direction
		var angle = Math.atan2(dy, dx) - player.rot;
		// Make angle from +/- PI
		if (angle < -Math.PI) angle += 2*Math.PI;
		if (angle >= Math.PI) angle -= 2*Math.PI;
		// Is enemy in front of player?
		if (angle > -Math.PI*0.5 && angle < Math.PI*0.5) {
			var distSquared = dx*dx + dy*dy;
			var dist = Math.sqrt(distSquared);
			var size = viewDist / (Math.cos(angle) * dist);
			var x = Math.tan(angle) * viewDist;
			var style = img.style;
			var oldStyles = enemy.oldStyles;

			// Height is equal to the sprite size
			if (size != oldStyles.height) {
				style.height = size + 'px';
				oldStyles.height = size;
			}
			// Width is equal to the sprite size
			// times the total number of states
			var styleWidth = size * enemy.totalStates;
			if (styleWidth != oldStyles.width) {
				style.width = styleWidth + 'px';
				oldStyles.width = styleWidth;
			}

			// Top position is halfway down the screen,
			// minus half the sprite height
			var styleTop = ((screenHeight-size)/2);
			if (styleTop != oldStyles.top) {
				style.top = styleTop + 'px';
				oldStyles.top = styleTop;
			}

			// Place at x position, adjusted for sprite
			// size and the current sprite state
			var styleLeft = (screenWidth/2 + x - size/2 - size*enemy.state);
			if (styleLeft != oldStyles.left) {
				style.left = styleLeft + 'px';
				oldStyles.left = styleLeft;
			}

			var styleZIndex = -(distSquared*1000)>>0;
			if (styleZIndex != oldStyles.zIndex) {
				style.zIndex = styleZIndex;
				oldStyles.zIndex = styleZIndex;
			}

			var styleDisplay = 'block';
			if (styleDisplay != oldStyles.display) {
				style.display = styleDisplay;
				oldStyles.display = styleDisplay;
			}

			var styleClip = 'rect(0, ' +
				(size*(enemy.state+1)) + ', ' +
				size + ', ' +
				(size*(enemy.state)) + ')';
			if (styleClip != oldStyles.clip) {
				style.clip = styleClip;
				oldStyles.clip = styleClip;
			}
		} else {
			var styleDisplay = 'none';
			if (styleDisplay != enemy.oldStyles.display) {
				img.style.display = styleDisplay;
				enemy.oldStyles.display = styleDisplay;
			}
		}
	}
}

如您所见,该oldStyles对象再次用于确保style仅在值实际更改时才设置属性。屏幕上的 x 位置被确定为静态精灵,只是现在我正在考虑精灵的当前状态。例如,如果当前状态为 3(步行周期的一部分),则精灵图像位于3 * sprite_size左侧。然后 CSS 剪辑矩形确保只有当前状态可见。

因此,这让我们有几个敌人站在周围,怀疑地看着我们,但没有做太多其他事情,如图 5 所示。

图 5:警卫还不想移动
是时候来点 AI 了!好吧,智能可能会拉伸它,但让我们看看我们是否至少不能让它们移动一点。在 中,gameCycle我将添加对ai函数的调用,该函数将负责评估敌人的动作。接下来我将对move函数做一个小的修改。到目前为止,它一直与player对象相关联,所以让我们对其进行更改,使其接受两个参数,timeDelta我之前介绍过的和一个新的entity,它是具有移动它所需的属性的任何对象(即x, y, moveSpeed,rot等)。然后move修改函数以使用此对象而不是player对象,我们的调用gameCycle也相应更改。这意味着我现在可以使用相同的功能来移动其他东西——比如敌人。

function gameCycle() {move(player, timeDelta);
	ai(timeDelta);}

现在为实际ai功能。对于每个敌人,我将计算与玩家的距离,如果它超过某个值(我使用的距离为 4),敌人将追逐玩家。我会通过将敌人的旋转设置为等于他和玩家之间的角度并将他的速度设置为 1 来做到这一点。然后我将调用move我用来移动玩家的相同方法,只是现在使用敌人对象而不是播放器,当然。相同的碰撞规则等将适用,因为它们move不关心我们在移动什么。

function ai(timeDelta) {
	for (var i=0; i < enemies.length; i++) {
		var enemy = enemies[i];
		var dx = player.x - enemy.x;
		var dy = player.y - enemy.y;
		// Distance from enemy to to player
		var dist = Math.sqrt(dx*dx + dy*dy);
		// If distance is more than X, then enemy must chase player
		if (dist > 4) {
			var angle = Math.atan2(dy, dx);
			enemy.rotDeg = angle * 180 / Math.PI;
			enemy.rot = angle;
			enemy.speed = 1;
			var walkCycleTime = 1000;
			var numWalkSprites = 4;
			enemy.state = Math.floor((new Date() % walkCycleTime) / (walkCycleTime / numWalkSprites)) + 1;
		// If not, then stop.
		} else {
			enemy.state = 0;
			enemy.speed = 0;
		}
		move(enemies[i], timeDelta);
	}
}

这也是我在函数中设置state上面使用的属性的地方。renderEnemies如果敌人没有移动,状态就是 0(“静止不动”的图像)。如果敌人在移动,那么我会让它在状态 1 到 4 之间循环。通过在当前时间使用 %(取模)运算符,并将整个步行周期的时间作为除数,我们玩得很开心基于步行周期。

我们终于得到它了!如图 6 所示,守卫现在将追赶玩家,直到他们在一定距离内。不可否认,这还不是最先进的人工智能,但它是一个开始。试着把他们困在角落里会很有趣,无论如何几分钟!

图6:被恶警追杀
下次
感谢阅读 — 希望您到目前为止玩得开心。在下一篇文章中,我可能会研究以下一些主题:

  1. 武器/射击。既然我们有了敌人,我们就需要一种简单、有效的方法来摆脱它们,而没有什么比使用枪支更好的方法了!
  2. 皮卡(黄金、弹药等)。这将与添加玩家统计数据(如得分和健康状况)相关联。
  3. 界面/HUD。一旦我们有了数字,我们就需要在某个地方显示它们。
  4. 听起来。击落敌人时应伴有悦耳的声音。
  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值