I am coding a small Javascript/ HTML-canvas
Wolfenstein style game. I am following Permadi tutorial.
For now I did suceed to implement the textured wall raycasting. What I want to do now is to do the floor raycasting.
As far as I understand, when I finish to draw a slice of wall, I have to check if it reaches the bottom of the canvas. If not, that means there is a floor to be rendered underneath it. So I grab every pixel from the bottom of the wall to the bottom of the canvas, calculate their coordinates in “real-world”, grab their texture and draw them on the screen.
I am using these two schemas for my calculations.
These is my code:
//we check if the wall reaches the bottom of the canvas // this.wallToBorder = (400 - wallHeight) / 2; if (this.wallToBorder > 0) { // we calculate how many pixels we have from bottom of wall to border of canvas var pixelsToBottom = Math.floor(this.wallToBorder); //we calculate the distance between the first pixel at the bottom of the wall and the player eyes (canvas.height / 2) var pixelRowHeight = 200 - pixelsToBottom; // then we loop through every pixels until we reach the border of the canvas for (let i = pixelRowHeight; i < 200; i++) { // we calculate the straight distance between the player and the pixel var directDistFloor = this.screenDist * (canvas.height/2) / Math.floor(i); // we calculate it's real world distance with the angle relative to the player var realDistance = directDistFloor / Math.cos(this.angleR); // we calculate it's real world coordinates with the player angle this.floorPointx = this.player.x + Math.cos(this.angle) * realDistance; this.floorPointy = this.player.y - Math.sin(this.angle) * realDistance; // we map the texture var textY = Math.floor(this.floorPointx % 64); var textX = Math.floor(this.floorPointy % 64); var pixWidthHeight = (1 / realDistance) * this.screenDist; if (pixWidthHeight < 1) pixWidthHeight = 1; // we draw it on the canvas this.ctx.drawImage(wallsSprite, textX, textY + 64, 1, 1, this.index, i + 200, pixWidthHeight, pixWidthHeight); } }
But The result is not I am expecting:
Here is my project in StackBlitz. What I am doing wrong?
Advertisement
Answer
There are two small mistakes in your code.
var directDistFloor = this.screenDist * (canvas.height/2) / Math.floor(i);
This should calculate the distance between the player and a floor tile. If we look up the actual value of this.screenDist
we can see:
this.screenDist = (canvas.width / 2) / Math.tan((30 * Math.PI) / 180);
So there is no relation to a floor tile. screenDist
should mirror the ‘height’ of a floor tile – 64 pixels in your case – which is determined as property mapS
of the Map
class.
Change the above line to this:
var directDistFloor = ((this.map.mapS / Math.tan((30 * Math.PI) / 180)) * (canvas.height/2)) / i;
The second bug is lurking here:
this.floorPointy = this.player.y - Math.sin(this.angle) * realDistance;
as you need to add the sine to the player’s vertical position.
Just as a side note: In your floor drawing algorithm, you’re literally abusing the drawImage()
method to draw individual pixels. This is a huge bottleneck. A quick fix would be reading the pixel’s color from your texture map and use fillRect()
instead. There’s still room for improvement though.
Here’s your modified code:
var wallsSprite = new Image(); wallsSprite.crossOrigin = "anonymous"; let tempCanvas = document.createElement("canvas"); let context = tempCanvas.getContext("2d"); let pixelData; wallsSprite.onload = function(e) { context.drawImage(e.target, 0, 0, e.target.naturalWidth, e.target.naturalHeight); pixelData = context.getImageData(0, 0, tempCanvas.width, tempCanvas.height).data; animate(); } wallsSprite.src = "https://api.codetabs.com/v1/proxy?quest=https://i.ibb.co/rbJJw2N/walls-2.png"; class Map { constructor(ctx) { this.ctx = ctx; this.mapX = 26; this.mapY = 20; this.mapS = 64; this.grid = [ [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, 1, 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, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 0, 1], [1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 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, 1, 0, 0, 0, 0, 0, 0, 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, 1], [1, 1, 1, 0, 0, 1, 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, 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, 1], [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 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, 1, 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, 1, 0, 0, 0, 0, 1], [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 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], ] } draw() { for (let y = 0; y < this.mapY; y++) { for (let x = 0; x < this.mapX; x++) { var color; this.grid[y][x] != 0 ? color = "black" : color = "white"; var Xo = x * this.mapS / 10; var Yo = y * this.mapS / 10; this.ctx.fillStyle = color; this.ctx.fillRect(Xo + 10, Yo + 10, this.mapS / 10, this.mapS / 10) } } } checkCollision(y, x) { var collision = false; if (this.grid[y][x] != 0) { collision = true; } return collision; } getTile(x, y) { var X = Math.floor(x / this.mapS); var Y = Math.floor(y / this.mapS); return (this.grid[Y][X]); } } class Player { constructor(x, y, map, ctx) { this.color = "yellow"; this.x = x; this.y = y; this.width = 4; this.height = 4; this.map = map; this.ctx = ctx; this.angle = 0; this.speed = 4; this.moveForward = 0; this.rotate = 0; this.rotationSpeed = 3 * (Math.PI / 180); this.isColliding = false; this.FOV = 60; } up() { this.moveForward = 1; } down() { this.moveForward = -1; } right() { this.rotate = 1; } left() { this.rotate = -1; } stopMove() { this.moveForward = 0; } stopTurn() { this.rotate = 0; } checkForCollision(x, y) { var collision = false; var xGridNb = Math.floor(x / this.map.mapS); var yGridNb = Math.floor(y / this.map.mapS); if (this.map.checkCollision(yGridNb, xGridNb)) { collision = true; }; return collision; } update() { var newX = this.x + this.moveForward * Math.cos(this.angle) * this.speed; var newY = this.y + this.moveForward * Math.sin(this.angle) * this.speed; this.angle += this.rotate * this.rotationSpeed; this.angle = normalizeAngle(this.angle); if (!this.checkForCollision(newX, newY)) { this.x = newX; this.y = newY; } } draw() { this.update(); this.ctx.fillStyle = this.color; this.ctx.fillRect(this.x / 10 + 10, this.y / 10 + 10, this.width, this.height); } } class Ray { constructor(player, map, ctx, angleR, i) { this.x; this.y; this.player = player; this.dist = 0; this.map = map; this.ctx = ctx; this.yIntercept; this.xIntercept; this.xStep; this.yStep; this.angleR = angleR; this.isHittingX; this.isHittingY; this.wallHitHX; this.wallHitHY; this.wallHitVX; this.wallHitVY; this.wallHitX; this.wallHitY; this.angle = this.player.angle + this.angleR; this.lookUp; this.lookRight; this.index = i; this.distHit = 0; this.texturePix; this.texture; this.wallBottom; this.playerHeight = canvas.height / 2; this.screenDist; this.floorPointx; this.floorPointy; this.screenDist = (canvas.width / 2) / Math.tan((30 * Math.PI) / 180); } update() { this.angle = this.player.angle + this.angleR; this.angle = normalizeAngle(this.angle) this.angle > Math.PI ? this.lookUp = true : this.lookUp = false; this.angle > Math.PI / 2 && this.angle < (3 * Math.PI) / 2 ? this.lookRight = false : this.lookRight = true; this.x = this.player.x; this.y = this.player.y; } cast() { this.update(); this.xCollision(); this.yCollision(); this.checkTile(); this.wallRendering(); } yCollision() { this.isHittingY = false; this.yIntercept = Math.floor(this.y / this.map.mapS) * this.map.mapS; if (!this.lookUp) this.yIntercept += this.map.mapS; var xOffset = (this.yIntercept - this.y) / Math.tan(this.angle); this.xIntercept = this.x + xOffset; this.xStep = this.map.mapS / Math.tan(this.angle); this.yStep = this.map.mapS; if (this.lookUp) this.yStep *= -1; if ((!this.lookRight && this.xStep > 0) || (this.lookRight && this.xStep < 0)) { this.xStep *= -1; } var nextHorizX = this.xIntercept; var nextHorizY = this.yIntercept; if (this.lookUp) { nextHorizY--; } while (!this.isHittingY) { var xTile = Math.floor(nextHorizX / this.map.mapS); var yTile = Math.floor(nextHorizY / this.map.mapS); if (this.map.checkCollision(yTile, xTile)) { this.isHittingY = true; this.wallHitHX = nextHorizX; this.wallHitHY = nextHorizY; } else { nextHorizX += this.xStep; nextHorizY += this.yStep; } } } xCollision() { this.isHittingX = false; this.xIntercept = Math.floor(this.x / this.map.mapS) * this.map.mapS; if (this.lookRight) this.xIntercept += this.map.mapS; var yOffset = (this.xIntercept - this.x) * Math.tan(this.angle); this.yIntercept = this.y + yOffset; this.xStep = this.map.mapS; this.yStep = this.map.mapS * Math.tan(this.angle); if (!this.lookRight) this.xStep *= -1; if ((this.lookUp && this.yStep > 0) || (!this.lookUp && this.yStep < 0)) { this.yStep *= -1; } var nextHorizX = this.xIntercept; var nextHorizY = this.yIntercept; if (!this.lookRight) { nextHorizX--; } var mapWidth = this.map.mapX * this.map.mapS; var mapHeight = this.map.mapY * this.map.mapS; while (!this.isHittingX && (nextHorizX > 1 && nextHorizY > 1 && nextHorizX < mapWidth - 1 && nextHorizY < mapHeight - 1)) { var xTile = Math.floor(nextHorizX / this.map.mapS); var yTile = Math.floor(nextHorizY / this.map.mapS); if (this.map.checkCollision(yTile, xTile)) { this.isHittingX = true; this.wallHitVX = nextHorizX; this.wallHitVY = nextHorizY; } else { nextHorizX += this.xStep; nextHorizY += this.yStep; } } } checkTile() { var horizDst = 999999; var vertiDst = 999999; var square; if (this.isHittingY) { vertiDst = distance(this.x, this.y, this.wallHitHX, this.wallHitHY); } if (this.isHittingX) { horizDst = distance(this.x, this.y, this.wallHitVX, this.wallHitVY); } if (horizDst < vertiDst) { this.wallHitX = this.wallHitVX; this.wallHitY = this.wallHitVY; this.distHit = horizDst; square = Math.floor(this.wallHitY / this.map.mapS); this.texturePix = this.wallHitY - (square * this.map.mapS); this.texture = this.map.getTile(this.wallHitX, this.wallHitY); } else { this.wallHitX = this.wallHitHX; this.wallHitY = this.wallHitHY; this.distHit = vertiDst; square = Math.floor(this.wallHitX / this.map.mapS) * this.map.mapS; this.texturePix = this.wallHitX - square; this.texture = this.map.getTile(this.wallHitX, this.wallHitY); } this.distHit = this.distHit * Math.cos(this.player.angle - this.angle); } draw() { this.ctx.beginPath(); this.ctx.strokeStyle = "blue"; this.ctx.moveTo(this.x, this.y); this.ctx.lineTo(this.wallHitX, this.wallHitY); this.ctx.stroke(); } wallRendering() { var realWallHeight = 64; var wallHeight = (realWallHeight / this.distHit) * this.screenDist; var y0 = canvas.height / 2 - Math.floor(wallHeight / 2); var y1 = y0 + wallHeight; this.wallToBorder = Math.floor((400 - wallHeight) / 2); var spriteHeight = 64; var screenSpriteHeight = y0 - y1; this.ctx.imageSmoothingEnabled = false; this.ctx.drawImage(wallsSprite, this.texturePix, this.texture * spriteHeight, 1, 63, this.index, y1, 1, screenSpriteHeight); //we check if the wall reaches the bottom of the canvas // this.wallToBorder = (400 - wallHeight) / 2; if (this.wallToBorder > 0) { // we calculate how many pixels we have from bottom of wall to border of canvas var pixelsToBottom = Math.floor(this.wallToBorder); //we calculate the distance between the first pixel at the bottom of the wall and the player eyes (canvas.height / 2) var pixelRowHeight = 200 - pixelsToBottom; let color; // then we loop through every pixels until we reach the border of the canvas for (let i = pixelRowHeight; i < 200; i++) { // we calculate the straight distance between the player and the pixel let temp = this.map.mapS / Math.tan((30 * Math.PI) / 180) var directDistFloor = ((this.map.mapS / Math.tan((30 * Math.PI) / 180)) * (canvas.height / 2)) / i; //var directDistFloor = (this.screenDist * (canvas.height / 2)) / i; //if (this.index === 399 ) console.log(this.screenDist, i, directDistFloor); // we calculate it's real world distance with the angle relative to the player var realDistance = directDistFloor / Math.cos(this.angleR); // we calculate it's real world coordinates with the player angle this.floorPointx = this.player.x + Math.cos(this.angle) * realDistance; this.floorPointy = this.player.y + Math.sin(this.angle) * realDistance; var cellX = Math.floor(this.floorPointx / 64); var cellY = Math.floor(this.floorPointy / 64); if ((cellX < map.mapX) && (cellY < map.mapY) && cellX >= 0 && cellY >= 0) { // we map the texture var textY = Math.floor(this.floorPointx % 64); var textX = Math.floor(this.floorPointy % 64); var pixWidthHeight = (1 / realDistance) * this.map.mapS; if (pixWidthHeight < 1) pixWidthHeight = 1; // we draw it on the canvas // this.ctx.drawImage(wallsSprite, textX, textY + 64, 1, 1, this.index, i + 200, pixWidthHeight, pixWidthHeight); color = ((textY + 64) * tempCanvas.width + textX) * 4; this.ctx.fillStyle = `rgba(${pixelData[color]},${pixelData[color+1]},${pixelData[color+2]},${pixelData[color+3]})`; this.ctx.fillRect(this.index, i + 200, pixWidthHeight, pixWidthHeight); } } } } } class RayCaster { constructor(player, map, ctx) { this.player = player; this.map = map; this.ctx = ctx; this.rayNb = canvas.width; this.rays = []; this.incAngle = toRadians(this.player.FOV / this.rayNb); this.startAngle = toRadians(this.player.angle - this.player.FOV / 2); this.rayAngle = this.startAngle; this.init(); } init() { for (let i = 0; i < this.rayNb; i++) { this.rays[i] = new Ray(this.player, this.map, this.ctx, this.rayAngle, i); this.rayAngle += this.incAngle; } } draw() { for (let i = 0; i < this.rays.length; i++) { this.rays[i].cast(); } this.map.draw(); } } class Controls { constructor(player) { document.addEventListener('keydown', function(e) { switch (e.keyCode) { case 38: player.up(); break; case 40: player.down(); break; case 39: player.right(); break; case 37: player.left(); break; } }); document.addEventListener('keyup', function(e) { switch (e.keyCode) { case 38: case 40: player.stopMove(); break; case 39: case 37: player.stopTurn(); break; } }); } } function normalizeAngle(angle) { angle = angle % (2 * Math.PI) if (angle < 0) { angle = angle + (2 * Math.PI) } return angle; } function distance(x1, y1, x2, y2) { return Math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) } function toRadians(angle) { return angle * (Math.PI / 180); } var canvas = document.getElementById('canvas'); var ctx = canvas.getContext('2d'); canvas.height = 400; canvas.width = 800; var map = new Map(ctx); var player = new Player(400, 65, map, ctx); //var player = new Player(128, 65, map, ctx); var controls = new Controls(player); var rayCaster = new RayCaster(player, map, ctx); Player.prototype.rays = rayCaster.rays; function animate() { ctx.clearRect(0, 0, canvas.width, canvas.height); rayCaster.draw(); player.draw(); requestAnimationFrame(animate); }
#canvas { width: 800px; height: 400px; border: 1px solid black; } body { display: flex; justify-content: center; padding-top: 50px; }
<canvas id="canvas"></canvas>