HTML5 Canvas camera/viewport - how to actually do it?

I'm sure this was solven 1000 times before: I got a canvas in the size of 960*560 and a room in the size of 5000*3000 of which always only 960*560 should be drawn, depending on where the player is. The player should be always in the middle, but when near to borders - then the best view should be calculated). The player can move entirely free with WASD or the arrow keys. And all objects should move themselves - instead of that i move everything else but the player to create the illusion that the player moves.

I now found those two quesitons:

HTML5 - Creating a viewport for canvas works, but only for this type of game, i can't reproduce the code for mine.

Changing the view "center" of an html5 canvas seems to be more promising and also perfomant, but i only understand it for drawing all other objects correctly relative to the player and not how to scroll the canvas viewport relative to the player, which i want to achieve first of course.

My code (simplified - the game logic is seperately):

var canvas = document.getElementById("game");
canvas.tabIndex = 0;
canvas.focus();
var cc = canvas.getContext("2d");

// Define viewports for scrolling inside the canvas

/* Viewport x position */   view_xview = 0;
/* Viewport y position */   view_yview = 0;
/* Viewport width */        view_wview = 960;
/* Viewport height */       view_hview = 560;
/* Sector width */          room_width = 5000;
/* Sector height */         room_height = 3000;

canvas.width = view_wview;
canvas.height = view_hview;

function draw()
{
    clear();
    requestAnimFrame(draw);

    // World's end and viewport
    if (player.x < 20) player.x = 20;
    if (player.y < 20) player.y = 20;
    if (player.x > room_width-20) player.x = room_width-20;
    if (player.y > room_height-20) player.y = room_height-20;

    if (player.x > view_wview/2) ... ?
    if (player.y > view_hview/2) ... ?
}

The way i am trying to get it working feels totally wrong and i don't even know how i am trying it... Any ideas? What do you think about the context.transform-thing?

I hope you understand my description and that someone has an idea. Kind regards


Solution 1:

LIVE DEMO at jsfiddle.net

This demo illustrates the viewport usage in a real game scenario. Use arrows keys to move the player over the room. The large room is generated on the fly using rectangles and the result is saved into an image.

Notice that the player is always in the middle except when near to borders (as you desire).


Now I'll try to explain the main portions of the code, at least the parts that are more difficult to understand just looking at it.


Using drawImage to draw large images according to viewport position

A variant of the drawImage method has eight new parameters. We can use this method to slice parts of a source image and draw them to the canvas.

drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)

The first parameter image, just as with the other variants, is either a reference to an image object or a reference to a different canvas element. For the other eight parameters it's best to look at the image below. The first four parameters define the location and size of the slice on the source image. The last four parameters define the position and size on the destination canvas.

Canvas drawImage

Font: https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Canvas_tutorial/Using_images

How it works in demo:

We have a large image that represents the room and we want to show on canvas only the part within the viewport. The crop position (sx, sy) is the same position of the camera (xView, yView) and the crop dimensions are the same as the viewport(canvas) so sWidth=canvas.width and sHeight=canvas.height.

We need to take care about the crop dimensions because drawImage draws nothing on canvas if the crop position or crop dimensions based on position are invalid. That's why we need the if sections bellow.

var sx, sy, dx, dy;
var sWidth, sHeight, dWidth, dHeight;

// offset point to crop the image
sx = xView;
sy = yView;

// dimensions of cropped image          
sWidth =  context.canvas.width;
sHeight = context.canvas.height;

// if cropped image is smaller than canvas we need to change the source dimensions
if(image.width - sx < sWidth){
    sWidth = image.width - sx;
}
if(image.height - sy < sHeight){
    sHeight = image.height - sy; 
}

// location on canvas to draw the croped image
dx = 0;
dy = 0;
// match destination with source to not scale the image
dWidth = sWidth;
dHeight = sHeight;          

// draw the cropped image
context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

Drawing game objects related to viewport

When writing a game it's a good practice separate the logic and the rendering for each object in game. So in demo we have update and draw functions. The update method changes object status like position on the "game world", apply physics, animation state, etc. The draw method actually render the object and to render it properly considering the viewport, the object need to know the render context and the viewport properties.

Notice that game objects are updated considering the game world's position. That means the (x,y) position of the object is the position in world. Despite of that, since the viewport is changing, objects need to be rendered properly and the render position will be different than world's position.

The conversion is simple:

object position in world(room): (x, y)
viewport position: (xView, yView)

render position: (x-xView, y-yView)

This works for all kind of coordinates, even the negative ones.


Game Camera

Our game objects have a separated update method. In Demo implementation, the camera is treated as a game object and also have a separated update method.

The camera object holds the left top position of viewport (xView, yView), an object to be followed, a rectangle representing the viewport, a rectangle that represents the game world's boundary and the minimal distance of each border that player could be before camera starts move (xDeadZone, yDeadZone). Also we defined the camera's degrees of freedom (axis). For top view style games, like RPG, the camera is allowed to move in both x(horizontal) and y(vertical) axis.

To keep player in the middle of viewport we set the deadZone of each axis to converge with the center of canvas. Look at the follow function in the code:

camera.follow(player, canvas.width/2, canvas.height/2)

Note: See the UPDATE section below as this will not produce the expected behavior when any dimension of the map (room) is smaller than canvas.


World's limits

Since each object, including camera, have its own update function, its easy to check the game world's boundary. Only remember to put the code that block the movement at the final of the update function.


Demonstration

See the full code and try it yourself. Most parts of the code have comments that guide you through. I'll assume that you know the basics of Javascript and how to work with prototypes (sometimes I use the term "class" for a prototype object just because it have a similar behavior of a Class in languages like Java).

LIVE DEMO

Full code:

<!DOCTYPE HTML>
<html>
<body>
<canvas id="gameCanvas" width=400 height=400 />
<script>
// wrapper for our game "classes", "methods" and "objects"
window.Game = {};

// wrapper for "class" Rectangle
(function() {
  function Rectangle(left, top, width, height) {
    this.left = left || 0;
    this.top = top || 0;
    this.width = width || 0;
    this.height = height || 0;
    this.right = this.left + this.width;
    this.bottom = this.top + this.height;
  }

  Rectangle.prototype.set = function(left, top, /*optional*/ width, /*optional*/ height) {
    this.left = left;
    this.top = top;
    this.width = width || this.width;
    this.height = height || this.height
    this.right = (this.left + this.width);
    this.bottom = (this.top + this.height);
  }

  Rectangle.prototype.within = function(r) {
    return (r.left <= this.left &&
      r.right >= this.right &&
      r.top <= this.top &&
      r.bottom >= this.bottom);
  }

  Rectangle.prototype.overlaps = function(r) {
    return (this.left < r.right &&
      r.left < this.right &&
      this.top < r.bottom &&
      r.top < this.bottom);
  }

  // add "class" Rectangle to our Game object
  Game.Rectangle = Rectangle;
})();

// wrapper for "class" Camera (avoid global objects)
(function() {

  // possibles axis to move the camera
  var AXIS = {
    NONE: 1,
    HORIZONTAL: 2,
    VERTICAL: 3,
    BOTH: 4
  };

  // Camera constructor
  function Camera(xView, yView, viewportWidth, viewportHeight, worldWidth, worldHeight) {
    // position of camera (left-top coordinate)
    this.xView = xView || 0;
    this.yView = yView || 0;

    // distance from followed object to border before camera starts move
    this.xDeadZone = 0; // min distance to horizontal borders
    this.yDeadZone = 0; // min distance to vertical borders

    // viewport dimensions
    this.wView = viewportWidth;
    this.hView = viewportHeight;

    // allow camera to move in vertical and horizontal axis
    this.axis = AXIS.BOTH;

    // object that should be followed
    this.followed = null;

    // rectangle that represents the viewport
    this.viewportRect = new Game.Rectangle(this.xView, this.yView, this.wView, this.hView);

    // rectangle that represents the world's boundary (room's boundary)
    this.worldRect = new Game.Rectangle(0, 0, worldWidth, worldHeight);

  }

  // gameObject needs to have "x" and "y" properties (as world(or room) position)
  Camera.prototype.follow = function(gameObject, xDeadZone, yDeadZone) {
    this.followed = gameObject;
    this.xDeadZone = xDeadZone;
    this.yDeadZone = yDeadZone;
  }

  Camera.prototype.update = function() {
    // keep following the player (or other desired object)
    if (this.followed != null) {
      if (this.axis == AXIS.HORIZONTAL || this.axis == AXIS.BOTH) {
        // moves camera on horizontal axis based on followed object position
        if (this.followed.x - this.xView + this.xDeadZone > this.wView)
          this.xView = this.followed.x - (this.wView - this.xDeadZone);
        else if (this.followed.x - this.xDeadZone < this.xView)
          this.xView = this.followed.x - this.xDeadZone;

      }
      if (this.axis == AXIS.VERTICAL || this.axis == AXIS.BOTH) {
        // moves camera on vertical axis based on followed object position
        if (this.followed.y - this.yView + this.yDeadZone > this.hView)
          this.yView = this.followed.y - (this.hView - this.yDeadZone);
        else if (this.followed.y - this.yDeadZone < this.yView)
          this.yView = this.followed.y - this.yDeadZone;
      }

    }

    // update viewportRect
    this.viewportRect.set(this.xView, this.yView);

    // don't let camera leaves the world's boundary
    if (!this.viewportRect.within(this.worldRect)) {
      if (this.viewportRect.left < this.worldRect.left)
        this.xView = this.worldRect.left;
      if (this.viewportRect.top < this.worldRect.top)
        this.yView = this.worldRect.top;
      if (this.viewportRect.right > this.worldRect.right)
        this.xView = this.worldRect.right - this.wView;
      if (this.viewportRect.bottom > this.worldRect.bottom)
        this.yView = this.worldRect.bottom - this.hView;
    }

  }

  // add "class" Camera to our Game object
  Game.Camera = Camera;

})();

// wrapper for "class" Player
(function() {
  function Player(x, y) {
    // (x, y) = center of object
    // ATTENTION:
    // it represents the player position on the world(room), not the canvas position
    this.x = x;
    this.y = y;

    // move speed in pixels per second
    this.speed = 200;

    // render properties
    this.width = 50;
    this.height = 50;
  }

  Player.prototype.update = function(step, worldWidth, worldHeight) {
    // parameter step is the time between frames ( in seconds )

    // check controls and move the player accordingly
    if (Game.controls.left)
      this.x -= this.speed * step;
    if (Game.controls.up)
      this.y -= this.speed * step;
    if (Game.controls.right)
      this.x += this.speed * step;
    if (Game.controls.down)
      this.y += this.speed * step;

    // don't let player leaves the world's boundary
    if (this.x - this.width / 2 < 0) {
      this.x = this.width / 2;
    }
    if (this.y - this.height / 2 < 0) {
      this.y = this.height / 2;
    }
    if (this.x + this.width / 2 > worldWidth) {
      this.x = worldWidth - this.width / 2;
    }
    if (this.y + this.height / 2 > worldHeight) {
      this.y = worldHeight - this.height / 2;
    }
  }

  Player.prototype.draw = function(context, xView, yView) {
    // draw a simple rectangle shape as our player model
    context.save();
    context.fillStyle = "black";
    // before draw we need to convert player world's position to canvas position            
    context.fillRect((this.x - this.width / 2) - xView, (this.y - this.height / 2) - yView, this.width, this.height);
    context.restore();
  }

  // add "class" Player to our Game object
  Game.Player = Player;

})();

// wrapper for "class" Map
(function() {
  function Map(width, height) {
    // map dimensions
    this.width = width;
    this.height = height;

    // map texture
    this.image = null;
  }

  // creates a prodedural generated map (you can use an image instead)
  Map.prototype.generate = function() {
    var ctx = document.createElement("canvas").getContext("2d");
    ctx.canvas.width = this.width;
    ctx.canvas.height = this.height;

    var rows = ~~(this.width / 44) + 1;
    var columns = ~~(this.height / 44) + 1;

    var color = "red";
    ctx.save();
    ctx.fillStyle = "red";
    for (var x = 0, i = 0; i < rows; x += 44, i++) {
      ctx.beginPath();
      for (var y = 0, j = 0; j < columns; y += 44, j++) {
        ctx.rect(x, y, 40, 40);
      }
      color = (color == "red" ? "blue" : "red");
      ctx.fillStyle = color;
      ctx.fill();
      ctx.closePath();
    }
    ctx.restore();

    // store the generate map as this image texture
    this.image = new Image();
    this.image.src = ctx.canvas.toDataURL("image/png");

    // clear context
    ctx = null;
  }

  // draw the map adjusted to camera
  Map.prototype.draw = function(context, xView, yView) {
    // easiest way: draw the entire map changing only the destination coordinate in canvas
    // canvas will cull the image by itself (no performance gaps -> in hardware accelerated environments, at least)
    /*context.drawImage(this.image, 0, 0, this.image.width, this.image.height, -xView, -yView, this.image.width, this.image.height);*/

    // didactic way ( "s" is for "source" and "d" is for "destination" in the variable names):

    var sx, sy, dx, dy;
    var sWidth, sHeight, dWidth, dHeight;

    // offset point to crop the image
    sx = xView;
    sy = yView;

    // dimensions of cropped image          
    sWidth = context.canvas.width;
    sHeight = context.canvas.height;

    // if cropped image is smaller than canvas we need to change the source dimensions
    if (this.image.width - sx < sWidth) {
      sWidth = this.image.width - sx;
    }
    if (this.image.height - sy < sHeight) {
      sHeight = this.image.height - sy;
    }

    // location on canvas to draw the croped image
    dx = 0;
    dy = 0;
    // match destination with source to not scale the image
    dWidth = sWidth;
    dHeight = sHeight;

    context.drawImage(this.image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
  }

  // add "class" Map to our Game object
  Game.Map = Map;

})();

// Game Script
(function() {
  // prepaire our game canvas
  var canvas = document.getElementById("gameCanvas");
  var context = canvas.getContext("2d");

  // game settings: 
  var FPS = 30;
  var INTERVAL = 1000 / FPS; // milliseconds
  var STEP = INTERVAL / 1000 // seconds

  // setup an object that represents the room
  var room = {
    width: 500,
    height: 300,
    map: new Game.Map(500, 300)
  };

  // generate a large image texture for the room
  room.map.generate();

  // setup player
  var player = new Game.Player(50, 50);

  // Old camera setup. It not works with maps smaller than canvas. Keeping the code deactivated here as reference.
  /* var camera = new Game.Camera(0, 0, canvas.width, canvas.height, room.width, room.height);*/
  /* camera.follow(player, canvas.width / 2, canvas.height / 2); */

  // Set the right viewport size for the camera
  var vWidth = Math.min(room.width, canvas.width);
  var vHeight = Math.min(room.height, canvas.height);

  // Setup the camera
  var camera = new Game.Camera(0, 0, vWidth, vHeight, room.width, room.height);
  camera.follow(player, vWidth / 2, vHeight / 2);

  // Game update function
  var update = function() {
    player.update(STEP, room.width, room.height);
    camera.update();
  }

  // Game draw function
  var draw = function() {
    // clear the entire canvas
    context.clearRect(0, 0, canvas.width, canvas.height);

    // redraw all objects
    room.map.draw(context, camera.xView, camera.yView);
    player.draw(context, camera.xView, camera.yView);
  }

  // Game Loop
  var gameLoop = function() {
    update();
    draw();
  }

  // <-- configure play/pause capabilities:

  // Using setInterval instead of requestAnimationFrame for better cross browser support,
  // but it's easy to change to a requestAnimationFrame polyfill.

  var runningId = -1;

  Game.play = function() {
    if (runningId == -1) {
      runningId = setInterval(function() {
        gameLoop();
      }, INTERVAL);
      console.log("play");
    }
  }

  Game.togglePause = function() {
    if (runningId == -1) {
      Game.play();
    } else {
      clearInterval(runningId);
      runningId = -1;
      console.log("paused");
    }
  }

  // -->

})();

// <-- configure Game controls:

Game.controls = {
  left: false,
  up: false,
  right: false,
  down: false,
};

window.addEventListener("keydown", function(e) {
  switch (e.keyCode) {
    case 37: // left arrow
      Game.controls.left = true;
      break;
    case 38: // up arrow
      Game.controls.up = true;
      break;
    case 39: // right arrow
      Game.controls.right = true;
      break;
    case 40: // down arrow
      Game.controls.down = true;
      break;
  }
}, false);

window.addEventListener("keyup", function(e) {
  switch (e.keyCode) {
    case 37: // left arrow
      Game.controls.left = false;
      break;
    case 38: // up arrow
      Game.controls.up = false;
      break;
    case 39: // right arrow
      Game.controls.right = false;
      break;
    case 40: // down arrow
      Game.controls.down = false;
      break;
    case 80: // key P pauses the game
      Game.togglePause();
      break;
  }
}, false);

// -->

// start the game when page is loaded
window.onload = function() {
  Game.play();
}

</script>
</body>
</html>


UPDATE

If width and/or height of the map (room) is smaller than canvas the previous code will not work properly. To resolve this, in the Game Script make the setup of the camera as followed:

// Set the right viewport size for the camera
var vWidth = Math.min(room.width, canvas.width);
var vHeight = Math.min(room.height, canvas.height);

var camera = new Game.Camera(0, 0, vWidth, vHeight, room.width, room.height);
camera.follow(player, vWidth / 2, vHeight / 2);

You just need to tell the camera constructor that viewport will be the smallest value between map (room) or canvas. And since we want the player centered and bonded to that viewport, the camera.follow function must be update as well.


Feel free to report any errors or to add suggestions.

Solution 2:

The code in the accepted answer is a bit much. Its this simple:

function draw() {
    ctx.setTransform(1,0,0,1,0,0);//reset the transform matrix as it is cumulative
    ctx.clearRect(0, 0, canvas.width, canvas.height);//clear the viewport AFTER the matrix is reset

    //Clamp the camera position to the world bounds while centering the camera around the player                                             
    var camX = clamp(-player.x + canvas.width/2, yourWorld.minX, yourWorld.maxX - canvas.width);
    var camY = clamp(-player.y + canvas.height/2, yourWorld.minY, yourWorld.maxY - canvas.height);

    ctx.translate( camX, camY );    

    //Draw everything
}

And clamp looks like:

function clamp(value, min, max){
    if(value < min) return min;
    else if(value > max) return max;
    return value;
}

Solution 3:

Here’s how to use canvas to be a viewport on another larger-than-canvas image

A viewport is really just a cropped portion of a larger image that is displayed to the user.

In this case, the viewport will be displayed to the user on a canvas (the canvas is the viewport).

First, code a move function that pans the viewport around the larger image.

This function moves the top/left corner of the viewport by 5px in the specified direction:

function move(direction){
    switch (direction){
        case "left":
            left-=5;
            break;
        case "up":
            top-=5;
            break;
        case "right":
            left+=5;
            break;
        case "down":
            top+=5
            break;
    }
    draw(top,left);
}

The move function calls the draw function.

In draw(), the drawImage function will crop a specified portion of a larger image.

drawImage will also display that “cropped background” to the user on the canvas.

context.clearRect(0,0,game.width,game.height);
context.drawImage(background,cropLeft,cropTop,cropWidth,cropHeight,
                     0,0,viewWidth,viewHeight);

In this example,

Background is the full background image (usually not displayed but is rather a source for cropping)

cropLeft & cropTop define where on the background image the cropping will begin.

cropWidth & cropHeight define how large a rectangle will be cropped from the background image.

0,0 say that the sub-image that has been cropped from the background will be drawn at 0,0 on the viewport canvas.

viewWidth & viewHeight are the width and height of the viewport canvas

So here is an example of drawImage using numbers.

Let’s say our viewport (= our display canvas) is 150 pixels wide and 100 pixels high.

context.drawImage(background,75,50,150,100,0,0,150,100);

The 75 & 50 say that cropping will start at position x=75/y=50 on the background image.

The 150,100 say that the rectangle to be cropped will be 150 wide and 100 high.

The 0,0,150,100 say that the cropped rectangle image will be displayed using the full size of the viewport canvas.

That’s it for the mechanics of drawing a viewport…just add key-controls!

Here is code and a Fiddle: http://jsfiddle.net/m1erickson/vXqyc/

<!doctype html>
<html>
<head>
<link rel="stylesheet" type="text/css" media="all" href="css/reset.css" /> <!-- reset css -->
<script type="text/javascript" src="http://code.jquery.com/jquery.min.js"></script>

<style>
    body{ background-color: ivory; }
    canvas{border:1px solid red;}
</style>

<script>
$(function(){

    var canvas=document.getElementById("canvas");
    var ctx=canvas.getContext("2d");
    var game=document.getElementById("game");
    var gameCtx=game.getContext("2d");

    var left=20;
    var top=20;

    var background=new Image();
    background.onload=function(){
        canvas.width=background.width/2;
        canvas.height=background.height/2;
        gameCtx.fillStyle="red";
        gameCtx.strokeStyle="blue";
        gameCtx.lineWidth=3;
        ctx.fillStyle="red";
        ctx.strokeStyle="blue";
        ctx.lineWidth=3;
        move(top,left);
    }
    background.src="https://dl.dropboxusercontent.com/u/139992952/stackoverflow/game.jpg";


    function move(direction){
        switch (direction){
            case "left":
                left-=5;
                break;
            case "up":
                top-=5;
                break;
            case "right":
                left+=5;
                break;
            case "down":
                top+=5
                break;
        }
        draw(top,left);
    }

    function draw(top,left){
        ctx.clearRect(0,0,canvas.width,canvas.height);
        ctx.drawImage(background,0,0,background.width,background.height,0,0,canvas.width,canvas.height);
        gameCtx.clearRect(0,0,game.width,game.height);
        gameCtx.drawImage(background,left,top,250,150,0,0,250,150);
        gameCtx.beginPath();
        gameCtx.arc(125,75,10,0,Math.PI*2,false);
        gameCtx.closePath();
        gameCtx.fill();
        gameCtx.stroke();
        ctx.beginPath();
        ctx.rect(left/2,top/2,125,75);
        ctx.stroke();
        ctx.beginPath();
        ctx.arc(left/2+125/2,top/2+75/2,5,0,Math.PI*2,false);
        ctx.stroke();
        ctx.fill();
    }

    $("#moveLeft").click(function(){move("left");}); 
    $("#moveRight").click(function(){move("right");}); 
    $("#moveUp").click(function(){move("up");}); 
    $("#moveDown").click(function(){move("down");}); 

}); // end $(function(){});
</script>

</head>

<body>
    <canvas id="game" width=250 height=150></canvas><br>
    <canvas id="canvas" width=500 height=300></canvas><br>
    <button id="moveLeft">Left</button>
    <button id="moveRight">Right</button>
    <button id="moveUp">Up</button>
    <button id="moveDown">Down</button>
</body>
</html>

Solution 4:

This is a simple matter of setting the viewport to the target's x and y coordinates, as Colton states, on each frame. Transforms are not necessary but can be used as desired. The basic formula without translation is:

function update() {

  // Assign the viewport to follow a target for this frame
  var viewportX = canvas.width / 2 - target.x;
  var viewportY = canvas.height / 2 - target.y;

  // Draw each entity, including the target, relative to the viewport
  ctx.fillRect(
    entity.x + viewportX, 
    entity.y + viewportY,
    entity.size,
    entity.size
  );
}

Clamping to the map is an optional second step to keep the viewport within world bounds:

function update() {

  // Assign the viewport to follow a target for this frame
  var viewportX = canvas.width / 2 - target.x;
  var viewportY = canvas.height / 2 - target.y;

  // Keep viewport in map bounds
  viewportX = clamp(viewportX, canvas.width - map.width, 0);
  viewportY = clamp(viewportY, canvas.height - map.height, 0);

  // Draw each entity, including the target, relative to the viewport
  ctx.fillRect(
    entity.x + viewportX,
    entity.y + viewportY,
    entity.size,
    entity.size
  );
}

// Restrict n to a range between lo and hi
function clamp(n, lo, hi) {
  return n < lo ? lo : n > hi ? hi : n;
}

Below are a few examples of this in action.

Without viewport translation, clamped:

const clamp = (n, lo, hi) => n < lo ? lo : n > hi ? hi : n;

const Ship = function (x, y, angle, size, color) {
  this.x = x;
  this.y = y;
  this.vx = 0;
  this.vy = 0;
  this.ax = 0;
  this.ay = 0;
  this.rv = 0;
  this.angle = angle;
  this.accelerationAmount = 0.05;
  this.decelerationAmount = 0.02;
  this.friction = 0.9;
  this.rotationSpd = 0.01;
  this.size = size;
  this.radius = size;
  this.color = color;
};
Ship.prototype = {
  accelerate: function () {
    this.ax += this.accelerationAmount;
    this.ay += this.accelerationAmount;
  },
  decelerate: function () {
    this.ax -= this.decelerationAmount;
    this.ay -= this.decelerationAmount;
  },
  rotateLeft: function () {
    this.rv -= this.rotationSpd;
  },
  rotateRight: function () {
    this.rv += this.rotationSpd;
  },
  move: function () {
    this.angle += this.rv;
    this.vx += this.ax;
    this.vy += this.ay;
    this.x += this.vx * Math.cos(this.angle);
    this.y += this.vy * Math.sin(this.angle);
    this.ax *= this.friction;
    this.ay *= this.friction;
    this.vx *= this.friction;
    this.vy *= this.friction;
    this.rv *= this.friction;
  },
  
  draw: function (ctx, viewportX, viewportY) {
    ctx.save();
    ctx.translate(this.x + viewportX, this.y + viewportY);
    ctx.rotate(this.angle);
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(this.size / 1.2, 0);
    ctx.stroke();
    ctx.fillStyle = this.color;
    ctx.fillRect(
      this.size / -2, 
      this.size / -2, 
      this.size, 
      this.size
    );
    ctx.strokeRect(
      this.size / -2, 
      this.size / -2, 
      this.size, 
      this.size
    );
    ctx.restore();
  }
};

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d");
canvas.height = canvas.width = 180;
const map = {
  height: canvas.height * 5, 
  width: canvas.width * 5
};
const ship = new Ship(
  canvas.width / 2, 
  canvas.height / 2, 
  0,
  canvas.width / 10 | 0, 
  "#fff"
);

const keyCodesToActions = {
  38: () => ship.accelerate(),
  37: () => ship.rotateLeft(),
  39: () => ship.rotateRight(),
  40: () => ship.decelerate(),
};
const validKeyCodes = new Set(
  Object.keys(keyCodesToActions).map(e => +e)
);
const keysPressed = new Set();
document.addEventListener("keydown", e => {
  if (validKeyCodes.has(e.keyCode)) {
    e.preventDefault();
    keysPressed.add(e.keyCode);
  }
});
document.addEventListener("keyup", e => {
  if (validKeyCodes.has(e.keyCode)) {
    e.preventDefault();
    keysPressed.delete(e.keyCode);
  }
});

(function update() {
  requestAnimationFrame(update);

  keysPressed.forEach(k => {
    if (k in keyCodesToActions) {
      keyCodesToActions[k]();
    }
  });

  ship.move();

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.save();
  const viewportX = clamp(canvas.width / 2 - ship.x, canvas.width - map.width, 0);
  const viewportY = clamp(canvas.height / 2 - ship.y, canvas.height - map.height, 0);

  /* draw everything offset by viewportX/Y */
  const tileSize = canvas.width / 5;

  for (let x = 0; x < map.width; x += tileSize) {
    for (let y = 0; y < map.height; y += tileSize) {
      const xx = x + viewportX;
      const yy = y + viewportY;

      // simple culling
      if (xx > canvas.width || yy > canvas.height || 
          xx < -tileSize || yy < -tileSize) { 
        continue;
      }

      const light = (~~(x / tileSize + y / tileSize) & 1) * 5 + 70;
      ctx.fillStyle = `hsl(${360 - (x + y) / 10}, 50%, ${light}%)`;
      ctx.fillRect(xx, yy, tileSize + 1, tileSize + 1);
    }
  }
  
  ship.draw(ctx, viewportX, viewportY);
  ctx.restore();
})();
body { 
  margin: 0;
  font-family: monospace;
  display: flex; 
  flex-flow: row nowrap;
  align-items: center; 
}

html, body { 
  height: 100%; 
}

canvas { 
  background: #eee;
  border: 4px solid #222; 
}
div {
  transform: rotate(-90deg);
  background: #222;
  color: #fff;
  padding: 2px;
}
<div>arrow keys to move</div>

With viewport translation, unclamped:

const Ship = function (x, y, angle, size, color) {
  this.x = x;
  this.y = y;
  this.vx = 0;
  this.vy = 0;
  this.ax = 0;
  this.ay = 0;
  this.rv = 0;
  this.angle = angle;
  this.accelerationAmount = 0.05;
  this.decelerationAmount = 0.02;
  this.friction = 0.9;
  this.rotationSpd = 0.01;
  this.size = size;
  this.radius = size;
  this.color = color;
};
Ship.prototype = {
  accelerate: function () {
    this.ax += this.accelerationAmount;
    this.ay += this.accelerationAmount;
  },
  decelerate: function () {
    this.ax -= this.decelerationAmount;
    this.ay -= this.decelerationAmount;
  },
  rotateLeft: function () {
    this.rv -= this.rotationSpd;
  },
  rotateRight: function () {
    this.rv += this.rotationSpd;
  },
  move: function () {
    this.angle += this.rv;
    this.vx += this.ax;
    this.vy += this.ay;
    this.x += this.vx * Math.cos(this.angle);
    this.y += this.vy * Math.sin(this.angle);
    this.ax *= this.friction;
    this.ay *= this.friction;
    this.vx *= this.friction;
    this.vy *= this.friction;
    this.rv *= this.friction;
  },
  
  draw: function (ctx) {
    ctx.save();
    ctx.translate(this.x, this.y);
    ctx.rotate(this.angle);
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(this.size / 1.2, 0);
    ctx.stroke();
    ctx.fillStyle = this.color;
    ctx.fillRect(
      this.size / -2, 
      this.size / -2, 
      this.size, 
      this.size
    );
    ctx.strokeRect(
      this.size / -2, 
      this.size / -2, 
      this.size, 
      this.size
    );
    ctx.restore();
  }
};

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d");
canvas.height = canvas.width = 180;
const map = {
  height: canvas.height * 5, 
  width: canvas.width * 5
};
const ship = new Ship(
  canvas.width / 2, 
  canvas.height / 2, 
  0,
  canvas.width / 10 | 0, 
  "#fff"
);

const keyCodesToActions = {
  38: () => ship.accelerate(),
  37: () => ship.rotateLeft(),
  39: () => ship.rotateRight(),
  40: () => ship.decelerate(),
};
const validKeyCodes = new Set(
  Object.keys(keyCodesToActions).map(e => +e)
);
const keysPressed = new Set();
document.addEventListener("keydown", e => {
  if (validKeyCodes.has(e.keyCode)) {
    e.preventDefault();
    keysPressed.add(e.keyCode);
  }
});
document.addEventListener("keyup", e => {
  if (validKeyCodes.has(e.keyCode)) {
    e.preventDefault();
    keysPressed.delete(e.keyCode);
  }
});

(function update() {
  requestAnimationFrame(update);

  keysPressed.forEach(k => {
    if (k in keyCodesToActions) {
      keyCodesToActions[k]();
    }
  });

  ship.move();
  
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.save();
  ctx.translate(canvas.width / 2 - ship.x, canvas.height / 2 - ship.y);

  /* draw everything as normal */
  const tileSize = canvas.width / 5;

  for (let x = 0; x < map.width; x += tileSize) {
    for (let y = 0; y < map.height; y += tileSize) {

      // simple culling
      if (x > ship.x + canvas.width || y > ship.y + canvas.height || 
          x < ship.x - canvas.width || y < ship.y - canvas.height) { 
        continue;
      }

      const light = ((x / tileSize + y / tileSize) & 1) * 5 + 70;
      ctx.fillStyle = `hsl(${360 - (x + y) / 10}, 50%, ${light}%)`;
      ctx.fillRect(x, y, tileSize + 1, tileSize + 1);
    }
  }
  
  ship.draw(ctx);
  ctx.restore();
})();
body { 
  margin: 0;
  font-family: monospace;
  display: flex; 
  flex-flow: row nowrap;
  align-items: center; 
}

html, body { 
  height: 100%; 
}

canvas { 
  background: #eee;
  border: 4px solid #222; 
}
div {
  transform: rotate(-90deg);
  background: #222;
  color: #fff;
  padding: 2px;
}
<div>arrow keys to move</div>

If you want to keep the target always facing in one direction and rotate the world, make a few adjustments:

ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(target.angle); // adjust to match your world
ctx.translate(-target.x, -target.y);

/* draw everything as normal */

Here's an example of this variant:

const Ship = function (x, y, angle, size, color) {
  this.x = x;
  this.y = y;
  this.vx = 0;
  this.vy = 0;
  this.ax = 0;
  this.ay = 0;
  this.rv = 0;
  this.angle = angle;
  this.accelerationAmount = 0.05;
  this.decelerationAmount = 0.02;
  this.friction = 0.9;
  this.rotationSpd = 0.01;
  this.size = size;
  this.radius = size;
  this.color = color;
};
Ship.prototype = {
  accelerate: function () {
    this.ax += this.accelerationAmount;
    this.ay += this.accelerationAmount;
  },
  decelerate: function () {
    this.ax -= this.decelerationAmount;
    this.ay -= this.decelerationAmount;
  },
  rotateLeft: function () {
    this.rv -= this.rotationSpd;
  },
  rotateRight: function () {
    this.rv += this.rotationSpd;
  },
  move: function () {
    this.angle += this.rv;
    this.vx += this.ax;
    this.vy += this.ay;
    this.x += this.vx * Math.cos(this.angle);
    this.y += this.vy * Math.sin(this.angle);
    this.ax *= this.friction;
    this.ay *= this.friction;
    this.vx *= this.friction;
    this.vy *= this.friction;
    this.rv *= this.friction;
  },
  
  draw: function (ctx) {
    ctx.save();
    ctx.translate(this.x, this.y);
    ctx.rotate(this.angle);
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(this.size / 1.2, 0);
    ctx.stroke();
    ctx.fillStyle = this.color;
    ctx.fillRect(
      this.size / -2, 
      this.size / -2, 
      this.size, 
      this.size
    );
    ctx.strokeRect(
      this.size / -2, 
      this.size / -2, 
      this.size, 
      this.size
    );
    ctx.restore();
  }
};

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d");
canvas.height = canvas.width = 180;
const map = {
  height: canvas.height * 5, 
  width: canvas.width * 5
};
const ship = new Ship(
  canvas.width / 2, 
  canvas.height / 2, 
  0,
  canvas.width / 10 | 0, 
  "#fff"
);

const keyCodesToActions = {
  38: () => ship.accelerate(),
  37: () => ship.rotateLeft(),
  39: () => ship.rotateRight(),
  40: () => ship.decelerate(),
};
const keysPressed = new Set();
document.addEventListener("keydown", e => {
  e.preventDefault();
  keysPressed.add(e.keyCode);
});
document.addEventListener("keyup", e => {
  e.preventDefault();
  keysPressed.delete(e.keyCode);
});

(function update() {
  requestAnimationFrame(update);

  keysPressed.forEach(k => {
    if (k in keyCodesToActions) {
      keyCodesToActions[k]();
    }
  });

  ship.move();

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.save();
  ctx.translate(canvas.width / 2, canvas.height / 1.4);
  //                                              ^^^ optionally offset y a bit
  //                                                  so the player can see better

  ctx.rotate(-90 * Math.PI / 180 - ship.angle);
  ctx.translate(-ship.x, -ship.y);
  
  /* draw everything as normal */
  const tileSize = ~~(canvas.width / 5);

  for (let x = 0; x < map.width; x += tileSize) {
    for (let y = 0; y < map.height; y += tileSize) {

      // simple culling
      if (x > ship.x + canvas.width || y > ship.y + canvas.height || 
          x < ship.x - canvas.width || y < ship.y - canvas.height) { 
        continue;
      }

      const light = ((x / tileSize + y / tileSize) & 1) * 5 + 70;
      ctx.fillStyle = `hsl(${360 - (x + y) / 10}, 50%, ${light}%)`;
      ctx.fillRect(x, y, tileSize + 1, tileSize + 1);
    }
  }

  ship.draw(ctx);
  ctx.restore();
})();
body { 
  margin: 0;
  font-family: monospace;
  display: flex; 
  flex-flow: row nowrap;
  align-items: center; 
}

html, body { 
  height: 100%; 
}

canvas { 
  background: #eee;
  border: 4px solid #222; 
}
div {
  transform: rotate(-90deg);
  background: #222;
  color: #fff;
  padding: 2px;
}
<div>arrow keys to move</div>

See this related answer for an example of the player-perspective viewport with a physics engine.

Solution 5:

The way you're going about it right now seems correct to me. I would change the "20" bounds to a variable though, so you can easily change the bounds of a level or the entire game if you ever require so.

You could abstract this logic into a specific "Viewport" method, that would simply handle the calculations required to determine where your "Camera" needs to be on the map, and then make sure the X and Y coordinates of your character match the center of your camera.

You could also flip that method and determine the location of your camera based on the characters position (e.g.: (position.x - (desired_camera_size.width / 2))) and draw the camera from there on out.

When you have your camera position figured out, you can start worrying about drawing the room itself as the first layer of your canvas.