Animation
Animation is best handled with the system time or page time with a single time variable holding the start time of the animation, and the current animation position being found by subtracting the start time from the current time.
Iterative animation
This will mean that the way you animate has to change as iterative animations do not lend themselves to conditional branching as you have done with...
// Code from OP's question
function theRect() {
ctx.fillStyle = "lightblue";
ctx.fillRect(x, y, w, h);
x += dx;
if (x + w > canvas.width / 2 || x < 0) {
dx = -dx;
}
}
The above works well if timing is not important, but you can not find the position at any particular point in time as you need to iterate the positions in between. Also any frames dropped will change the speed of the animated object and over time the position becomes indeterminate in respect to time.
Time and controllers.
The alternative is to use controllers to animate. Controllers are just functions the produce shaped output depending on a input. This is how most CSS animation is done, with controllers such as easeIn, easeOut, and dozens more. Animation controls can be fixed time (only play once over a fixed amount of time), they have a start time and an end time, or they can be cyclic having a fixed period. Controllers can also easily integrate keyframed animation.
Controllers are very powerful and should be in every game/animation coders knowledge set.
Cyclic triangle waveform
For this animation a cyclic triangle controller will suit best to mimic the animation you have.
The above image is from Desmos calculator and shows the triangle function over time (x axis is time). All controllers have a frequency of 1 and an amplitude of 1.
The controller as a Javascript function
// waveforms are cyclic to the second and return a normalised range 0-1
const waveforms = {
triangle (time) { return Math.abs(2 * (time - Math.floor(time + 0.5))) },
}
Now you can get the position of the rectangle as a function of time. All you need is the the start time, the current time, the speed in pixels of the object, and the distance it travels.
I have created an object to define the rectangle and animation parameters
const rect = {
startTime : -1, // start time if -1 means start time is unknown
speed : 60, // in pixels per second
travelDist : canvas.width / 2 - 20,
colour : "#8F8",
w : 20,
h : 20,
y : 100,
setStart(time){
this.startTime = time / 1000; // convert to seconds
this.travelDist = (canvas.width / 2) - this.w;
},
draw(time){
var pos = ((time / 1000) - this.startTime); // get animation position in seconds
pos = (pos * this.speed) / (this.travelDist * 2)
var x = waveforms.triangle(pos) * this.travelDist;
ctx.fillStyle = this.colour;
ctx.fillRect(x,this.y,this.w,this.h);
}
}
So once the start time is set you can find the position of the rectangle at any time by just providing the current time
This is a simple animation and the position of the rectangle can also be calculated as a function of time.
rect.draw(performance.now()); // assumes start time has been set
Resizing
Resize events can be problematic as they will fire as often as the mouse sample rate which can be many more times than the frame rate. The result is code being run many more times than is necessary, increasing GC hits (due to canvas resizing), reduced mouse capture rates (mouse events are dropped with subsequent events providing the missing data) making the window resize seem sluggish.
Debounced events
The common way to battle this problem has been to use a debounced resize event handler. This is simply means that the actual resize event is delayed by a small amount of time. If in the debounce period another resize event is fired the current scheduled resize is canceled and another is scheduled.
var debounceTimer; // handle to timeout
const debounceTime = 50; // in ms set to a few frames
addEventListener("resize",() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(resizeCanvas,debounceTime);
}
function resizeCanvas(){
canvas.width = innerWidth;
canvas.height = innerHeight;
}
This ensures that the canvas resizes waits before resizing give subsequent resize event a chance to happen before resizing.
Though this is better than directly handling the resize event it is still a little problematic as the delay is noticeable with the canvas not resizing until after the next frame or two.
Frame synced resize
The best solution I have found is to sync the resize to the animation frames. You can just have the resize event set a flag to indicate that the window has resized. Then in the animation main loop you monitor the flag, if true then resize. This means that the resize is synced with the display rate, and extra resize events are ignored. It also sets a better start time for animations (synced to the frame time)
var windowResized = true; // flag a resize at start so that its starts
// all the stuff needed at startup
addEventListener("resize",() => {windowResized = true}); // needs the {}
function resizeWindow(time){
windowResized = false; // clear the flag
canvas.width = innerWidth;
canvas.height = innerHeight;
rect.setStart(time); // reset the animation to start at current time;
}
Then in the main loop you just check for the windowResized
flag and if true call resizeWindow
Animation time
When you use the requestAnimationFrame(callback) function the callback is called with the first argument as time. Time is in ms 1/1000th of a second and accurate to microseconds 1/1,000,000th second. Eg (time > 1000.010) is time since load over 1.00001 seconds.
function mainLoop(time){ // time in ms since page load.
requestAnimationFrame(mainLoop);
}
requestAnimationFrame(mainLoop);
Example
Thus with all that let's rewrite the animation using controllers and synced resize events.
To highlight the usefulness of timed animations and controllers I have added two extra waveforms sin
and sinTri
Also I have the original animation that will show how it slowly goes out of sync. All for animation should once every cycle be at the left edge of the canvas. Animation speed is set as a movement speed in pixels per second to match the original approximation of 1 pixel per frame the speeds are set to 60 pixels per second. Though for the sin, and sinTri animation that speed is the average speed, not the instantaneous speed.
requestAnimationFrame(animate); //Starts when ready
var ctx = canvas.getContext("2d"); // get canvas 2D API
// original animation by OP
var x = 0;
var y = 20;
var h = 20;
var w = 20;
var dx = 1;
// Code from OP's question
function theRect() {
ctx.fillStyle = "lightblue";
ctx.fillRect(x, y, w, h);
x += dx;
if (x + w > canvas.width / 2 || x < 0) {
dx = -dx;
}
}
// Alternative solution
// wave forms are cyclic to the second and return a normalised range 0-1
const waveforms = {
triangle (time) { return Math.abs(2 * (time - Math.floor(time + 0.5))) },
sin(time) { return Math.cos((time + 0.5) * Math.PI * 2)*0.5 + 0.5 },
// just for fun the following is a composite of triangle and sin
// Keeping the output range to 0-1 means that multiplying any controller
// with another will always keep the value in the range 0-1
triSin(time) { return waveforms.triangle(time) * waveforms.sin(time) }
}
// object to animate is easier to manage and replicate
const rect1 = {
controller : waveforms.triangle,
startTime : -1, // start time if -1 means start time is unknown
speed : 60, // in pixels per second
travelDist : canvas.width / 2 - 20,
colour : "#8F8",
w : 20,
h : 20,
y : 60,
setStart(time){
this.startTime = time / 1000; // convert to seconds
this.travelDist = (canvas.width / 2) - this.w;
},
draw(time){
var pos = ((time / 1000) - this.startTime); // get animation position in seconds
pos = (pos * this.speed) / (this.travelDist * 2)
var x = this.controller(pos) * this.travelDist;
ctx.fillStyle = this.colour;
ctx.fillRect(x,this.y,this.w,this.h);
}
}
// create a second object that uses a sin wave controller
const rect2 = Object.assign({},rect1,{
colour : "#0C0",
controller : waveforms.sin,
y : 100,
})
const rect3 = Object.assign({},rect1,{
colour : "#F80",
controller : waveforms.triSin,
y : 140,
})
// very simple window resize event, just sets flag.
addEventListener("resize",() => { windowResized = true });
var windowResized = true; // will resize canvas on first frame
function resizeCanvas(time){
canvas.width = innerWidth; // set canvas size to window inner size
canvas.height = innerHeight;
rect1.setStart(time); // restart animation
rect2.setStart(time); // restart animation
rect3.setStart(time); // restart animation
x = 0; // restart the stepped animation
// fix for stack overflow title bar getting in the way
y = canvas.height - 4 * 40;
rect1.y = canvas.height - 3 * 40;
rect2.y = canvas.height - 2 * 40;
rect3.y = canvas.height - 1 * 40;
windowResized = false; // clear the flag
// resizing the canvas will reset the 2d context so
// need to setup state
ctx.font = "16px arial";
}
function drawText(text,y){
ctx.fillStyle = "black";
ctx.fillText(text,10,y);
}
function animate(time){ // high resolution time since page load in millisecond with presision to microseconds
if(windowResized){ // has the window been resize
resizeCanvas(time); // yes than resize for next frame
}
ctx.clearRect(0,0,canvas.width,canvas.height);
drawText("Original animation.",y-5);
theRect(); // render original animation
// three example animation using different waveforms
drawText("Timed triangle wave.",rect1.y-5);
rect1.draw(time); // render using timed animation
drawText("Timed sin wave.",rect2.y-5);
rect2.draw(time); // render using timed animation
drawText("Timed sin*triangle wave.",rect3.y-5);
rect3.draw(time); // render using timed animation
ctx.lineWidth = 2;
ctx.strokeRect(1,1,canvas.width - 2,canvas.height - 2);
requestAnimationFrame(animate);
}
canvas {
position : absolute;
top : 0px;
left : 0px;
}
<canvas id="canvas"></canvas>