Search code examples
javascripthtml5-canvasraycasting

Ray Casting floor with HTML canvas


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.

enter image description here enter image description here

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:

enter image description here

Here is my project in StackBlitz. What I am doing wrong?


Solution

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