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

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

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

Here is a simple example of this where we clamp the camera position to the bounds of the game world. This allows the camera to move through the game world and will never display any void space outside of the bounds you specify.
const worldBounds = {minX:-100,maxX:100,minY:-100,maxY:100};
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
// update the player position
movePlayer();
// player is clamped to the world boundaries - don't let the player leave
player.x = clamp(player.x, worldBounds.minX, worldBounds.maxX);
player.y = clamp(player.y, worldBounds.minY, worldBounds.maxY);
// center the camera around the player,
// but clamp the edges of the camera view to the world bounds.
const camX = clamp(player.x - canvas.width/2, worldBounds.minX, worldBounds.maxX - canvas.width);
const camY = clamp(player.y - canvas.height/2, worldBounds.minY, worldBounds.maxY - canvas.height);
ctx.translate(-camX, -camY);
//Draw everything
}
And clamp just ensures that the value given is always between the specified min/max range :
// clamp(10, 20, 30) - output: 20
// clamp(40, 20, 30) - output: 30
// clamp(25, 20, 30) - output: 25
function clamp(value, min, max){
if(value < min) return min;
else if(value > max) return max;
return value;
}
Building on #dKorosec's example - use the arrow keys to move:
Fiddle

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>

#gustavo-carvalho's solution is phenomenal, but it involves extensive calculations and cognitive overhead. #Colton's approach is a step in the right direction; too bad it wasn't elaborated enough in his answer. I took his idea and ran with it to create this CodePen. It achieves exactly what #user2337969 is asking for using context.translate. The beauty is that this doesn't require offsetting any map or player coordinates so drawing them is as easy as using their x and y directly, which is much more straightforward.
Think of the 2D camera as a rectangle that pans inside a larger map. Its top-left corner is at (x, y) coordinates in the map, and its size is that of the canvas, i.e. canvas.width and canvas.height. That means that x can range from 0 to map.width - canvas.width, and y from 0 to map.height - canvas.height (inclusive). These are min and max that we feed into #Colton's clamp method.
To make it work however, I had to flip the sign on x and y since with context.translate, positive values shift the canvas to the right (making an illusion as if the camera pans to the left) and negative - to the left (as if the camera pans to the right).

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.

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.

Save the code below as a .HTM (.html) file and open in your browser.
The result should match this screen shot EXACTLY.
Here is some example code that maps viewports of different sizes onto each other.
Though this implementation uses pixels, you could expand upon this logic to render
tiles. I actually store my tilemaps as .PNG files. Depending on the color of the
pixel, it can represent a different tile type. The code here is designed to sample
from viewports 1,2, or 3 and paste results into viewport 0.
Youtube Video Playlist For The Screenshot and Code Directly Below : REC_MAP
EDIT: REC_MAP.HTM CODE MOVED TO PASTEBIN:
https://pastebin.com/9hWs8Bag
Part #2: BUF_VEW.HTM (Sampling from off screen buffer)
We are going to refactor the code from the previous demo so that
our source viewport samples a bitmap that is off screen. Eventually
we will interpret each pixel color on the bitmap as a unique tile value.
We don't go that far in this code, this is just a refactor to get one
of our viewports off-screen. I recorded the entire process here.
No edits. Entire process including me taking way too long to think
up variable names.
Youtube Video Playlist For The Screenshot and Code Directly Below : BUF_VEW
As before, you can take this source code, save it as a .HTM (.html) file, and run it in your browser.
EDIT: BUF_VEW.HTM CODE MOVED TO PASTEBIN:
https://pastebin.com/zedhD60u
Part #3: UIN_ADA.HTM ( User Input Adapter & Snapping Camera )
We are now going to edit the previous BUF_VEW.HTM file from
part #2 and add 2 new pieces of functionality.
1: User input handling
2: A camera that can zoom in and out and be moved.
This camera will move in increments of it's own viewport
selection area width and height, meaning the motion will
be very "snappy". This camera is designed for level editing,
not really in-game play. We are focusing on a level editor
camera first. The long-term end goal is to make the editor-code
and the in-game-play code the same code. The only difference
should be that when in game-play mode the camera will behave
differently and tile-map editing will be disabled.
Youtube Video Playlist For The Screenshot And Code Directly Below: UIN_ADA
Copy code below, save as: "UIN_ADA.HTM" and run in browser.
Controls: Arrows & "+" "-" for camera zoom-in, zoom-out.
EDIT: UIN_ADA.HTM MOVED TO PASTEBIN:
https://pastebin.com/ntmWihra
Part #4: DAS_BOR.HTM ( DAShed_BOaRders )
We are going to do some calculations to draw a 1 pixel
thin boarder around each tile. The result won't be fancy,
but it will help us verify that we are able to get the
local coordinates of each tile and do something useful with
them. These tile-local coordinates will be necessary for
mapping a bitmap image onto the tile in later installments.
Youtube_Playlist: DAS_BOR.HTM
Source_Code: DAS_BOR.HTM
Part #5: Zoom + Pan over WebGL Canvas fragment shader code:
This is the math required for zooming and panning over a
shader written in GLSL. Rather than taking a sub-sample of off-screen
data, we take a sub-sample of the gl_FragCoord values. The math here
allows for an inset on-screen viewport and a camera that can
zoom and pan over your shader. If you have done a shader tutorial
by "Lewis Lepton" and you would like to zoom and pan over it,
you can filter his input coordinates through this logic and that
should do it.
JavaScript Code
Quick Video Explanation Of Code
Part #6: ICOG.JS : WebGL2 port of DAS_BOR.HTM
To run this you'll need to include the script in an otherwise
empty .HTM file. It replicates the same behavior found in DAS_BOR.HTM,
except all of the rendering is done with GLSL shader code.
There is also the makings of a full game framework in the code as well.
Usage:
1: Press "~" to tell the master editor to read input.
2: Press "2" to enter editor #2 which is the tile editor.
3: WASD to move over 512x512 memory sub sections.
4: Arrow Keys to move camera over by exactly 1 camera.
5: "+" and "-" keys to change the "zoom level" of the camera.
Though this code simply renders each tile value as a gradient square,
it demonstrates the ability to get the correct tile value and internal
coordinates of current tile being draw. Armed with the local coordinates
of a tile in your shader code, you have the ground-work math in place
for mapping images onto these tiles.
Full JavaScript Webgl2 Code
Youtube playlist documenting creation of ICOG.JS
//|StackOverflow Says:
//|Links to pastebin.com must be accompanied by code. Please |//
//|indent all code by 4 spaces using the code toolbar button |//
//|or the CTRL+K keyboard shortcut. For more editing help, |//
//|click the [?] toolbar icon. |//
//| |//
//|StackOverflow Also Says (when I include the code here) |//
//|You are over you 30,000 character limit for posts. |//
function(){ console.log("[FixingStackOverflowComplaint]"); }

Related

js canvas simulate infinite, pan- and zoomable grid

So I have created an html canvas with the illusion of an infinite grid. My goal was to make it as efficient as possible, drawing only what is visible and simulating all the effects by doing the math, instead of for example drawing the grid 3-times as wide and high to make it "infinite". I implemented it by storing the x- and y-offset of the grid. When the mouse moves, the offset is being increased by the moved distance, and then clamped to the cell size. This way, when the moved distance is bigger than the size of one cell, the offset starts again at 0, because only the "overlapping distance" needs to be drawn. This way I can create the illusion of an infinite grid without actually having to worry about world coordinates etc. The snippet below is a working version of this:
let canvas = document.querySelector("canvas");
let ctx = canvas.getContext("2d");
let width = 200;
let height = 200;
let dpi = 4;
let cellSize = 10;
let pressed = false;
canvas.height = height * dpi;
canvas.width = width * dpi;
canvas.style.height = height + "px";
canvas.style.width = width + "px";
canvas.addEventListener("mousedown", (e) => mousedown(e));
canvas.addEventListener("mouseup", (e) => mouseup(e));
canvas.addEventListener("mousemove", (e) => mousemove(e));
let offset = {x: 0, y: 0};
draw();
function draw() {
ctx.save();
ctx.scale(dpi, dpi);
ctx.translate(-0.5, -0.5);
ctx.lineWidth = 1;
ctx.strokeStyle = "silver";
ctx.beginPath();
for (let x = offset.x; x < width; x += cellSize) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
for (let y = offset.y; y < height; y += cellSize) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
ctx.closePath();
ctx.stroke();
ctx.restore();
}
function mousedown(e) {
pressed = true;
}
function mouseup(e) {
pressed = false;
}
function mousemove(e) {
if (!pressed) {
return;
}
ctx.clearRect(0, 0, width * dpi, height * dpi);
offset.x += e.movementX;
offset.y += e.movementY;
let signX = offset.x > 0 ? 1 : -1;
let signY = offset.y > 0 ? 1 : -1;
offset = {
x: (Math.abs(offset.x) > cellSize)
? offset.x - Math.floor((offset.x * signX) / cellSize) * cellSize * signX
: offset.x,
y: (Math.abs(offset.y) > cellSize)
? offset.y - Math.floor((offset.y * signY) / cellSize) * cellSize * signY
: offset.y
};
draw();
}
canvas {
background-color: white;
}
<canvas></canvas>
I now wanted to implement zooming into the illusion. This could be achieved by increasing the cell size according to the zoom level. I also changed the way the grid was drawn: Instead of clamping the offset, and beginning to draw the lines at that offset, the offset is now the center of the grid (thats why its starting position is at w/2 and h/2). I then draw the lines from the offset to the left, top, bottom and right edge. This allows me, when zooming, to simply set the offset to the mouse position, and increase the cell size. This way it looks like it would zoom to the mouse position, because the cell size increases "away" from that point. This works fine and looks pretty nice so far, try it out below:
let canvas = document.querySelector("canvas");
let ctx = canvas.getContext("2d");
let width = 200;
let height = 200;
let dpi = 4;
let cellSize = 10;
let pressed = false;
let zoomIntensity = 0.1;
let zoom = 1;
canvas.height = height * dpi;
canvas.width = width * dpi;
canvas.style.height = height + "px";
canvas.style.width = width + "px";
canvas.addEventListener("mousedown", (e) => mousedown(e));
canvas.addEventListener("mouseup", (e) => mouseup(e));
canvas.addEventListener("mousemove", (e) => mousemove(e));
canvas.addEventListener("wheel", (e) => wheel(e));
let offset = {x: width / 2, y: height / 2};
draw();
function draw() {
ctx.save();
ctx.scale(dpi, dpi);
ctx.translate(-0.5, -0.5);
ctx.lineWidth = 1;
ctx.strokeStyle = "silver";
ctx.beginPath();
for (let x = offset.x; x < width; x += cellSize * zoom) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
for (let x = offset.x; x > 0; x -= cellSize * zoom) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
for (let y = offset.y; y < height; y += cellSize * zoom) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
for (let y = offset.y; y > 0; y -= cellSize * zoom) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
ctx.closePath();
ctx.stroke();
ctx.fillStyle = "red";
ctx.arc(offset.x, offset.y, 4, 0, 2*Math.PI);
ctx.fill();
ctx.restore();
}
function mousedown(e) {
pressed = true;
}
function mouseup(e) {
pressed = false;
}
function mousemove(e) {
if (!pressed) {
return;
}
offset.x += e.movementX;
offset.y += e.movementY;
let signX = offset.x > 0 ? 1 : -1;
let signY = offset.y > 0 ? 1 : -1;
/*offset = {
x: (Math.abs(offset.x) > cellSize)
? offset.x - Math.floor((offset.x * signX) / cellSize) * cellSize * signX
: offset.x,
y: (Math.abs(offset.y) > cellSize)
? offset.y - Math.floor((offset.y * signY) / cellSize) * cellSize * signY
: offset.y
};*/
update();
}
function update() {
ctx.clearRect(0, 0, width * dpi, height * dpi);
draw();
}
function wheel(e) {
offset = {x: e.offsetX, y: e.offsetY};
zoom += ((e.deltaY > 0) ? -1 : 1) * zoomIntensity;
update();
}
canvas {
background-color: white;
}
<p>Scroll with mouse wheel<p>
<canvas></canvas>
However, as you might have noticed, when changing the mouse position while zooming, the grid looks like it jumps to that position. That is logical, because the new center of the grid is exactly at the mouse position - regardless of the distance of the mouse to the nearest cell. This way, when trying to zoom in on the center of a cell, the new grid creates a line exactly at that center, making it look like it jumped. I tried to store the offset of the mouse position to the nearest cell border and drawing everything shifted by that offset, but I couldnt get it to work. I would need to know when a new wheel event is initiated (like on mousedown) and then store the offset to the nearest cells borders, and draw everything shifted by those offsets * zoom, until the zooming ended. I am having trouble implementing something like that, because there are no inbuilt listener functions for the wheel besides wheel, and using something different like keyboard etc. isnt an option here. It is still my goal to make that illusion as efficient as it can be, trying to avoid ctx.scale() and ctx.translate and rather calculate the coords myself.
I was able to solve it on my own after many hours of trying different mathematical approches. At the end of this answer you will find a working snippet that simulates an infinite, pan- and zoomable grid. Because Im doing all the maths myself and draw only whats visible, the illusion of the infinite grid is really fast and efficient. You might notice that the lines are sometimes blurry when zooming in, this could be solved by rounding the numbers at which the lines are drawn to integers, however then you will notice small "jumps" while zooming in, because the lines are slighty shifted. I will now give my best to try to explain process how I achieved the illusion and explain the mathematical procedures.
Quick notes for the snippet:
zoomPoint (red point) is the point around the grid should be zoomed
rx and ry (blue lines) are the offset from the zoomPoint to the right or bottom border of the nearest cell
the top, left, bottom and right variables are the coordinates, at which the borders of the cell around the zoomPoint should be drawn
How does the zooming work?
on zoom, store the current zoomPoint in lastZoomPoint
update zoomPoint to new position of mouse
store current scale in lastScale
update scale by adding scaleStep * amt where amt is -1 or 1, depending on the wheels scrolling direction (deltaY)
calculate rx and ry (the distances from the new zoomPoint to the nearest vertical or horizontal line). it is important to use lastZoomPoint and lastScale here! Here is a piece of my notes to better understand exactly whats going on(needed for the following explanation):
picture
multiply rx and ry with scale, and draw the new grid
So we want to calculate the new rx (and ry, but once we have a formula for rx we can use that to calculate ry).
P2 (the green point) is our lastZoomPoint. We need the new rx, in the picture its called rneu. To get that, we want to subtract the yellow distance from Pneu. But actually, we can make this simpler. We only need a point that we know is perfectly on any of the vertical lines, lets call it Pv. Because rneu is the distance of Pneu to the next vertical line on its left, we need a point Pnearest, exactly on that vertical line. We can get that Point by just moving Pv by the size of one cell times the amount of cells between the two points. Well, we know the size of one cell (cellSize * lastScale), we now need the number of cells in between Pv and Pnearest. We can get that number by calculating the x-distance of these two points and divide it by the size of one cell. Lets use Math.floor to get the number of whole cells in between. Multiply that number by the size of one cell and add it to Pv and voilà, we get Pnearest. Lets write that down mathematically:
let Rneu = Pneu - Pnearest
let Pnearest = Pv + NWholeCells * scaledCellSize
let NWholeCells = Math.floor((Pneu - Pv) / (scaledCellSize))
let scaledCellSize = lastScale * cellSize
Substitute everything in, we get:
let Rneu = Pneu - (Pv + Math.floor((Pneu - Pv) / (lastScale * cellSize)) * (lastScale * cellSize))
So we know Pneu, lastScale and cellSize. The only thing missing is Pv. Remember, this was any Point that lies perfectly on one of the vertical lines. And this is going to be straightforward: Subtract rx (the one from the previous calculation) from P2 and we have ourselves a point that is perfectly on a vertical line!
So:
let Pv = P2 - rx
// substitue that in again
let Rneu = Pneu - ((P2 - rx) + Math.floor((Pneu - (P2 - rx)) / (lastScale * cellSize)) * (lastScale * cellSize));
Nice! Simplify that, and we get:
let Rneu = Pneu - P2 + rx - Math.floor((Pneu - P2 + rx) / (lastScale * cellSize)) * lastScale * cellSize);
In the snippet Pneu - P2 + rx is called dx.
So, now we have the distance from our new zoomPoint to the nearest vertical line. This distance however is obviously scaled by lastScale, so we need to divide rx by lastScale (this will become cleared in further explanation)
Now we have a "cleansed" rx. This "cleansed" value is the distance Pneu to the nearest cell border, if the scale was equal to 1. This is our "initial state". From that (this is now in calculateDrawingPositions()), to make it appear as if we were zooming in on Pneu, we only need to multiple the "cleansed" rx by the new scale (this time not lastScale but scale!). Now we start drawing the vertical lines of our new grid at Pneu - rx * scale, and decrease that x by cellSize * scale, until x reached 0. We now have the vertical lines on the left of our Pneu. Do the same for the horizontal lines above Pneu, and the starting points for the lines on the right or below are easily calculated by adding cellSize * scale to the left or top drawing position.
Puh, done! Thats it! I hope I didnt miss something in my explanation and everything is correct. It took me long to come up with that solution, I hope I explained it in a way it can be easily understood, however I dont know if thats even possible, for me this whole task was pretty complex.
let canvas = document.querySelector("canvas");
let ctx = canvas.getContext("2d");
let width = 200;
let height = 200;
let dpi = 4;
let cellSize = 10;
let backgroundColor = "white";
let lineColor = "silver";
let pressed = false;
let scaleStep = 0.1;
let scale = 1;
let lastScale = scale;
let maxScale = 10;
let minScale = 0.1;
let zoomPoint = {
x: 0,
y: 0
};
let lastZoomPoint = zoomPoint;
let rx = 0,
ry = 0;
let left = 0,
right = 0,
_top = 0,
bottom = 0;
resizeCanvas();
addEventListeners();
calculate();
draw();
function resizeCanvas() {
canvas.height = height * dpi;
canvas.width = width * dpi;
canvas.style.height = height + "px";
canvas.style.width = width + "px";
}
function addEventListeners() {
canvas.addEventListener("mousedown", (e) => mousedown(e));
canvas.addEventListener("mouseup", (e) => mouseup(e));
canvas.addEventListener("mousemove", (e) => mousemove(e));
canvas.addEventListener("wheel", (e) => wheel(e));
}
function calculate() {
calculateDistancesToCellBorders();
calculateDrawingPositions();
}
function calculateDistancesToCellBorders() {
let dx = zoomPoint.x - lastZoomPoint.x + rx * lastScale;
rx = dx - Math.floor(dx / (lastScale * cellSize)) * lastScale * cellSize;
rx /= lastScale;
let dy = zoomPoint.y - lastZoomPoint.y + ry * lastScale;
ry = dy - Math.floor(dy / (lastScale * cellSize)) * lastScale * cellSize;
ry /= lastScale;
}
function calculateDrawingPositions() {
let scaledCellSize = cellSize * scale;
left = zoomPoint.x - rx * scale;
right = left + scaledCellSize;
_top = zoomPoint.y - ry * scale;
bottom = _top + scaledCellSize;
}
function draw() {
ctx.save();
ctx.scale(dpi, dpi);
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, width, height);
ctx.lineWidth = 1;
ctx.strokeStyle = lineColor;
ctx.translate(-0.5, -0.5);
ctx.beginPath();
let scaledCellSize = cellSize * scale;
for (let x = left; x > 0; x -= scaledCellSize) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
for (let x = right; x < width; x += scaledCellSize) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
for (let y = _top; y > 0; y -= scaledCellSize) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
for (let y = bottom; y < height; y += scaledCellSize) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
ctx.stroke();
/* Only for understanding */
ctx.strokeStyle = "blue";
ctx.beginPath();
ctx.moveTo(zoomPoint.x, zoomPoint.y);
ctx.lineTo(left, zoomPoint.y);
ctx.moveTo(zoomPoint.x, zoomPoint.y);
ctx.lineTo(zoomPoint.x, _top);
ctx.stroke();
ctx.fillStyle = "red";
ctx.beginPath();
ctx.arc(zoomPoint.x, zoomPoint.y, 2, 0, 2*Math.PI);
ctx.fill();
/* -----------------------*/
ctx.restore();
}
function update() {
ctx.clearRect(0, 0, width * dpi, height * dpi);
draw();
}
function move(dx, dy) {
zoomPoint.x += dx;
zoomPoint.y += dy;
}
function zoom(amt, point) {
lastScale = scale;
scale += amt * scaleStep;
if (scale < minScale) {
scale = minScale;
}
if (scale > maxScale) {
scale = maxScale;
}
lastZoomPoint = zoomPoint;
zoomPoint = point;
}
function wheel(e) {
zoom(e.deltaY > 0 ? -1 : 1, {
x: e.offsetX,
y: e.offsetY
});
calculate();
update();
}
function mousedown(e) {
pressed = true;
}
function mouseup(e) {
pressed = false;
}
function mousemove(e) {
if (!pressed) {
return;
}
move(e.movementX, e.movementY);
// do not recalculate the distances again, this wil lead to wronrg drawing
calculateDrawingPositions();
update();
}
canvas {
border: 1px solid black;
}
<p>Zoom with wheel, move by dragging</p>
<canvas></canvas>

Javascript pseudo-zoom around mouse position in canvas

I would like to "zoom" (mouse wheel) a grid I have, around the mouse position in canvas (like Desmos does).
By zooming, I mean redrawing the lines inside the canvas to look like a zoom effect, NOT performing an actual zoom.
And I would like to only use vanilla javascript and no libraries (so that I can learn).
At this point, I set up a very basic magnification effect that only multiplies the distance between the gridlines, based on the mouse wheel values.
////////////////////////////////////////////////////////////////////////////////
// User contants
const canvasWidth = 400;
const canvasHeight = 200;
const canvasBackground = '#282c34';
const gridCellColor = "#777";
const gridBlockColor = "#505050";
const axisColor = "white";
// Internal constants
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d', { alpha: false });
const bodyToCanvas = 8;
////////////////////////////////////////////////////////////////////////////////
// User variables
let cellSize = 10;
let cellBlock = 5;
let xSubdivs = 40
let ySubdivs = 20
// Internal variables
let grid = '';
let zoom = 0;
let xAxisOffset = xSubdivs/2;
let yAxisOffset = ySubdivs/2;
let mousePosX = 0;
let mousePosY = 0;
////////////////////////////////////////////////////////////////////////////////
// Classes
class Grid{
constructor() {
this.width = canvasWidth,
this.height = canvasHeight,
this.cellSize = cellSize,
this.cellBlock = cellBlock,
this.xSubdivs = xSubdivs,
this.ySubdivs = ySubdivs
}
draw(){
// Show canvas
context.fillStyle = canvasBackground;
context.fillRect(-this.width/2, -this.height/2, this.width, this.height);
// Horizontal lines
this.xSubdivs = Math.floor(this.height / this.cellSize);
for (let i = 0; i <= this.xSubdivs; i++) {this.setHorizontalLines(i);}
// Vertical lines
this.ySubdivs = Math.floor(this.width / this.cellSize);
for (let i = 0; i <= this.ySubdivs; i++) {this.setVerticalLines(i) ;}
// Axis
this.setAxis();
}
setHorizontalLines(i) {
// Style
context.lineWidth = 0.5;
if (i % this.cellBlock == 0) {
// light lines
context.strokeStyle = gridCellColor;
}
else{
// Dark lines
context.strokeStyle = gridBlockColor;
}
//Draw lines
context.beginPath();
context.moveTo(-this.width/2, (this.cellSize * i) - this.height/2);
context.lineTo( this.width/2, (this.cellSize * i) - this.height/2);
context.stroke();
context.closePath();
}
setVerticalLines(i) {
// Style
context.lineWidth = 0.5;
if (i % cellBlock == 0) {
// Light lines
context.strokeStyle = gridCellColor;
}
else {
// Dark lines
context.strokeStyle = gridBlockColor;
}
//Draw lines
context.beginPath();
context.moveTo((this.cellSize * i) - this.width/2, -this.height/2);
context.lineTo((this.cellSize * i) - this.width/2, this.height/2);
context.stroke();
context.closePath();
}
// Axis are separated from the line loops so that they remain on
// top of them (cosmetic measure)
setAxis(){
// Style x Axis
context.lineWidth = 1.5;
context.strokeStyle = axisColor;
// Draw x Axis
context.beginPath();
context.moveTo(-this.width/2, (this.cellSize * yAxisOffset) - this.height/2);
context.lineTo( this.width/2, (this.cellSize * yAxisOffset) - this.height/2);
context.stroke();
context.closePath();
// Style y axis
context.lineWidth = 1.5;
context.strokeStyle = axisColor;
// Draw y axis
context.beginPath();
context.moveTo((this.cellSize * xAxisOffset ) - this.width/2, -this.height/2);
context.lineTo((this.cellSize * xAxisOffset ) - this.width/2, this.height/2);
context.stroke();
context.closePath();
}
}
////////////////////////////////////////////////////////////////////////////////
// Functions
function init() {
// Set up canvas
if (window.devicePixelRatio > 1) {
canvas.width = canvasWidth * window.devicePixelRatio;
canvas.height = canvasHeight * window.devicePixelRatio;
context.scale(window.devicePixelRatio, window.devicePixelRatio);
}
else {
canvas.width = canvasWidth;
canvas.height = canvasHeight;
}
canvas.style.width = canvasWidth + "px";
canvas.style.height = canvasHeight + "px";
// Initialize coordinates in the middle of the canvas
context.translate(canvasWidth/2,canvasHeight/2)
// Setup the grid
grid = new Grid();
// Display the grid
grid.draw();
}
function setZoom(){
grid.cellSize = grid.cellSize + zoom;
grid.draw();
}
////////////////////////////////////////////////////////////////////////////////
//Launch the page
init();
////////////////////////////////////////////////////////////////////////////////
// Update the page on resize
window.addEventListener("resize", init);
// Zoom the canvas with mouse wheel
canvas.addEventListener('mousewheel', function (e) {
e.preventDefault();
e.stopPropagation();
zoom = e.wheelDelta/120;
requestAnimationFrame(setZoom);
})
// Get mouse coordinates on mouse move.
canvas.addEventListener('mousemove', function (e) {
e.preventDefault();
e.stopPropagation();
mousePosX = parseInt(e.clientX)-bodyToCanvas ;
mousePosY = parseInt(e.clientY)-bodyToCanvas ;
})
////////////////////////////////////////////////////////////////////////////////
html, body
{
background:#21252b;
width:100%;
height:100%;
margin:0px;
padding:0px;
overflow: hidden;
}
span{
color:white;
font-family: arial;
font-size: 12px;
margin:8px;
}
#canvas{
margin:8px;
border: 1px solid white;
}
<span>Use mouse wheel to zoom</span>
<canvas id="canvas"></canvas>
This works fine but, as expected, it magnifies the grid from the top left corner not from the mouse position.
So, I thought about detecting the mouse position and then modifying all the "moveTo" and "lineTo" parts. The goal would be to offset the magnified grid so that everything is displaced except the 2 lines intersecting the current mouse coordinates.
For instance, it feels to me that instead of this:
context.moveTo(
(this.cellSize * i) - this.width/2,
-this.height/2
);
I should have something like this:
context.moveTo(
(this.cellSize * i) - this.width/2 + OFFSET BASED ON MOUSE COORDS,
-this.height/2
);
But after hours of trials and errors, I'm stuck. So, any help would be appreciated.
FYI: I already coded a functional panning system that took me days to achieve (that I stripped from the code here for clarity), so I would like to keep most of the logic I have so far, if possible.
Unless my code is total nonsense or has performance issues, of course.
Thank you.
You're already tracking the mouse position in pixels. If you transform the mouse position to the coordinate system of your grid, you can define which grid cell it's hovering.
After zooming in, you can again calculate the mouse's coordinate. When zooming in around another center point, you'll see the coordinate shift.
You can undo that shift by translating the grid's center in the opposite direction.
Here's the main part of it:
function setZoom() {
// Calculate the mouse position before applying the zoom
// in the coordinate system of the grid
const x1 = (mousePosX - grid.centerX) / grid.cellSize;
const y1 = (mousePosY - grid.centerY) / grid.cellSize;
// Make the zoom happen: update the cellSize
grid.cellSize = grid.cellSize + zoom;
// After zoom, you'll see the coordinates changed
const x2 = (mousePosX - grid.centerX) / grid.cellSize;
const y2 = (mousePosY - grid.centerY) / grid.cellSize;
// Calculate the shift
const dx = x2 - x1;
const dy = y2 - y1;
// "Undo" the shift by shifting the coordinate system's center
grid.centerX += dx * grid.cellSize;
grid.centerY += dy * grid.cellSize;
grid.draw();
}
To make this easier to work with, I introduced a grid.centerX and .centerY. I updated your draw method to take the center in to account (and kind of butchered it along the way, but I think you'll manage to improve that a bit).
Here's the complete example:
////////////////////////////////////////////////////////////////////////////////
// User contants
const canvasWidth = 400;
const canvasHeight = 200;
const canvasBackground = '#282c34';
const gridCellColor = "#777";
const gridBlockColor = "#505050";
const axisColor = "white";
// Internal constants
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d', { alpha: false });
const bodyToCanvas = 8;
////////////////////////////////////////////////////////////////////////////////
// User variables
let cellSize = 10;
let cellBlock = 5;
let xSubdivs = 40
let ySubdivs = 20
// Internal variables
let grid = '';
let zoom = 0;
let xAxisOffset = xSubdivs/2;
let yAxisOffset = ySubdivs/2;
let mousePosX = 0;
let mousePosY = 0;
////////////////////////////////////////////////////////////////////////////////
// Classes
class Grid{
constructor() {
this.width = canvasWidth,
this.height = canvasHeight,
this.cellSize = cellSize,
this.cellBlock = cellBlock,
this.xSubdivs = xSubdivs,
this.ySubdivs = ySubdivs,
this.centerX = canvasWidth / 2,
this.centerY = canvasHeight / 2
}
draw(){
// Show canvas
context.fillStyle = canvasBackground;
context.fillRect(0, 0, this.width, this.height);
// Horizontal lines
const minIY = -Math.ceil(this.centerY / this.cellSize);
const maxIY = Math.ceil((this.height - this.centerY) / this.cellSize);
for (let i = minIY; i <= maxIY; i++) {this.setHorizontalLines(i);}
// Vertical lines
const minIX = -Math.ceil(this.centerX / this.cellSize);
const maxIX = Math.ceil((this.width - this.centerX) / this.cellSize);
for (let i = minIX; i <= maxIX; i++) {this.setVerticalLines(i) ;}
this.setVerticalLines(0);
this.setHorizontalLines(0);
}
setLineStyle(i) {
if (i === 0) {
context.lineWidth = 1.5;
context.strokeStyle = axisColor;
} else if (i % cellBlock == 0) {
// Light lines
context.lineWidth = 0.5;
context.strokeStyle = gridCellColor;
} else {
// Dark lines
context.lineWidth = 0.5;
context.strokeStyle = gridBlockColor;
}
}
setHorizontalLines(i) {
// Style
this.setLineStyle(i);
//Draw lines
const y = this.centerY + this.cellSize * i;
context.beginPath();
context.moveTo(0, y);
context.lineTo(this.width, y);
context.stroke();
context.closePath();
}
setVerticalLines(i) {
// Style
this.setLineStyle(i);
//Draw lines
const x = this.centerX + this.cellSize * i;
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, this.height);
context.stroke();
context.closePath();
}
}
////////////////////////////////////////////////////////////////////////////////
// Functions
function init() {
// Set up canvas
if (window.devicePixelRatio > 1) {
canvas.width = canvasWidth * window.devicePixelRatio;
canvas.height = canvasHeight * window.devicePixelRatio;
context.scale(window.devicePixelRatio, window.devicePixelRatio);
}
else {
canvas.width = canvasWidth;
canvas.height = canvasHeight;
}
canvas.style.width = canvasWidth + "px";
canvas.style.height = canvasHeight + "px";
// Setup the grid
grid = new Grid();
// Display the grid
grid.draw();
}
function setZoom() {
// Calculate the mouse position before applying the zoom
// in the coordinate system of the grid
const x1 = (mousePosX - grid.centerX) / grid.cellSize;
const y1 = (mousePosY - grid.centerY) / grid.cellSize;
grid.cellSize = grid.cellSize + zoom;
// After zoom, you'll see the coordinates changed
const x2 = (mousePosX - grid.centerX) / grid.cellSize;
const y2 = (mousePosY - grid.centerY) / grid.cellSize;
// Calculate the shift
const dx = x2 - x1;
const dy = y2 - y1;
// "Undo" the shift by shifting the coordinate system's center
grid.centerX += dx * grid.cellSize;
grid.centerY += dy * grid.cellSize;
grid.draw();
}
////////////////////////////////////////////////////////////////////////////////
//Launch the page
init();
////////////////////////////////////////////////////////////////////////////////
// Update the page on resize
window.addEventListener("resize", init);
// Zoom the canvas with mouse wheel
canvas.addEventListener('mousewheel', function (e) {
e.preventDefault();
e.stopPropagation();
zoom = e.wheelDelta/120;
requestAnimationFrame(setZoom);
})
// Get mouse coordinates on mouse move.
canvas.addEventListener('mousemove', function (e) {
e.preventDefault();
e.stopPropagation();
mousePosX = parseInt(e.clientX)-bodyToCanvas ;
mousePosY = parseInt(e.clientY)-bodyToCanvas ;
})
////////////////////////////////////////////////////////////////////////////////
html, body
{
background:#21252b;
width:100%;
height:100%;
margin:0px;
padding:0px;
overflow: hidden;
}
span{
color:white;
font-family: arial;
font-size: 12px;
margin:8px;
}
#canvas{
margin:8px;
border: 1px solid white;
}
<canvas id="canvas"></canvas>

How to move object to target naturally and smoothly?

Can somebody fix it script to make it works properly?
What I expects:
Run script
Click at the canvas to set target (circle)
Object (triangle) starts to rotate and move towards to target (circle)
Change target at any time
How it works:
Sometimes object rotates correctly, sometimes isn't
Looks like one half sphere works well, another isn't
Thanks!
// prepare 2d context
const c = window.document.body.appendChild(window.document.createElement('canvas'))
.getContext('2d');
c.canvas.addEventListener('click', e => tgt = { x: e.offsetX, y: e.offsetY });
rate = 75 // updates delay
w = c.canvas.width;
h = c.canvas.height;
pi2 = Math.PI * 2;
// object that moves towards the target
obj = {
x: 20,
y: 20,
a: 0, // angle
};
// target
tgt = undefined;
// main loop
setInterval(() => {
c.fillStyle = 'black';
c.fillRect(0, 0, w, h);
// update object state
if (tgt) {
// draw target
c.beginPath();
c.arc(tgt.x, tgt.y, 2, 0, pi2);
c.closePath();
c.strokeStyle = 'red';
c.stroke();
// update object position
// vector from obj to tgt
dx = tgt.x - obj.x;
dy = tgt.y - obj.y;
// normalize
l = Math.sqrt(dx*dx + dy*dy);
dnx = (dx / l);// * 0.2;
dny = (dy / l);// * 0.2;
// update object position
obj.x += dnx;
obj.y += dny;
// angle between +x and tgt
a = Math.atan2(0 * dx - 1 * dy, 1 * dx + 0 * dy);
// update object angle
obj.a += -a * 0.04;
}
// draw object
c.translate(obj.x, obj.y);
c.rotate(obj.a);
c.beginPath();
c.moveTo(5, 0);
c.lineTo(-5, 4);
c.lineTo(-5, -4);
//c.lineTo(3, 0);
c.closePath();
c.strokeStyle = 'red';
c.stroke();
c.rotate(-obj.a);
c.translate(-obj.x, -obj.y);
}, rate);
This turned out to be a bit more challenging than I first thought and I ended up just re-writing the code.
The challenges:
Ensure the ship only rotated to the exact point of target. This required me to compare the two angle from the ship current position to where we want it to go.
Ensure the target did not rotate past the target and the ship did not translate past the target. This required some buffer space for each because when animating having this.x === this.x when an object is moving is very rare to happen so we need some room for the logic to work.
Ensure the ship turned in the shortest direction to the target.
I have added notes in the code to better explain. Hopefully you can implement this into yours or use it as is. Oh and you can change the movement speed and rotation speed as you see fit.
let canvas = document.getElementById("canvas");
let ctx = canvas.getContext("2d");
canvas.width = 400;
canvas.height = 400;
let mouse = { x: 20, y: 20 };
let canvasBounds = canvas.getBoundingClientRect();
let target;
canvas.addEventListener("mousedown", (e) => {
mouse.x = e.x - canvasBounds.x;
mouse.y = e.y - canvasBounds.y;
target = new Target();
});
class Ship {
constructor() {
this.x = 20;
this.y = 20;
this.ptA = { x: 15, y: 0 };
this.ptB = { x: -15, y: 10 };
this.ptC = { x: -15, y: -10 };
this.color = "red";
this.angle1 = 0;
this.angle2 = 0;
this.dir = 1;
}
draw() {
ctx.save();
//use translate to move the ship
ctx.translate(this.x, this.y);
//angle1 is the angle from the ship to the target point
//angle2 is the ships current rotation angle. Once they equal each other then the rotation stops. When you click somewhere else they are no longer equal and the ship will rotate again.
if (!this.direction(this.angle1, this.angle2)) {
//see direction() method for more info on this
if (this.dir == 1) {
this.angle2 += 0.05; //change rotation speed here
} else if (this.dir == 0) {
this.angle2 -= 0.05; //change rotation speed here
}
} else {
this.angle2 = this.angle1;
}
ctx.rotate(this.angle2);
ctx.beginPath();
ctx.strokeStyle = this.color;
ctx.moveTo(this.ptA.x, this.ptA.y);
ctx.lineTo(this.ptB.x, this.ptB.y);
ctx.lineTo(this.ptC.x, this.ptC.y);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
driveToTarget() {
//get angle to mouse click
this.angle1 = Math.atan2(mouse.y - this.y, mouse.x - this.x);
//normalize vector
let vecX = mouse.x - this.x;
let vecY = mouse.y - this.y;
let dist = Math.hypot(vecX, vecY);
vecX /= dist;
vecY /= dist;
//Prevent continuous x and y increment by checking if either vec == 0
if (vecX != 0 || vecY != 0) {
//then also give the ship a little buffer incase it passes the given point it doesn't turn back around. This allows time for it to stop if you increase the speed.
if (
this.x >= mouse.x + 3 ||
this.x <= mouse.x - 3 ||
this.y >= mouse.y + 3 ||
this.y <= mouse.y - 3
) {
this.x += vecX; //multiple VecX by n to increase speed (vecX*2)
this.y += vecY; //multiple VecY by n to increase speed (vecY*2)
}
}
}
direction(ang1, ang2) {
//converts rads to degrees and ensures we get numbers from 0-359
let a1 = ang1 * (180 / Math.PI);
if (a1 < 0) {
a1 += 360;
}
let a2 = ang2 * (180 / Math.PI);
if (a2 < 0) {
a2 += 360;
}
//checks whether the target is on the right or left side of the ship.
//We use then to ensure it turns in the shortest direction
if ((360 + a1 - a2) % 360 > 180) {
this.dir = 0;
} else {
this.dir = 1;
}
//Because of animation timeframes there is a chance the ship could turn past the target if rotating too fast. This gives the ship a 1 degree buffer to either side of the target to determine if it is pointed in the right direction.
//We then correct it to the exact degrees in the draw() method above once the if statment defaults to 'else'
if (
Math.trunc(a2) <= Math.trunc(a1) + 1 &&
Math.trunc(a2) >= Math.trunc(a1) - 1
) {
return true;
}
return false;
}
}
let ship = new Ship();
class Target {
constructor() {
this.x = mouse.x;
this.y = mouse.y;
this.r = 3;
this.color = "red";
}
draw() {
ctx.strokeStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2, false);
ctx.stroke();
}
}
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(0, 0, canvas.width, canvas.height);
ship.draw();
ship.driveToTarget();
if (target) {
target.draw();
}
requestAnimationFrame(animate);
}
animate();
<canvas id="canvas"></canvas>

How do I resize my canvas with my elements in it?

Background Information
I'm currently working on a project and I want the background to be a blank canvas with balls bouncing off the walls.
I've managed so far, but I've come across a problem.
Whenever I resize my browser window, neither my canvas or the balls in it follow through. I have no clue what I'm doing wrong.
codepen
Code
const canvas = document.querySelector('#responsive-canvas')
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
let c = canvas.getContext('2d');
function Circle(x, y, dx, dy, radius, colour) {
this.x = x;
this.y = y;
this.dx = dx;
this.dy = dy;
this.radius = radius;
this.colour = colour;
this.draw = function() {
this.getNewColour = function() {
let symbols, colour;
symbols = "0123456789ABCDEF";
colour = "#";
for (let i = 0; i < 6; i++) {
colour += symbols[Math.floor(Math.random() * 16)];
}
c.strokeStyle = colour;
c.fillStyle = colour;
}
this.getNewColour();
this.x = x;
this.y = y;
this.radius = radius;
//this.getNewColour().colour = colour;
c.beginPath();
c.arc(x, y, radius, 0, Math.PI * 2, false);
// c.strokeStyle = 'blue';
c.stroke();
//c.fill();
}
this.update = function() {
this.x = x;
this.y = y;
this.dx = dx;
this.dy = dy;
this.radius = radius;
if (x + radius > innerWidth || x - radius < 0) {
dx = -dx;
}
if (y + radius > innerHeight || y - radius < 0) {
dy = -dy;
}
x += dx;
y += dy;
this.draw();
}
}
let circleArr = [];
for (let i = 0; i < 23; i++) {
let radius = 50;
let x = Math.random() * (innerWidth - radius * 2) + radius;
let y = Math.random() * (innerHeight - radius * 2) + radius;
let dx = (Math.random() - 0.5);
let dy = (Math.random() - 0.5);
circleArr.push(new Circle(x, y, dx, dy, radius));
};
function animate() {
requestAnimationFrame(animate);
c.clearRect(0, 0, innerWidth, innerHeight)
for (var i = 0; i < circleArr.length; i++) {
circleArr[i].update();
}
};
animate();
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* BODY
*/
html,
body {
width: 100%;
height: 100%;
margin: 0;
}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title></title>
<meta name="description" content="">
<link rel="stylesheet" href="../dist/css/style.css">
<body>
<canvas id="responsive-canvas"></canvas>
</body>
<script src="js/canvas.js"></script>
</html>
you can use
ctx.scale(x,y);
to scale everything on the canvas by the given factor, the X and Y are scalling on X and Y axis respectively.
You might want to reset the canvas using
ctx.resetTransform() or ctx.setTransform(1,0,0,1,0,0);
Then draw your background from 0,0 to canvas.width and canvas.height
Then draw everything (all of the circles) and finally set the scalling to your desired value.
If you want the width and height to change, you just need to add the width and height in the same CSS area that the `{border-box} is in.
but if you want to make the images inside it to not be stretched, you would need to access the heigh specifically in the canvas using the above comments methods.
Preferably using:
width:() => document.documentElement.clientWidth
First you must fix your collision logic, then you can safely change the canvas size as needed.
Always resolve the collisions fully.
An unresolved collision means that your sim is in an impossible state (eg ball inside a wall). From that point the following states will be meaning less.
Fix
The reason why the balls move out of the canvas is because you are not moving them back onto the canvas if they are outside.
Also when you change the ball direction when it hits the side you do not ensure that the direction is the correct sign.
// if the dx = 1 and x > innerWidth + 1000 then dx becomes -1
// Next tine x is still > innerWidth + 1000 and you flip the sign of dx again to 1,
// Then dx to -1 and so on. You never move the ball
if(x + radius > innerWidth || x - radius < 0) {
dx = -dx;
}
Be systematic in the collision testing so that when the playfield (canvas) changes size you don't get unexpected results
When you update, first move then check for collisions. If there is a collision, move the ball again to ensure not magic ball in wall thing happening.
update() {
this.x += this.dx;
this.y += this.dy;
if (this.x < this.radius) {
this.x = this.radius;
this.dx = Math.abs(this.dx);
}
if (this.y < this.radius) {
this.y = this.radius;
this.dy = Math.abs(this.dy);
}
if (this.x > ctx.canvas.width - this.radius) {
this.x = ctx.canvas.width - this.radius;
this.dx = -Math.abs(this.dx);
}
if (this.y > ctx.canvas.height - this.radius) {
this.y = ctx.canvas.height - this.radius;
this.dy = -Math.abs(this.dy);
}
}
Resize the canvas
With the collision problem solved you can now change the canvas size whenever you need to. One way is to do it inside the animate function.
If the canvas size does not match the page size change the canvas size to match.
function animate () {
if (ctx.canvas.width !== innerWidth || ctx.canvas.height !== innerHeight) {
ctx.canvas.width = innerWidth;
ctx.canvas.height = innerHeight;
} else {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
}
// ... do stuff
requestAnimationFrame(animate);
}
Note that changing the canvas size also clears the canvas, that is why the clear is on the else clause.
Copy paste demo
There were many bad points in the code. You can copy paste the snippet below into code pen or use the information above to modify your code in your style.
const canvas = document.querySelector('#responsive-canvas')
canvas.width = innerWidth;
canvas.height = innerHeight;
// Set constants in one place so you can make changes quickly and easily
const DEFAULT_RADIUS = 50;
const MAX_SPEED = 5;
const CIRCLE_COUNT = 100;
Math.TAU = Math.PI * 2
// Use function to do repetitive code
Math.rand = (min, max) => Math.random() * (max - min) + min; //
function randomHexColor() {
return "#" + ((Math.random() * 0xFFFFFF | 0).toString(16).padStart(6,"0"));
}
// pulral names for arrays and variables that do not change should be constants
const circles = [];
const ctx = canvas.getContext('2d');
requestAnimationFrame(animate); // start frame renderer with a request, dont call it directly
function Circle( // using default params to set random values
radius = Math.rand(DEFAULT_RADIUS/4, DEFAULT_RADIUS), // radius must be first argument as its used to set random x, and y
x = Math.rand(radius, ctx.canvas.width - radius),
y = Math.rand(radius, ctx.canvas.height - radius),
dx = Math.rand(-MAX_SPEED, MAX_SPEED),
dy = Math.rand(-MAX_SPEED, MAX_SPEED),
colour = randomHexColor()
) {
this.x = x;
this.y = y;
this.dx = dx;
this.dy = dy;
this.radius = radius;
this.colour = colour;
}
// Define Circle functions as prototype outside the function Circle (runs faster)
Circle.prototype = {
draw() {
ctx.strokeStyle = ctx.fillStyle = this.colour;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.TAU);
ctx.stroke();
},
update() {
this.x += this.dx;
this.y += this.dy;
if (this.x < this.radius) {
this.x = this.radius;
this.dx = Math.abs(this.dx);
}
if (this.y < this.radius) {
this.y = this.radius;
this.dy = Math.abs(this.dy);
}
if (this.x > ctx.canvas.width - this.radius) {
this.x = ctx.canvas.width - this.radius;
this.dx = -Math.abs(this.dx);
}
if (this.y > ctx.canvas.height - this.radius) {
this.y = ctx.canvas.height - this.radius;
this.dy = -Math.abs(this.dy);
}
}
};
for (let i = 0; i < CIRCLE_COUNT; i++) { circles.push(new Circle()) }
function animate () {
if (ctx.canvas.width !== innerWidth || ctx.canvas.height !== innerHeight) {
ctx.canvas.width = innerWidth;
ctx.canvas.height = innerHeight;
} else {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
}
// use for of loop (saves having to mess with index)
for (const circle of circles) {
circle.update(); // seperate update and draw
circle.draw()
}
requestAnimationFrame(animate);
}

Html5 canvas game, creating a map much bigger than viewing canvas

So let me start off by saying I am trying to create a large image or room, say 5000 by 3750, and have the canvas or viewing area only 800 by 599, always following the player piece. I did find a good guide on how to do this, using a background and Player drawn in java script (not taken from a sprite sheet). But what I have is a sprite sheet with the background, and the player, I have gotten the player to work on the background drawn from JavaScript, but not taken from the sprite sheet like I want to do.
Here is some of the code:
// wrapper for "class" Map
(function(){
function Map(width, height){
// map dimensions
this.width = width;
this.height = height;
// map texture
this.image = null;
}
// generate an example of a large map
Map.prototype.generate = function(){
ctxBg.drawImage(imgSprite,0,2250,5000,3750,0,0,5000,3750);
}
// draw the map adjusted to camera
Map.prototype.draw = function(context, xView, yView){
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 = 800;
sHeight = 599;
// if cropped image is smaller than canvas we need to change the source dimensions
if(800 - sx < sWidth){
sWidth = 800 - sx;
}
if(599 - sy < sHeight){
sHeight = 599 - 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(imgSprite, 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: 5000,
height: 3750,
map: new Game.Map(5000, 3750)
};
room.map.generate();
heres the camra/player codes:
(function(){
function Rectangle(left, top, width, height){
this.left = left || 0;
this.top = top || 0;
this.right = (left + width) || 0;
this.bottom = (top + height) || 0;
}
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: "none",
HORIZONTAL: "horizontal",
VERTICAL: "vertical",
BOTH: "both"
};
// Camera constructor
function Camera(xView, yView, canvasWidth, canvasHeight, 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 = 800;
this.hView = 599;
// 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(this.xView, this.yView, this.wView, this.hView);
}
// 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;
this.srcX = 1700;
this.srcY = 599;
this.drawX = 350;
this.drawY = 400;
xView = this.x-this.width/2;
yView = this.y-this.height/2;
// move speed in pixels per second
this.speed = 100;
// render properties
this.width = 85;
this.height = 80;
}
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){
ctxPlayer.clearRect(0,0,800,599);
context.save();
ctxPlayer.drawImage(imgSprite,this.srcX,this.srcY,this.width,this.height,(this.x-this.width/2),(this.y-this.height/2),this.width,this.height);
context.restore();
}
// add "class" Player to our Game object
Game.Player = Player;
})();
It shows the image and player, but the canvas doesn't follow the player object, It does if I use a background like this, though:
(function(){
function Map(width, height){
this.width = width;
this.height = height;
this.image = null;
}
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();
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){
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;
}
dx = 0;
dy = 0;
dWidth = sWidth;
dHeight = sHeight;
context.drawImage(this.image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
}
Game.Map = Map;
})();
I looked around, did some google searches and used a JavaScript checker, but no luck. Any suggestions/advice is much appreciated.
I'll give it a shot anyways. So, for the theoretical part:
What you are trying to achieve is simple Scene Management. A Scene needs a Camera, an object that stores the X and Y offset and the Width and Height it is going to display (The Width and Height is also known as the Projection Plane in 3D graphics). In every frame you will draw your Scene (or World) by the offset of your Camera.
To the implementation:
To draw a large image onto a small Canvas, just use the drawImage() function with all 9 parameters as you allready do.
To draw many small images like Tiles, i recommend taking a look at Scene graphs, i wrote a more in-depth answer some time ago at Collision detection in HTML5 canvas. Optimization too
If you draw many Objects per frame, note that you can always create canvas objects that are not in the html DOM to cache draw results, it is necessary for good performance. A draw call is expensive because of its render state changes and streaming costs, not because of the pixels itself.
Finally, to draw your character on top, you would need some sort of z index, so your draw loop knows the player is on top of the ground, you can do this via layers or storing a z index for your game objects.
So far, you're already on the right track!

Categories

Resources