Raytracing vision cones on a 2D tilemap
gamedevtilemaptutorialvisionray-tracinglightingjavascriptarchivedVision cone on a 2D Tile Map
Raytracing is easy to implement, and adapting it for vision couldn't be simpler. As we're already collecting a series of points touched by the rays, we simply need to test if our target is on one of these points.
In our example, we'll change the radial vision to a cone, which we'll rotate around a central point, like an NPC on guard or maybe a security camera.
We'll be making use of our points on arc circumference method from another article.
This is almost identical to our previous method except for a minor change; instead of calling pointsOnCircumference we call pointsInArc. This means we call the main method, updateRayData with two extra arguments. Our new argument list looks like this; cx, cy (the central point from which our arc eminates), cr (the radius or distance of the arc), and arcStart, arcEnd. The new arcStart and arcEnd arguments are values in radians, that express the beginning and end points of the arc.
We calculate these values from the following: the visionDirection, which is the angle (in radians) the object is facing, and visionRadius, which is the radius the vision arc will cover:
arcStart = visionDirection - (visionRadius / 2); arcEnd = visionDirection + (visionRadius / 2);
Our new vision function looks like this (for a full description check out the previous raycasting tutorial):
function updateRayData(cx, cy, cr, arcStart, arcEnd)
{
var edges = pointsInArc(cx, cy, cr, arcStart, arcEnd);
var points = {};
for(x in edges)
{
var line = pointsOnLine(cx, cy, edges[x][0], edges[x][1]);
for(l in line)
{
points[((line[l][1]*gridW)+line[l][0])] = [line[l][0], line[l][1]];
if(mapData[((line[l][1]*gridW)+line[l][0])]==0) { break; }
}
}
return points;
}
To see if an object is visible, we just check if its position occurs in the returned points list, which we're calling rayData in the global scope:
blueVisible = (typeof rayData[((bluePos[1]*gridW)+bluePos[0])]!='undefined');
Where ((bluePos[1]*gridW)+bluePos[0]) simply converts the blue target x, y position to an index in the mapData array. We just see if this index occurs in our list of rayData points.
Rotating the cone of Vision
We'll also look at how the cone if vision is rotated briefly. We set the global variables delayRotation to the number of milliseconds we want one full rotation to take, and delayUpdate to the number of milliseconds delay between recalculations of the vision cone. We do not update it every frame - limiting calculations that are not needed every frame helps improve code efficiency!
We also keep track of gameTime, the number of elapsed milliseconds since the demo began, and lastFrame - the time the last frame was drawn. The variable now is the current time, in milliseconds, when this frame is being drawn in our game loop.
var now = Date.now();
gameTime+= (now-lastFrame);
lastFrame = now;
We can check and see if the time since we last recalculated the vision cone is longer than the delayUpdate value - if it is, we'll recalculate:
if(delayUpdate<(now-lastUpdate))
{
Our visionDirection is calculated dividing PI2 (the number of radians in a circle) by delayRotation, and multiplying the resulting value by the modulus of the gameTime divided by delayRotation:
visionDirection = ((Math.PI*2) / delayRotation) * (gameTime % delayRotation);
We can now recalculate the vision arc.
rayData = updateRayData(circleX, circleY, radius,
(visionDirection-(visionRadius/2)), (visionDirection+(visionRadius/2)));
lastUpdate = now;
We now check if the target is visible, and update blueVisible accordingly, and we're done!
// Is the blue target visible?
blueVisible = (typeof rayData[((bluePos[1]*gridW)+bluePos[0])]!='undefined');
}
For a more detailed example of how this is all put together, please check the example source code.
Example source code
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript">
var ctx = null;
var tileW = 20, tileH = 20;
var gridW = 30, gridH = 20;
var circleX = 15, circleY = 10, radius = 8;
var mapData = new Array();
var rayData = {};
var visionDirection = 0, visionRadius = (Math.PI/3);
var delayRotation = 4000, delayUpdate = 100;
var bluePos = [3, 3], blueVisible = false;
var currentSecond = 0, frameCount = 0, framesLastSecond = 0;
var gameTime = 0, lastFrame = 0, lastUpdate = 0;
window.onload = function() {
ctx = document.getElementById('game').getContext('2d');
ctx.font = "bold 10pt sans-serif";
// Create our "map" and set random 10 tiles as impassable (0)
for(var i = 0; i < (gridW*gridH); i++) { mapData.push(1); }
for(var i = 0; i < 10; i++)
{
// Choose a random point on the map
var randPos = (Math.floor(Math.random()*20000)%(gridW*gridH));
// Check it's not the centre of the circle
if(randPos==((circleY*gridW)+circleX)) { continue; }
// Set the value of this position to 0
mapData[randPos] = 0;
}
game.addEventListener('mouseup', function(e) {
// Get the position of the mouse click on the page
var mouseX = e.pageX;
var mouseY = e.pageY;
// Find the offset of the Canvas relative to the document top, left,
// and modify the mouse position to account for this
var p = game;
do
{
mouseX-= p.offsetLeft;
mouseY-= p.offsetTop;
p = p.offsetParent;
} while(p!=null);
// fit the real mouse position to our grid
mouseX = Math.floor(mouseX / tileW);
mouseY = Math.floor(mouseY / tileH);
// Set this blocking point to on or off, UNLESS this is the central point...
if(mouseX!=circleX || mouseY!=circleY)
{
bluePos = [mouseX, mouseY];
}
});
requestAnimationFrame(drawGame);
};
function updateRayData(cx, cy, cr, arcStart, arcEnd)
{
var edges = pointsInArc(cx, cy, cr, arcStart, arcEnd);
var points = {};
for(x in edges)
{
var line = pointsOnLine(cx, cy, edges[x][0], edges[x][1]);
for(l in line)
{
points[((line[l][1]*gridW)+line[l][0])] = [line[l][0], line[l][1]];
if(mapData[((line[l][1]*gridW)+line[l][0])]==0) { break; }
}
}
return points;
}
function pointsInArc(cx, cy, cr, angleStart, angleEnd)
{
var list = pointsOnCircumference(cx, cy, cr);
var arc = new Array();
for(i in list)
{
var a = getAngle(cx, cy, list[i][0], list[i][1]);
if(angleStart < 0 && (a >= (Math.PI*2 + angleStart))) { arc.push(list[i]); }
else if(angleEnd > (Math.PI*2) && a <= (angleEnd - (Math.PI*2))) { arc.push(list[i]); }
else if(a>=angleStart && a<=angleEnd) { arc.push(list[i]); }
}
return arc;
}
function pointsOnLine(x1, y1, x2, y2)
{
line = new Array();
var dx = Math.abs(x2 - x1);
var dy = Math.abs(y2 - y1);
var x = x1;
var y = y1;
var n = 1 + dx + dy;
var xInc = (x1 < x2 ? 1 : -1);
var yInc = (y1 < y2 ? 1 : -1);
var error = dx - dy;
dx *= 2;
dy *= 2;
while(n>0)
{
line.push([x, y]);
if(error>0)
{
x+= xInc;
error-= dy;
}
else
{
y+= yInc;
error+= dx;
}
n-= 1;
}
return line;
}
function pointsOnCircumference(cx, cy, cr)
{
var list = new Array();
var x = cr;
var y = 0;
var o2 = Math.floor(1 - x);
while(y <= x)
{
list.push([ x + cx, y + cy]);
list.push([ y + cx, x + cy]);
list.push([-x + cx, y + cy]);
list.push([-y + cx, x + cy]);
list.push([-x + cx, -y + cy]);
list.push([-y + cx, -x + cy]);
list.push([ x + cx, -y + cy]);
list.push([ y + cx, -x + cy]);
y+= 1;
if(o2 <= 0) { o2+= (2 * y) + 1; }
else
{
x-= 1;
o2+= (2 * (y - x)) + 1;
}
}
return list;
}
function getAngle(x, y, x2, y2)
{
var a = Math.atan2(y2 - y, x2 - x);
return a < 0 ? a + (Math.PI * 2) : a;
}
function drawGame()
{
if(ctx==null) { return; }
// Framerate & game time calculations
var sec = Math.floor(Date.now()/1000);
if(sec!=currentSecond)
{
r
currentSecond = sec;
framesLastSecond = frameCount;
frameCount = 1;
}
else { frameCount++; }
var now = Date.now();
gameTime+= (now-lastFrame);
lastFrame = now;
// Update the vision
if(delayUpdate<(now-lastUpdate))
{
visionDirection = ((Math.PI*2) / delayRotation) * (gameTime % delayRotation);
rayData = updateRayData(circleX, circleY, radius,
(visionDirection-(visionRadius/2)), (visionDirection+(visionRadius/2)));
lastUpdate = now;
// Is the blue target visible?
blueVisible = (typeof rayData[((bluePos[1]*gridW)+bluePos[0])]!='undefined');
}
// Clear the Canvas
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, 600, 400);
// Draw rays
ctx.fillStyle = "#dddd00";
for(r in rayData)
{
ctx.fillRect(rayData[r][0]*tileW, rayData[r][1]*tileH, tileW, tileH);
}
// Draw the grid
ctx.fillStyle = "#000000";
ctx.strokeStyle = "#999999";
ctx.beginPath();
for(y = 0; y < gridH; ++y)
{
for(x = 0; x < gridW; ++x)
{
// Draw a blocking point here?
if(mapData[((y*gridW)+x)]==0) { ctx.fillRect((x*tileW), (y*tileH), tileW, tileH); }
// Draw the grid lines
ctx.rect((x*tileW), (y*tileH), tileW, tileH);
}
}
ctx.closePath();
ctx.stroke();
// Draw circle centre
ctx.fillStyle = "#ff0000";
ctx.fillRect(circleX*tileW, circleY*tileH, tileW, tileH);
ctx.fillText("Framerate: " + framesLastSecond, 10, 20);
// Draw the blue square target
ctx.fillStyle = "#0000cc";
ctx.fillRect(bluePos[0]*tileW, bluePos[1]*tileH, tileW, tileH);
ctx.fillText("Blue target visible? " + (blueVisible ? "YES!" : "no"), 10, 40);
// Ask for the next animation frame
requestAnimationFrame(drawGame);
}
</script>
</head>
<body>
<p>Click on a grid square to move the blue target. If it is visible at its current position and depending on the current vision cone direction, you will see the "Blue target visible?" text change. If you do not see a grid and the raycasting example below, please ensure you have Javascript enabled and that your browser supports the Canvas element.</p>
<canvas id="game" width="600" height="400"></canvas>
</body>
</html>