使用 HTML 5 Canvas 和 Raycasting 创建伪 3D 游戏
Posted 妇男主任
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了使用 HTML 5 Canvas 和 Raycasting 创建伪 3D 游戏相关的知识,希望对你有一定的参考价值。
使用 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 run
…
if (dist)
drawRay(xHit, yHit);
水平墙的测试与垂直测试几乎相同,因此我不会详细介绍该部分;我只想补充一点,如果在两次运行中都发现了一堵墙,我们会选择距离最短的那一面。在光线投射结束时,我们在小地图上绘制实际光线。这只是暂时的,用于测试目的。在某些浏览器中它需要相当多的 CPU,因此一旦我们开始渲染世界的 3D 视图,我们将删除光线绘制。
纹理
在我们继续之前,让我们先看看我们将使用的纹理。由于我之前的项目深受德军总部 3D 的启发,我们将坚持这一点,并使用该游戏中的一小部分墙壁纹理。每个墙壁纹理都是 64x64 像素,并且使用地图数组中的墙壁类型索引,很容易找到特定地图块的正确纹理,即如果地图块具有墙壁类型 2,这意味着我们应该查看垂直方向从 64px 到 128px 的图像。稍后当我们开始拉伸纹理以模拟距离和高度时,这会变得稍微复杂一些,但原理保持不变。正如您在图 4 中看到的,每种纹理都有两个版本,一个普通版本和一个稍暗的版本。
(未完待续)
以上是关于使用 HTML 5 Canvas 和 Raycasting 创建伪 3D 游戏的主要内容,如果未能解决你的问题,请参考以下文章
HTML 5 Gaming 是不是使用 Canvas 和 Javascript?在这种情况下如何防止作弊?
使用 HTML 5 Canvas 和 Raycasting 创建伪 3D 游戏
HTML 5 Canvas 和 Javascript:结合分层画布