Javascript Canvas Viewport Camera for Top down RPG game - javascript

edit: what is my question exactly?
I want to create a "viewport" camera effect that will follow the player without moving the background
I'm adding a websocket support and will render more characters on the map - i need movement to happen based on the player and not the map - so that i can update the rest of the players with movement position correctly
original post:
I've gone over most other posts about this subject.
It seems that, everyone, has their own unique problem for their own unique implementation of the "Canvas topdown game".
That makes it really hard finding a solution for your problem without refactoring your whole code.
So hopefully you guys will be able to help me this time.
Simple game (so far) - utilizing Vue3 btw but that's besides the point - most of the code is just Vanilla stuff.
Simple topdown map.
X and Y Axis
What I'm doing in my implementation is taking a PNG I've created with the Tiled application
Zoomed in by about 400% - drawing it with ctx.drawImage
then I'm also setting boundaries
and then drawing the player
what I'm doing now is adding a "force" value to the x and y position on key pressed (WASD)
Which means the character moves on the map and collision detection works
what I want is the map to draw x amount of pixels based on where the character is positioned WITHOUT moving the map since I'm already implementing some SocketIO code to make this multiplayer
I'm really really lost here, not sure what I should be doing to make the map draw a viewport....
I hope some of this makes sense
Some code
function create() {
const canvas: HTMLCanvasElement = document.getElementById("game") as HTMLCanvasElement;
if (!canvas) return console.error("canvas is undefined");
const ctx: CanvasRenderingContext2D = canvas.getContext("2d") as CanvasRenderingContext2D;
ctx.imageSmoothingEnabled = false;
initializeGameWindow(canvas, ctx);
setEvents(canvas, ctx);
const playerSpriteWidth = 192;
const playerSpriteHeight = 68;
const spriteFrames = 4;
background = new Sprite();
player = new Sprite();
update(canvas, ctx);
}
function update(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
window.requestAnimationFrame(() => update(canvas, ctx));
drawGame(canvas, ctx);
}
function drawGame(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
background.draw(ctx);
boundaries.forEach((boundary) => {
boundary.draw(ctx);
});
player.value.draw(ctx);
let moving: boolean = true;
player.value.moving = false;
if (keys.w.pressed && lastKey.value === "w") {
player.value.moving = true;
player.value.image = player.value.sprites.up;
for (let i = 0; i < boundaries.length; i++) {
if (
rectengularCollision(player.value, {
...boundaries[i],
position: { x: boundaries[i].position.x, y: boundaries[i].position.y + Sprite.force },
})
) {
moving = false;
break;
}
}
if (!moving) return;
player.value.position.y -= Sprite.force;
} else if (keys.a.pressed && lastKey.value === "a") {
player.value.moving = true;
player.value.image = player.value.sprites.left;
for (let i = 0; i < boundaries.length; i++) {
if (
rectengularCollision(player.value, {
...boundaries[i],
position: { x: boundaries[i].position.x + Sprite.force, y: boundaries[i].position.y },
})
) {
moving = false;
break;
}
}
if (!moving) return;
player.value.position.x -= Sprite.force;
} else if (keys.s.pressed && lastKey.value === "s") {
player.value.moving = true;
player.value.image = player.value.sprites.down;
for (let i = 0; i < boundaries.length; i++) {
if (
rectengularCollision(player.value, {
...boundaries[i],
position: { x: boundaries[i].position.x, y: boundaries[i].position.y - Sprite.force },
})
) {
moving = false;
break;
}
}
if (!moving) return;
player.value.position.y += Sprite.force;
} else if (keys.d.pressed && lastKey.value === "d") {
player.value.moving = true;
player.value.image = player.value.sprites.right;
for (let i = 0; i < boundaries.length; i++) {
if (
rectengularCollision(player.value, {
...boundaries[i],
position: { x: boundaries[i].position.x - Sprite.force, y: boundaries[i].position.y },
})
) {
moving = false;
break;
}
}
if (!moving) return;
player.value.position.x += Sprite.force;
}
}
export class Sprite {
static force: number = 3; // speed, velocity, acceleration, etc.
frames: number;
spriteIteration: number = 0;
elapsed: number = 0;
defaultSrc: string;
image: HTMLImageElement;
sprites: { up: HTMLImageElement; down: HTMLImageElement; left: HTMLImageElement;
right: HTMLImageElement };
position: { x: number; y: number };
width: number = 0;
height: number = 0;
moving: boolean = false;
constructor(
position: { x: number; y: number },
src : string,
frames: number = 1,
sprites: { up: string; down: string; left: string; right: string } = { up:
"", down: "", left: "", right: "" },
) {
this.defaultSrc = src;
this.moving = false;
const { up, down, left, right } = this.initSprites(sprites);
this.sprites = { up, down, left, right };
this.image = down;
this.frames = frames;
this.position = position;
this.image.onload = () => {
this.width = this.image.width / this.frames;
this.height = this.image.height;
};
}
draw(ctx: CanvasRenderingContext2D) {
ctx.drawImage(
// src
this.image,
// crop from x axis
this.spriteIteration * this.width,
// crop from y axis
0,
// crop width
this.image.width / this.frames,
// crop height
this.image.height,
// x position on canvas
this.position.x,
// y position on canvas
this.position.y,
// width on canvas
this.image.width / this.frames,
// height on canvas
this.image.height,
);
}
initSprites(sprites: { up: string; down: string; left: string; right: string }) {
const up = new Image();
up.src = sprites.up;
const down = new Image();
down.src = sprites.down !== "" ? sprites.down : this.defaultSrc;
const left = new Image();
left.src = sprites.left;
const right = new Image();
right.src = sprites.right;
return { up, down, left, right };
}
}

Since you haven't included a runnable snippet for me to work with, here's a very generic example that shows how you can use CanvasRenderingContext2D's transformation methods to change the viewport of a scene.
In draw, you'll see:
Reset the transform to use the center of the <canvas> as (0, 0) using setTransform
To draw from the perspective of a player:
translate the context by -x and -y multiplied by the zoom factor
scale the canvas with the zoom factor
// Initialize
const cvs = document.createElement("canvas");
cvs.width = 200;
cvs.height = 200;
const ctx = cvs.getContext("2d");
// State
const players = [
[-20, -30, "green"],
[50, 70, "blue"]
];
let viewport = {
x: 0,
y: 0,
zoom: 1
};
// Drawing methods
const clear = () => {
ctx.fillStyle = "white";
ctx.fillRect(-cvs.width / 2, -cvs.height / 2, cvs.width, cvs.height);
};
const drawMap = () => {
const w = cvs.width - 25;
const h = cvs.height - 25;
ctx.fillStyle = "#efefef";
ctx.fillRect(
-w / 2,
-h / 2,
w,
h
);
}
const drawPlayers = () => {
for (const [x, y, color] of players) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, 10, Math.PI * 2, 0);
ctx.closePath();
ctx.fill();
}
}
const drawCenter = () => {
ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
ctx.fillRect(-1, -15, 2, 30);
ctx.fillRect(-15, -1, 30, 2);
}
const draw = () => {
// Zoom out and set (0, 0) to center of canvas
ctx.setTransform(1, 0, 0, 1, cvs.width / 2, cvs.height / 2);
clear();
const { zoom, x, y } = viewport;
ctx.translate(-x * zoom, -y * zoom);
ctx.scale(zoom, zoom);
drawMap();
drawPlayers();
// For debugging: mark the center of the canvas
ctx.setTransform(1, 0, 0, 1, cvs.width / 2, cvs.height / 2);
drawCenter();
}
draw();
document.body.appendChild(cvs);
// UI
const updateViewport = () => {
const centerValue = document.querySelector("input[name=center]:checked").value;
const [x, y] = players[centerValue] || [0, 0];
const zoom = document.querySelector("input[type=range]").valueAsNumber;
viewport = { x: x, y: y, zoom };
draw();
};
document.querySelectorAll("input").forEach(el => {
el.addEventListener("input", updateViewport);
});
body { display: flex; }
canvas { border: 1px solid red; position: relative; }
fieldset { margin-bottom: 1rem; }
<div>
<fieldset>
<legend>View center:</legend>
<label>
<input type="radio" value="-1" name="center" checked> Map
</label>
<label>
<input type="radio" value="0" name="center"> Green player
</label>
<label>
<input type="radio" value="1" name="center"> Blue player
</label>
</fieldset>
<fieldset>
<legend>Zoom</legend>
<input type="range" min="0.1" max ="3" step="0.1" value="1">
</fieldset>
</div>

Related

How do I repeatedly interpolate linearly between two values within a requestAnimationFrame loop

I'm trying to implement multiplayer position interpolation for my canvas game but I'm having trouble using my linear interpolation (lerp) function. I experience slight jitter when using t = 0.1 so I'm looking for some alternative way to calculate t such that movement will appear smooth to the user - or a different solution altogether.
I render a canvas as follows (simplified):
function lerp (a, b, t) {
return a + (b - a) * t;
}
function tick() {
// Process position update(s).
const t = 0.1;
position.x = lerp(position.x, target_position.x, t);
position.y = lerp(position.y, target_position.y, t);
// Camera follow by offsetting canvas transform.
update_camera();
window.requestAnimationFrame(tick);
}
function update_camera() {
const position_delta_x = position.x - initial_position.x;
const position_delta_y = position.y - initial_position.y;
const offset_x = Math.round(position_delta_x);
const offset_y = Math.round(position_delta_y);
ctx.setTransform(1, 0, 0, 1, -offset_x, -offset_y);
}
tick();
I receive about 10 updates every second via a websocket that contains new position data:
function handle_position_update(new_position) {
target_position.x = new_position.x;
target_position.y = new_position.y;
}
I've noticed the jitter is coming from my camera follow logic but I'm sure as to why this is happening.
JSFIDDLE
// Shared code.
const INITIAL_POSITION = {
x: 300,
y: 80
};
// Server-side code.
const server_state = {
x: INITIAL_POSITION.x,
y: INITIAL_POSITION.y
};
const UPDATE_TICK_RATE = 10;
const PHYSICS_TICK_RATE = 60;
setInterval(() => {
// Simulate server physics update.
const w = input[87] ? 1 : 0;
const a = input[65] ? 1 : 0;
const s = input[83] ? 1 : 0;
const d = input[68] ? 1 : 0;
const vertical = w ? 1 : s ? -1 : 0;
const horizontal = d ? 1 : a ? -1 : 0;
server_state.x += horizontal * 5;
server_state.y -= vertical * 5;
}, 1000 / PHYSICS_TICK_RATE)
setInterval(() => {
// Simulate server sending updates.
target_pos_x = server_state.x;
target_pos_y = server_state.y;
}, 1000 / UPDATE_TICK_RATE);
// Client-side code.
let target_pos_x = INITIAL_POSITION.x,
target_pos_y = INITIAL_POSITION.y;
let input = [];
window.addEventListener('keydown', e => input[e.keyCode] = true);
window.addEventListener('keyup', e => input[e.keyCode] = false);
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width = 600;
const H = canvas.height = 160;
const circle = {
position: {
x: INITIAL_POSITION.x,
y: INITIAL_POSITION.y
},
tick: function() {
let t = 0.1;
this.position.x = lerp(this.position.x, target_pos_x, t);
this.position.y = lerp(this.position.y, target_pos_y, t);
ctx.beginPath();
ctx.arc(this.position.x, this.position.y, 3, 0, 2 * Math.PI);
ctx.fillStyle = 'white'
ctx.fill();
}
}
const reference_point = {
position: {
x: 240,
y: 60
},
tick: function() {
ctx.beginPath();
ctx.arc(this.position.x, this.position.y, 3, 0, 2 * Math.PI);
ctx.fillStyle = 'red'
ctx.fill()
}
}
function tick(now) {
clear();
circle.tick();
reference_point.tick();
camera_follow();
window.requestAnimationFrame(tick);
}
tick();
function clear() {
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
ctx.restore();
}
function camera_follow() {
const position_delta_x = circle.position.x - INITIAL_POSITION.x;
const position_delta_y = circle.position.y - INITIAL_POSITION.y;
const offset_x = Math.round(position_delta_x);
const offset_y = Math.round(position_delta_y);
ctx.setTransform(1, 0, 0, 1, -offset_x, -offset_y);
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
<canvas></canvas>
<div>WASD to move</div>

Move an object through set of coordinates on HTML5 Canvas

I want to move a object (circle in this case) through array of coordinates (for example: {(300,400), (200,300), (300,200),(400,400)})on HTML5 Canvas. I could move the object to one coordinate as follows. The following code draws a circle at (100,100) and moves it to (300,400). I am stuck when trying to extend this so that circle moves through set of coordinates one after the other.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
//circle object
let circle ={
x:100,
y:100,
radius:10,
dx:1,
dy:1,
color:'blue'
}
//function to draw above circle on canvas
function drawCircle(){
ctx.beginPath();
ctx.arc(circle.x,circle.y,circle.radius,0,Math.PI*2);
ctx.fillStyle=circle.color;
ctx.fill();
ctx.closePath();
}
//Moving to a target coordinate (targetX,targetY)
function goTo(targetX,targetY){
if(Math.abs(circel.x-targetX)<circle.dx && Math.abs(circel.y-targetY)<circle.dy){
circle.dx=0;
circle.dy=0;
circel.x = targetX;
circle.y = targetY;
}
else{
const opp = targetY - circle.y;
const adj = targetX - circle.x;
const angle = Math.atan2(opp,adj)
circel.x += Math.cos(angle)*circle.dx
circle.y += Math.sin(angle)*circle.dy
}
}
function update(){
ctx.clearRect(0,0,canvas.width,canvas.height);
drawCircle()
goTo(300,400)
requestAnimationFrame(update);
}
update()
Random access key frames
For the best control of animations you need to create way points (key frames) that can be accessed randomly by time. This means you can get any position in the animation just by setting the time.
You can then play and pause, set speed, reverse and seek to any position in the animation.
Example
The example below uses a set of points and adds data required to quickly locate the key frames at the requested time and interpolate the position.
The blue dot will move at a constant speed over the path in a time set by pathTime in this case 4 seconds.
The red dot's position is set by the slider. This is to illustrate the random access of the animation position.
const ctx = canvas.getContext('2d');
const pathTime = 4; // Total time to travel path from start to end in seconds
var startTime, animTime = 0, paused = false;
requestAnimationFrame(update);
const P2 = (x, y) => ({x, y, dx: 0,dy: 0,dist: 0, start: 0, end: 0});
const pathCoords = [
P2(20, 20), P2(100, 50),P2(180, 20), P2(150, 100), P2(180, 180),
P2(100, 150), P2(20, 180), P2(50, 100), P2(20, 20),
];
createAnimationPath(pathCoords);
const circle ={
draw(rad = 10, color = "blue") {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(this.x, this.y, rad, 0, Math.PI * 2);
ctx.fill();
}
};
function createAnimationPath(points) { // Set up path for easy random position lookup
const segment = (prev, next) => {
[prev.dx, prev.dy] = [next.x - prev.x, next.y - prev.y];
prev.dist = Math.hypot(prev.dx, prev.dy);
next.end = next.start = prev.end = prev.start + prev.dist;
}
var i = 1;
while (i < points.length) { segment(points[i - 1], points[i++]) }
}
function getPos(path, pos, res = {}) {
pos = (pos % 1) * path[path.length - 1].end; // loop & scale to total length
const pathSeg = path.find(p => pos >= p.start && pos <= p.end);
const unit = (pos - pathSeg.start) / pathSeg.dist; // unit distance on segment
res.x = pathSeg.x + pathSeg.dx * unit; // x, y position on segment
res.y = pathSeg.y + pathSeg.dy * unit;
return res;
}
function update(time){
// startTime ??= time; // Throws syntax on iOS
startTime = startTime ?? time; // Fix for above
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (paused) { startTime = time - animTime }
else { animTime = time - startTime }
getPos(pathCoords, (animTime / 1000) / pathTime, circle).draw();
getPos(pathCoords, timeSlide.value, circle).draw(5, "red");
requestAnimationFrame(update);
}
pause.addEventListener("click", ()=> { paused = true; pause.classList.add("pause") });
play.addEventListener("click", ()=> { paused = false; pause.classList.remove("pause") });
rewind.addEventListener("click", ()=> { startTime = undefined; animTime = 0 });
div {
position:absolute;
top: 5px;
left: 20px;
}
#timeSlide {width: 360px}
.pause {color:blue}
button {height: 30px}
<div><input id="timeSlide" type="range" min="0" max="1" step="0.001" value="0" width= "200"><button id="rewind">Start</button><button id="pause">Pause</button><button id="play">Play</button></div>
<canvas id="canvas" width="200" height="200"></canvas>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// array of path coords
const pathCoords = [
[200,100],
[300, 150],
[200,190],
[400,100],
[50,10],
[150,10],
[0, 50],
[500,90],
[20,190],
[10,180],
];
// current point
let currentTarget = pathCoords.shift();
//circle object
const circle ={
x:10,
y:10,
radius:10,
dx:2,
dy:2,
color:'blue'
}
//function to draw above circle on canvas
function drawCircle(){
ctx.beginPath();
ctx.arc(circle.x,circle.y,circle.radius,0,Math.PI*2);
ctx.fillStyle=circle.color;
ctx.fill();
ctx.closePath();
}
//Moving to a target coordinate (targetX,targetY)
function goTo(targetX, targetY){
if(Math.abs(circle.x-targetX)<circle.dx && Math.abs(circle.y-targetY)<circle.dy){
// dont stop...
//circle.dx = 0;
//circle.dy = 0;
circle.x = targetX;
circle.y = targetY;
// go to next point
if (pathCoords.length) {
currentTarget = pathCoords.shift();
} else {
console.log('Path end');
}
} else {
const opp = targetY - circle.y;
const adj = targetX - circle.x;
const angle = Math.atan2(opp,adj)
circle.x += Math.cos(angle)*circle.dx
circle.y += Math.sin(angle)*circle.dy
}
}
function update(){
ctx.clearRect(0,0,canvas.width,canvas.height);
drawCircle();
goTo(...currentTarget);
requestAnimationFrame(update);
}
update();
<canvas id=canvas width = 500 height = 200></canvas>

Simulate car rotation in JavaScript [duplicate]

This question already has answers here:
How do I rotate a single object on an html 5 canvas?
(8 answers)
Closed 2 years ago.
I want to simulate car rotation and movement in the new direction in a game I am designing.
According to the following answer, within the HTML5 canvas element you cannot rotate individual elements.
The movement itself is happening in this function, I expect the vehicle to move but the entire canvas moves (try pressing left and right). I couldn't figure out how to rotate the car.
Game._rotate = function(dirx, direction) {
this.ctx.translate(200,200);
}
According to this tutorial, I can only rotate the car itself by rotating the canvas. I'd like the rotation and movement to emulate the following: https://oseiskar.github.io/js-car/.
The code itself here: https://plnkr.co/edit/K5X8fMhUlRLhdeki.
You need to render the car relative to ITS position. Also, you can do the rotation inside its RENDER.
The changes below will handle rotation for the car, but you have bigger problems. I would take time and invest in learning how vectors work. The position, speed and acceleration should all be 2D vectors that provide vector math like adding and multiplication.
Also, provide an initial angle to your car so it is initially rendered the right way. It also appears that your acceleration and deceleration are mixed up.
function Car(map, x, y) {
this.map = map;
this.x = x;
this.y = y;
this.angle = 0; // IMPORTANT
this.width = map.tsize;
this.height = map.tsize;
this.image = Loader.getImage('car');
}
Car.speed = 0;
Car.acceleration = 0;
Car.friction = 5;
Car.moveAngle = 0;
Car.maxSpeed = 500;
Car.forward = 'FORWARD';
Car.backward = 'BACKWARD';
Car.left = 'LEFT';
Car.right = 'RIGHT';
// Render relative to car...
Car.prototype.render = function(ctx) {
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(this.angle);
ctx.drawImage(this.image, -this.width / 2, -this.height / 2);
ctx.restore();
};
Game.update = function (delta) {
var dirx = 0;
var diry = 0;
if (Keyboard.isDown(Keyboard.LEFT)) {
this._rotate(dirx, Car.left)
}
else if (Keyboard.isDown(Keyboard.RIGHT)) {
this._rotate(dirx, Car.right)
}
else if (Keyboard.isDown(Keyboard.UP)) {
this._rotate(dirx, Car.up)
diry = accelerate(diry, Car.forward);
}
else if (Keyboard.isDown(Keyboard.DOWN)) {
this._rotate(dirx, Car.down)
diry = accelerate(diry, Car.backward);
}
else {
decelerate();
}
this.car.move(delta, dirx, diry);
this.camera.update();
};
Game._rotate = function(dirx, direction) {
let angleInDegrees = 0;
switch (direction) {
case 'UP':
angleInDegrees = 0;
break;
case 'RIGHT':
angleInDegrees = 90;
break;
case 'DOWN':
angleInDegrees = 180;
break;
case 'LEFT':
angleInDegrees = 270;
break;
}
this.car.angle = angleInDegrees * (Math.PI / 180);
}
Game.render = function () {
// draw map background layer
this._drawLayer(0);
this.car.render(this.ctx);
// draw map top layer
this._drawLayer(1);
};
Vectors
Here is an example using vectors.
loadImage('https://i.stack.imgur.com/JY7ai.png')
.then(function(img) {
main(img);
})
function main(img) {
let game = new Game({
canvas: document.querySelector('#viewport')
});
let car = new Car({
img: img,
imgRadiansOffset : -Math.PI / 2,
position: new Victor(game.canvas.width, game.canvas.height).divide(new Victor(2, 2)),
velocity: new Victor(0, -1),
showBorder: true
});
game.addLayer(car);
game.start();
}
class Game {
constructor(options) {
Object.assign(this, Game.defaultOptions, options);
if (this.canvas != null) {
Object.assign(this, {
width: this.canvas.width,
height: this.canvas.height
});
this.addListeners();
}
}
addLayer(layer) {
this.layers.push(layer);
layer.parent = this;
}
start() {
this.id = setInterval(() => {
this.render();
}, 1000 / this.rate); // frames per second
}
render() {
let ctx = this.canvas.getContext('2d');
ctx.clearRect(0, 0, this.width, this.height);
this.layers.forEach(layer => layer.render(ctx));
}
addListeners() {
if (this.canvas != null) {
window.addEventListener('keydown', (e) => {
this.handleKeyDown(e.keyCode);
});
window.addEventListener('keyup', (e) => {
this.handleKeyUp(e.keyCode);
});
}
}
handleKeyDown(keyCode) {
this.layers.forEach(layer => {
layer.update(keyCode !== this.lastKeyCode ? keyCode : null);
});
this.lastKeyCode = keyCode;
}
handleKeyUp(keyCode) {
this.layers.forEach(layer => {
layer.update(this.lastKeyCode); // calls reset...
});
}
}
Game.defaultOptions = {
id: null,
rate: 30,
layers: [],
canvas: null,
width: 0,
height: 0
};
class Car {
constructor(options) {
Object.assign(this, Car.defaultOptions, options);
if (this.img != null) {
Object.assign(this, {
width: this.img.width,
height: this.img.height
});
}
}
render(ctx) {
ctx.save();
ctx.translate(this.position.x, this.position.y);
ctx.rotate(this.velocity.angle() - this.imgRadiansOffset);
ctx.drawImage(this.img, -this.width / 2, -this.height / 2, this.width, this.height);
if (this.showBorder) {
ctx.strokeStyle = '#C00';
ctx.setLineDash([4, 8]);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.rect(-this.width / 2, -this.height / 2, this.width, this.height);
ctx.stroke();
}
ctx.restore();
}
update(keyCode) {
if (keyCode != null) this.changeDirection(keyCode);
this.position.add(this.velocity.add(this.acceleration));
this.detectCollision();
}
detectCollision() {
let xMin = this.width / 2, xMax = this.parent.width - xMin;
let yMin = this.height / 2, yMax = this.parent.height - yMin;
if (this.position.x < xMin) this.position.x = xMin;
if (this.position.x > xMax) this.position.x = xMax;
if (this.position.y < yMin) this.position.y = yMin;
if (this.position.y > yMax) this.position.y = yMax;
}
changeDirection(keyCode) {
switch (keyCode) {
case 37:
this.reset(new Victor(-1, 0)); // LEFT
break;
case 38:
this.reset(new Victor(0, -1)); // UP
break;
case 39:
this.reset(new Victor(1, 0)); // RIGHT
break;
case 40:
this.reset(new Victor(0, 1)); // DOWN
break;
}
}
reset(dirVect) {
this.velocity = new Victor(this.speedFactor, this.speedFactor).multiply(dirVect);
this.acceleration = new Victor(this.accelFactor, this.accelFactor).multiply(dirVect);
}
}
Car.defaultOptions = {
position: new Victor(0, 0),
width: 0,
height: 0,
img: null,
imgRadiansOffset: 0,
velocity: new Victor(0, 0),
acceleration: new Victor(0, 0),
showBorder: false,
speedFactor: 3, // Velocity scalar
accelFactor: 1 // Acceleration scalar
};
function loadImage(url) {
return new Promise(function(resolve, reject) {
var img = new Image;
img.onload = function() {
resolve(this)
};
img.onerror = img.onabort = function() {
reject("Error loading image")
};
img.src = url;
})
}
#viewport {
border: thin solid grey;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/victor/1.1.0/victor.min.js"></script>
<canvas id="viewport" width="400" height="160"></canvas>

How to animate multiple HTML5 canvas objects one after another?

I want to make an animation using the HTML5 canvas and JavaScript. The idea is to write classes for different objects, like this:
class Line {
constructor(x1, y1, x2, y2) {
this.x1 = x1;
this.y1 = y2;
...
}
draw() {
}
}
class Circle {
constructor(x, y, radius) {
this.x = x;
...
}
draw() {}
}
...
Then all you would have to do in the main code is to draw the shapes one after another with pauses in between:
let line1 = new Line(x1, y1, x2, y2);
let circle = new Circle(x, y, r);
let line2 = new Line(x1, y1, x2, y2);
line1.draw()
pause()
circle.draw()
pause()
line2.draw()
...
Is there an easy way to this (without having to deal with Promises and nested Callback Functions), for example by using some library?
Key frames
You can use key frames to great effect to animate almost anything.
The example below (was going to do more of a write up but I was too late, you have accepted an answer) shows how a very basic key frame utility can create animations.
A key frame is just a time and a value
Key frames are added to tracks that give a name to the value.
Thus the name x (position) and the keys {time:0, value:100}, {time:1000, value:900} will change the x property from 100 to 900 during the time 0 to 1 second
For example a circle
const circle = {
x: 0,
y: 0,
r: 10,
col : "",
draw() {
ctx.fillStyle = this.col;
ctx.beginPath();
ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
ctx.fill()
}
};
can have any of its properties changed over time.
First create a tracks object and define the keys
const circleTracks = createTracks();
// properties to animate
circleTracks.addTrack("x");
circleTracks.addTrack("y");
circleTracks.addTrack("r");
circleTracks.addTrack("col");
Then add key frames at specific time stamps.
circleTracks.addKeysAtTime(0, {x: 220, y :85, r: 20, col: "#F00"});
circleTracks.addKeysAtTime(1000, {x: 220, y :50, r: 50, col: "#0F0"});
circleTracks.addKeysAtTime(2000, {x: 420, y :100, r: 20, col: "#00F"});
circleTracks.addKeysAtTime(3000, {x: 180, y :160, r: 10, col: "#444"});
circleTracks.addKeysAtTime(4000, {x: 20, y :100, r: 20});
circleTracks.addKeysAtTime(5000, {x: 220, y :85, r: 10, col: "#888"});
circleTracks.addKeysAtTime(5500, {r: 10, col: "#08F"});
circleTracks.addKeysAtTime(6000, {r: 340, col: "#00F"});
When ready clean up the the keys (You can add them out of time order)
circleTracks.clean();
Seek to the start
circleTracks.seek(0);
And update the object
circleTracks.update(circle);
To animate just call the tick and update functions, and draw the circle
circleTracks.tick();
circleTracks.update(circle);
circle.draw();
Example
Click to start the animation.
When it ends you can scrub the animation using tracks.seek(time)
This is the most basic keyframe animations.
And the best thing about key frames is that they separate the animation from the code, letting you import and export animations as simple data structures.
const ctx = canvas.getContext("2d");
requestAnimationFrame(mainLoop);
const allTracks = [];
function addKeyframedObject(tracks, object) {
tracks.clean();
tracks.seek(0);
tracks.update(object);
allTracks.push({tracks, object});
}
const FRAMES_PER_SEC = 60, TICK = 1000 / FRAMES_PER_SEC; //
const key = (time, value) => ({time, value});
var playing = false;
var showScrubber = false;
var currentTime = 0;
function mainLoop() {
ctx.clearRect(0 ,0 ,ctx.canvas.width, ctx.canvas.height);
if(playing) {
for (const animated of allTracks) {
animated.tracks.tick();
animated.tracks.update(animated.object);
}
}
for (const animated of allTracks) {
animated.object.draw();
}
if(showScrubber) {
slide.update();
slide.draw();
if(slide.value !== currentTime) {
currentTime = slide.value;
for (const animated of allTracks) {
animated.tracks.seek(currentTime);
animated.tracks.update(animated.object);
}
}
} else {
if(mouse.button) { playing = true }
}
if(allTracks[0].tracks.time > 6300) {
showScrubber = true
playing = false;
}
requestAnimationFrame(mainLoop);
}
const text = {
x: canvas.width / 2,
y: canvas.height / 2,
alpha: 1,
text: "",
draw() {
ctx.font = "24px arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "#000";
ctx.globalAlpha = this.alpha;
ctx.fillText(this.text, this.x, this.y);
ctx.globalAlpha = 1;
}
}
const circle = {
x: 0,
y: 0,
r: 10,
col : "",
draw() {
ctx.fillStyle = this.col;
ctx.beginPath();
ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
ctx.fill()
}
}
const circleTracks = createTracks();
circleTracks.addTrack("x");
circleTracks.addTrack("y");
circleTracks.addTrack("r");
circleTracks.addTrack("col");
circleTracks.addKeysAtTime(0, {x: 220, y :85, r: 20, col: "#F00"});
circleTracks.addKeysAtTime(1000, {x: 220, y :50, r: 50, col: "#0F0"});
circleTracks.addKeysAtTime(2000, {x: 420, y :100, r: 20, col: "#00F"});
circleTracks.addKeysAtTime(3000, {x: 180, y :160, r: 10, col: "#444"});
circleTracks.addKeysAtTime(4000, {x: 20, y :100, r: 20});
circleTracks.addKeysAtTime(5000, {x: 220, y :85, r: 10, col: "#888"});
circleTracks.addKeysAtTime(5500, {r: 10, col: "#08F"});
circleTracks.addKeysAtTime(6000, {r: 340, col: "#00F"});
addKeyframedObject(circleTracks, circle);
const textTracks = createTracks();
textTracks.addTrack("alpha");
textTracks.addTrack("text");
textTracks.addKeysAtTime(0, {alpha: 1, text: "Click to start"});
textTracks.addKeysAtTime(1, {alpha: 0});
textTracks.addKeysAtTime(20, {alpha: 0, text: "Simple keyframed animation"});
textTracks.addKeysAtTime(1000, {alpha: 1});
textTracks.addKeysAtTime(2000, {alpha: 0});
textTracks.addKeysAtTime(3500, {alpha: 0, text: "The END!" });
textTracks.addKeysAtTime(3500, {alpha: 1});
textTracks.addKeysAtTime(5500, {alpha: 1});
textTracks.addKeysAtTime(6000, {alpha: 0, text: "Use slider to scrub"});
textTracks.addKeysAtTime(6300, {alpha: 1});
addKeyframedObject(textTracks, text);
function createTracks() {
return {
tracks: {},
addTrack(name, keys = [], value) {
this.tracks[name] = {name, keys, idx: -1, value}
},
addKeysAtTime(time, keys) {
for(const name of Object.keys(keys)) {
this.tracks[name].keys.push(key(time, keys[name]));
}
},
clean() {
for(const track of Object.values(this.tracks)) {
track.keys.sort((a,b) => a.time - b.time);
}
},
seek(time) { // seek to random time
this.time = time;
for(const track of Object.values(this.tracks)) {
if (track.keys[0].time > time) {
track.idx = -1; // befor first key
}else {
let idx = 1;
while(idx < track.keys.length) {
if(track.keys[idx].time > time && track.keys[idx-1].time <= time) {
track.idx = idx - 1;
break;
}
idx += 1;
}
}
}
this.tick(0);
},
tick(timeStep = TICK) {
const time = this.time += timeStep;
for(const track of Object.values(this.tracks)) {
if(track.keys[track.idx + 1] && track.keys[track.idx + 1].time <= time) {
track.idx += 1;
}
if(track.idx === -1) {
track.value = track.keys[0].value;
} else {
const k1 = track.keys[track.idx];
const k2 = track.keys[track.idx + 1];
if (typeof k1.value !== "number" || !k2) {
track.value = k1.value;
} else if (k2) {
const unitTime = (time - k1.time) / (k2.time - k1.time);
track.value = (k2.value - k1.value) * unitTime + k1.value;
}
}
}
},
update(obj) {
for(const track of Object.values(this.tracks)) {
obj[track.name] = track.value;
}
}
};
};
const slide = {
min: 0,
max: 6300,
value: 6300,
top: 160,
left: 1,
height: 9,
width: 438,
slide: 10,
slideX: 0,
draw() {
ctx.fillStyle = "#000";
ctx.fillRect(this.left-1, this.top-1, this.width+ 2, this.height+ 2);
ctx.fillStyle = "#888";
ctx.fillRect(this.left, this.top, this.width, this.height);
ctx.fillStyle = "#DDD";
this.slideX = (this.value - this.min) / (this.max - this.min) * (this.width - this.slide) + this.left;
ctx.fillRect(this.slideX, this.top + 1, this.slide, this.height - 2);
},
update() {
if(mouse.x > this.left && mouse.x < this.left + this.width &&
mouse.y > this.top && mouse.y < this.top + this.height) {
if (mouse.button && !this.captured) {
this.captured = true;
} else {
canvas.style.cursor = "ew-resize";
}
}
if (this.captured) {
if (!mouse.button) {
this.captured = false;
canvas.style.cursor = "default";
} else {
this.value = ((mouse.x - this.left) / this.width) * (this.max - this.min) + this.min;
canvas.style.cursor = "none";
this.value = this.value < this.min ? this.min : this.value > this.max ? this.max : this.value;
}
}
}
};
const mouse = {x : 0, y : 0, button : false};
function mouseEvents(e){
const bounds = canvas.getBoundingClientRect();
mouse.x = e.pageX - bounds.left - scrollX;
mouse.y = e.pageY - bounds.top - scrollY;
mouse.button = e.type === "mousedown" ? true : e.type === "mouseup" ? false : mouse.button;
}
["down","up","move"].forEach(name => document.addEventListener("mouse"+name,mouseEvents));
canvas { border: 1px solid black; }
<canvas id="canvas" width="440" height="170"><canvas>
A good question given that what you don't want to do (use promises and/or callbacks) would effectively mean hard coding the animation in script with limited potential for re-use, and possibly creating difficulties in making modifications in the future.
A solution that I've used is to create a story book of functions that draw frames, so you would put
()=>line1.draw()
into the book rather than
line1.draw()
which would draw it immediately and try adding its return value to the book!
The next part (in no particular order) is a player that uses requestAnimationFrame to time stepping through the story book and calling functions to draw the frame. Minimally it would need methods for script to
add a frame drawing function,
add a delay before advancing to the next frame, and
play the animation.
Making the delay function take a number of frames to wait before calling the next entry in the story book keeps it simple, but creates timings based on frame rate which may not be constant.
Here's a simplified example in pure JavaScript that changes background color (not canvas manipulation) for demonstration - have a look for reference if you can't get it working.
"use strict";
class AnimePlayer {
constructor() {
this.storyBook = [];
this.pause = 0;
this.drawFrame = this.drawFrame.bind( this);
this.frameNum = 0;
}
addFrame( frameDrawer) {
this.storyBook.push( frameDrawer);
}
pauseFrames(n) {
this.storyBook.push ( ()=>this.pause = n);
}
play() {
this.frameNum = 0;
this.drawFrame();
}
drawFrame() {
if( this.pause > 0) {
--this.pause;
requestAnimationFrame( this.drawFrame);
}
else if( this.frameNum < this.storyBook.length) {
this.storyBook[this.frameNum]();
++this.frameNum;
requestAnimationFrame( this.drawFrame);
}
}
}
let player = new AnimePlayer();
let style = document.body.style;
player.addFrame( ()=> style.backgroundColor = "green");
player.pauseFrames(60);
player.addFrame( ()=> style.backgroundColor = "yellow");
player.pauseFrames(5);
player.addFrame( ()=>style.backgroundColor = "orange");
player.pauseFrames(60);
player.addFrame( ()=> style.backgroundColor = "red");
player.pauseFrames(60);
player.addFrame( ()=> style.backgroundColor = "");
function tryMe() {
console.clear();
player.play();
}
<button type="button" onclick="tryMe()">try me</button>

How to change the image to another in canvas for some time and then change back again

I am new to game development and I am developing a simple game where I can get a point when I hit the monster by car. So now I want to change the color of the car (if red then blue and vice versa ) when I hit the monster for a while (5 seconds) and then switch back to the original color. I tried with
var myObj = {
imgs: ["http://res.cloudinary.com/dfhppjli0/image/upload/c_scale,w_2048/v1492045665/road_dwsmux.png", "http://res.cloudinary.com/dfhppjli0/image/upload/c_scale,w_32/v1491958999/car_p1k2hw.png","http://res.cloudinary.com/dfhppjli0/image/upload/v1491958478/monster_rsm0po.png","http://res.cloudinary.com/dfhppjli0/image/upload/v1492579967/car_03_ilt08o.png"],
currentImg: 0,
draw: function(){
ctx.drawImage(this.imgs[this.currentImg], this.x, this.y)
},
changeImg: function(index){
this.currentImg = index
}
}
and then draw with
images.__createImage("background","img[0]");
But it did not work.
My working :pen
Any help is appreciated
You were already loading images you need for changing appearance forth and back so that was ok, you don't need to keep url array inside your hero (car) object and loading it again when it is needed.
I have added changeImage property to your heroDefault (car) so I can mark the object that it needs to check if the image need to be changed back, and I also added changeImageTime to heroDefault where I store time after the change has to be done, so when car hits a monster I check changeImage to true and I set changeImageTime to Date.now() + 5000 which means I store current time plus 5 seconds (5000ms):
if (monster.isTouching(this)) {
monster.reset();
monstersCaught += 1;
this.changeImage = true;
this.changeImageTime = Date.now() + 5000; //5 sec from now.
this.image = (this.image === images.hero)? images.hero_other : images.hero;
}
Then inside your heroDefault update function I check if there is need to change image back and if the time has passed, if so I change image back and mark the object so it won't compare time no more by setting changeImage to false.
if(this.changeImage){
if(Date.now() > this.changeImageTime){
this.changeImage = false;
this.image = (this.image === images.hero)? images.hero_other : images.hero;
}
}
// Create the canvas
var canvas = document.createElement("canvas");
var ctx = canvas.getContext("2d");
canvas.width = 2048;
canvas.height = 1024;
document.body.appendChild(canvas);
var monstersCaught = 0;
var lastFrameTime;
var frameTime = 0; // in seconds used to control hero speed
// The main game loop
function main(time) {
if (lastFrameTime !== undefined) {
frameTime = (time - lastFrameTime) / 1000; // in seconds
}
lastFrameTime = time
updateObjects();
render();
requestAnimationFrame(main);
};
// this is called when all the images have loaded
function start() {
monstersCaught = 0;
resetObjs();
requestAnimationFrame(main);
}
function displayStatus(message) {
ctx.setTransform(1, 0, 0, 1, 0, 0); // set default transform
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "black";
ctx.font = "24px Helvetica";
ctx.textAlign = "center";
ctx.textBaseline = "center";
ctx.fillText(message, canvas.width / 2, canvas.height / 2);
}
// reset objects
function resetObjs() {
monsters.array.forEach(monster => monster.reset());
heros.array.forEach(hero => hero.reset());
}
// Update game objects
function updateObjects(modifier) {
monsters.array.forEach(monster => monster.update());
heros.array[0].update('random');
heros.array[1].update('random');
heros.array[2].update('random');
heros.array[3].update('random');
heros.array[4].update('random');
heros.array[5].update('random');
heros.array[6].update('random');
heros.array[7].update('random');
}
function drawObjects(modifier) {
monsters.array.forEach(monster => monster.draw());
heros.array.forEach(hero => hero.draw());
}
// Draw everything
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0); // set default transform
ctx.drawImage(images.background, 0, 0);
drawObjects();
// Score
ctx.setTransform(1, 0, 0, 1, 0, 0); // set default transform
ctx.fillStyle = "rgb(250, 250, 250)";
ctx.font = "24px Helvetica";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText("points : " + monstersCaught, 32, 32);
}
// hold all the images in one object.
const images = { // double underscore __ is to prevent adding images that replace these functions
__status: {
count: 0,
ready: false,
error: false,
},
__onready: null,
__createImage(name, src) {
var image = new Image();
image.src = src;
images.__status.count += 1;
image.onerror = function() {
images.__status.error = true;
displayStatus("Error loading image : '" + name + "'");
}
image.onload = function() {
images.__status.count -= 1;
if (images.__status.count === 0) {
images.__status.ready = true;
images.__onready();
}
if (!images.__status.error) {
displayStatus("Images remaing : " + images.__status.count);
}
}
images[name] = image;
return image;
}
}
// Handle all key input
const keys = { // key input object
ArrowLeft: false, // only add key names you want to listen to
ArrowRight: false,
ArrowDown: false,
ArrowUp: false,
keyEvent(event) {
if (keys[event.code] !== undefined) { // are we interested in this key
keys[event.code] = event.type === "keydown";
event.preventDefault();
}
}
}
// default setting for objects
const objectDefault = {
x: 0,
y: 0,
dir: 0, // the image rotation
isTouching(obj) { // returns true if object is touching box x,y,w,h
return !(this.x > obj.x + obj.w || this.y > obj.y + obj.h || this.x + this.w < obj.x || this.y + this.h < obj.y);
},
draw() {
ctx.setTransform(1, 0, 0, 1, this.x + this.w / 2, this.y + this.h / 2);
ctx.rotate(this.dir);
ctx.drawImage(this.image, -this.image.width / 2, -this.image.height / 2);
},
reset() {},
update() {},
}
// default setting for monster object
const monsterDefault = {
w: 32, // width
h: 32, // height
reset() {
this.x = this.w + (Math.random() * (canvas.width - this.w * 2));
this.y = this.h + (Math.random() * (canvas.height - this.h * 2));
},
}
// default settings for hero
const heroDefault = {
w: 32, // width
h: 32, // height
speed: 256,
spawnPos: 1.5,
distanceTraveledInOneDirection: 0,
autoDirection: 'right',
changeImage: false, // If true, will check if changeImageTime passed and image needs to be changed
changeImageTime: 0, // Time after image will be changed
reset() {
this.x = canvas.width / this.spawnPos;
this.y = canvas.height / this.spawnPos;
this.autoDirection = 'right';
this.distanceTraveledInOneDirection = 0;
},
auto() {
this.y -= this.speed * frameTime;
},
update(dir) {
this.distanceTraveledInOneDirection += this.speed * frameTime;
if (dir === 'random') {
dir = this.autoDirection; //set new direction
if (this.distanceTraveledInOneDirection > 300) { //make this random or use a timestamp instead of distance if you want
this.autoDirection = ['up', 'down', 'right', 'left','right','down','left','up'][Math.floor(Math.random() * 4)]; //we have traveled in one direction long enough, time to roll dice and change direction.
dir = this.autoDirection; //set new direction
this.distanceTraveledInOneDirection = 0;
}
}
if (!dir) {
if (keys.ArrowUp) dir = 'up'
if (keys.ArrowDown) dir = 'down'
if (keys.ArrowLeft) dir = 'left'
if (keys.ArrowRight) dir = 'right'
}
if (dir === 'up') { // Player holding up
this.y -= this.speed * frameTime;
this.dir = Math.PI * 0; // set direction
}
if (dir === 'down') { // Player holding down
this.y += this.speed * frameTime;
this.dir = Math.PI * 1; // set direction
}
if (dir === 'left') { // Player holding left
this.x -= this.speed * frameTime;
this.dir = Math.PI * 1.5; // set direction
}
if (dir === 'right') { // Player holding right
this.x += this.speed * frameTime;
this.dir = Math.PI * 0.5; // set direction
}
if (Math.sign(this.speed) === -1) { // filp directio of second car
this.dir += Math.PI; // set direction
}
monsters.array.forEach(monster => {
if (monster.isTouching(this)) {
monster.reset();
monstersCaught += 1;
this.changeImage = true;
this.changeImageTime = Date.now() + 5000; //5 sec from now.
this.image = (this.image === images.hero)? images.hero_other : images.hero;
}
});
if (this.x >= canvas.width || this.y >= canvas.height || this.y < 0 || this.x < 0) {
this.reset();
}
if(this.changeImage){
if(Date.now() > this.changeImageTime){
this.changeImage = false;
this.image = (this.image === images.hero)? images.hero_other : images.hero;
}
}
}
}
// objects to hold monsters and heros
const monsters = { // dont call a monster "array"
array: [], // copy of monsters as array
};
const heros = { // dont call a monster "array"
array: [], // copy of heros as array
};
// add monster
function createMonster(name, settings = {}) {
monsters[name] = Object.assign({}, objectDefault, monsterDefault, settings, {
name
});
//monsters[name] = {...objectDefault, ...monsterDefault, ...settings, name};
monsters[name].reset();
monsters.array.push(monsters[name]);
return monsters[name];
}
// add hero to heros object
function createHero(name, settings) {
heros[name] = Object.assign({}, objectDefault, heroDefault, settings, {
name
});
//heros[name] = {...objectDefault, ...heroDefault, ...settings, name};
heros[name].reset();
heros.array.push(heros[name]);
return heros[name];
}
// set function to call when all images have loaded
images.__onready = start;
// load all the images
images.__createImage("background", "http://res.cloudinary.com/dfhppjli0/image/upload/c_scale,w_2048/v1492045665/road_dwsmux.png");
images.__createImage("hero", "http://res.cloudinary.com/dfhppjli0/image/upload/c_scale,w_32/v1491958999/car_p1k2hw.png");
images.__createImage("monster", "http://res.cloudinary.com/dfhppjli0/image/upload/v1491958478/monster_rsm0po.png");
images.__createImage("hero_other", "http://res.cloudinary.com/dfhppjli0/image/upload/v1492579967/car_03_ilt08o.png");
// create all objects
createHero("hero", {
image: images.hero,
spawnPos: 1.5
});
createHero("hero3", {
image: images.hero,
spawnPos: 2
});
createHero("hero9", {
image: images.hero_other,
spawnPos: 2.6
});
createHero("hero12", {
image: images.hero,
spawnPos: 1.75
});
createHero("hero15", {
image: images.hero,
spawnPos: 1.8
});
createHero("hero18", {
image: images.hero,
spawnPos: 2.4
});
createHero("hero21", {
image: images.hero_other,
spawnPos: 2.8
});
createHero("hero24", {
image: images.hero,
spawnPos: 1.9
});
createMonster("monster", {
image: images.monster,
});
createMonster("monster3", {
image: images.monster
});
createMonster("monster9", {
image: images.monster
});
createMonster("monster12", {
image: images.monster
});
createMonster("monster15", {
image: images.monster
});
createMonster("monster18", {
image: images.monster
});
// add key listeners
document.addEventListener("keydown", keys.keyEvent);
document.addEventListener("keyup", keys.keyEvent);
canvas.addEventListener('click', function(event) {
createMonster("monster24", {
image: images.monster
});
}, false);
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Simple Canvas Game</title>
</head>
<body>
<script src="game.js"></script>
</body>
</html>

Categories

Resources