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?
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>