使用html5canvas和raycasting创建伪3d游戏(代码片段)

妇男主任 妇男主任     2023-02-18     206

关键词:

使用 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查看详情  

使用 Raycast 时出错

】使用Raycast时出错【英文标题】:ErroronusingRaycast【发布时间】:2010-12-1123:19:39【问题描述】:我正在尝试使用Physics.Raycast方法,但我收到错误消息:\'UnityEngine.Physics.Raycast(UnityEngine.Vector3,UnityEngine.Vector3,float,int)\'的最佳重载方法... 查看详情

physx中raycast和sweep对block和touch的处理逻辑

零、说明测试代码基于PhysX_3.4一、raycast和sweep的特殊性在场景查询中,raycast/sweep相对于overlap来说有一个重要的特性,就是前两者是有明确方向性的,也就是有一个起点加上一个终点。这个和overlap完全不同,因为overlap是在一个... 查看详情

[小巩u3d]关于raycast对boxcollider和boxcollider2d的碰撞监测规则

以下为密经过亲手测的记录,使用UNITY2017.2.2对BoxCollider的碰撞监测使用,可以探测到一个3d碰撞体Rayray=Camera.main.ScreenPointToRay(Input.mousePosition);RaycastHithit;if(Physics.Raycast(ray,outhit)){Debug.Log(hit.transform.name);}对2d物理碰 查看详情

使用html5canvas绘制圆形或弧线

注意:本文属于《html5Canvas绘制图形入门详解》系列文章中的一部分。如果你是html5初学者,仅仅阅读本文,可能无法较深入的理解canvas,甚至无法顺畅地通读本文。请点击上述链接以了解使用html5canvas绘制图形的完整内容。在htm... 查看详情

带有读取和定义 fn 的 Raycaster 引擎错误

】带有读取和定义fn的Raycaster引擎错误【英文标题】:Raycasterengineerrorw/readanddeffn【发布时间】:2019-03-0105:34:06【问题描述】:程序运行时返回OUTOFDATA到readnwDIMvx1(15),vx2(20),vy1(20),vy2(20)DIMtx1(20),tz1(20),tx2(20),tz2(20),wc(20)ASINTEGERDIMwASINTEGE... 查看详情

三个js raycasting OBJ

】三个jsraycastingOBJ【英文标题】:ThreejsraycastingOBJ【发布时间】:2014-04-0213:55:50【问题描述】:你好!我有一个三个js并尝试在我的项目中使用它。问题是-我需要选择从OBJ文件加载的自定义网格。我创建了简单的光线投射器、简... 查看详情

SwiftUI 和 RealityKit 的预览错误-“ARView”类型的值没有成员“raycast”

...SwiftUI和RealityKit的预览错误-“ARView”类型的值没有成员“raycast”【英文标题】:PreviewerrorwithSwiftUIandRealityKit-Valueoftype\'ARView\'hasnomember\'raycast\'【发布时间】:2020-05-1719:24:18【问题描述】:我在Xcode11.4中预览时遇到了一些问题。我... 查看详情

three.js 2D 鼠标点击到 3d 坐标使用 raycaster

】three.js2D鼠标点击到3d坐标使用raycaster【英文标题】:three.js2Dmouseclickto3dco-ordinatesusingraycaster【发布时间】:2016-04-1723:07:41【问题描述】:我正在尝试在z=0平面上绘制2D对象,现在我想将屏幕鼠标坐标转换为3D世界坐标。我在下面... 查看详情

使用对象来回旋转 raycast2D

】使用对象来回旋转raycast2D【英文标题】:Rotatingraycast2Dbackandforthwithobject【发布时间】:2022-01-0920:54:53【问题描述】:我做了一个圆圈,并在上面附加了一个激光盒。激光将向其上y轴(垂直向上)发射光线投射。我还添加了一... 查看详情

使用threejs raycast的fabricjs点击角问题

】使用threejsraycast的fabricjs点击角问题【英文标题】:Problemwithclickcorneroffabricjsusingthreejsraycast【发布时间】:2022-01-0803:00:15【问题描述】:我正在尝试重新创建此app,它目前正在运行。但是我不能完美的点击文字的角落,它总是... 查看详情

html5canvas绘图基本使用方法

<canvas></canvas>是HTML5中新增的标签,用于绘制图形,实际上,这个标签和其他的标签一样,其特殊之处在于该标签可以获取一个CanvasRenderingContext2D对象,我们可以通过JavaScript脚本来控制该对象进行绘图。<canvas></can... 查看详情

使用 Raycasting 算法进行具有纬度/经度坐标的 Point-In-Polygon 测试

】使用Raycasting算法进行具有纬度/经度坐标的Point-In-Polygon测试【英文标题】:UsingaRaycastingAlgorithmforPoint-In-Polygontestwithlatitude/longitudecoordinates【发布时间】:2015-03-2616:28:52【问题描述】:我已经成功使用DotSpatial.Contains函数来测试... 查看详情

eventsystem事件系统

...理GameObject被认为是选择  2管理输入模块使用  3管理Raycasting(如果需要的话)  4根据需要更新所有输入模块一个输入模块的主要逻辑是如何希望事件系统行为的生活,它们的用途1处理输入  2管理事件状态  3发送事件到... 查看详情

threejs raycasting - 相机和加载的 obj 模型之间的交集

】threejsraycasting-相机和加载的obj模型之间的交集【英文标题】:threejsraycasting-intersectionsbetweencameraandaloadedobjmodel【发布时间】:2015-03-2317:44:00【问题描述】:我正在通过包含我已加载为网格的obj的场景移动相机,并且我想检测相... 查看详情

html5:html5canvas

ylbtech-HTML5:HTML5Canvas 1.返回顶部1、HTML5 Canvas<canvas>标签定义图形,比如图表和其他图像,您必须使用脚本来绘制图形。在画布上(Canvas)画一个红色矩形,渐变矩形,彩色矩形,和一些彩色的文字。 什么是canvas?HTM... 查看详情

html5canvas参考手册

描述HTML5<canvas>标签用于绘制图像(通过脚本,通常是JavaScript)。不过,<canvas>元素本身并没有绘制能力(它仅仅是图形的容器)-您必须使用脚本来完成实际的绘图任务。getContext()方法可返回一个对象,该对象提供了用... 查看详情

html5canvas参考手册

描述HTML5<canvas>标签用于绘制图像(通过脚本,通常是JavaScript)。不过,<canvas>元素本身并没有绘制能力(它仅仅是图形的容器)-您必须使用脚本来完成实际的绘图任务。getContext()方法可返回一个对象,该对象提供了用... 查看详情