Files
js-canvas-playground/move.ts
T
2026-08-15 13:39:47 +02:00

119 lines
4.0 KiB
TypeScript

import Vec2d from "./vec.js";
import { drawCircle, resizeCanvas } from "./common.js";
const SEGMENT_SPACING = 100;
function initTrail(pos: Vec2d, len: number) {
return Array.from({ length: len }, (_, i) => pos.add(new Vec2d(-1, 0).scale(i * SEGMENT_SPACING)));
}
// state
let target: Vec2d | undefined = undefined;
let trail = initTrail(new Vec2d(500, 500), 5);
let velocity = new Vec2d(500, 500);
let pause = false;
let mode: "follow" | "bounce" | "circle" = "follow";
let lastFrameTime: number | undefined = undefined;
function update(ctx: CanvasRenderingContext2D, timestamp: number) {
if (!lastFrameTime) lastFrameTime = timestamp;
const dt = (timestamp - lastFrameTime) / 1000; // seconds
lastFrameTime = timestamp;
if (!pause) {
switch (mode) {
case "bounce": updateBounce(ctx, dt); break;
case "follow": updateFollow(ctx, dt); break;
case "circle": updateCircle(ctx, dt); break;
default: throw new Error(`Unknown mode: ${mode}`);
}
}
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
for (const pos of trail) {
drawCircle(ctx, pos, 20, 0xFF00FF);
}
window.requestAnimationFrame(t => update(ctx, t));
}
function updateFollow(ctx: CanvasRenderingContext2D, dt: number) {
if (target) {
// update head position towards the mouse cursor
// using lerp with a factor of 0.1 moves 10% towards the target at each
// frame, so this is framerate dependent:
// trail[0] = trail[0].lerp(target, 0.1);
// use exponential decay instead, this is framerate independent:
// a = a + (b - a) * t lerp formula
// a = a + (b - a) * e^(-k * dt) substitute t with e^(-k * dt)
const alpha = Math.exp(-10 * dt)
trail[0] = trail[0].lerp(target, alpha);
updateTrail();
}
}
function updateBounce(ctx: CanvasRenderingContext2D, dt: number) {
// update head pos
// P_t+1 = P_t + V * t
const newPos = trail[0].add(velocity.scale(dt));
if (newPos.x > ctx.canvas.width - 100) { velocity.x *= -1; newPos.x = ctx.canvas.width - 100; }
if (newPos.y > ctx.canvas.height - 100) { velocity.y *= -1; newPos.y = ctx.canvas.height - 100; }
if (newPos.x < 100) { velocity.x *= -1; newPos.x = 100; }
if (newPos.y < 100) { velocity.y *= -1; newPos.y = 100; }
trail[0] = newPos;
updateTrail();
}
function updateCircle(ctx: CanvasRenderingContext2D, dt: number) {
const center = new Vec2d(ctx.canvas.width / 2, ctx.canvas.height / 2);
const rel = trail[0].sub(center); // distance from center
const { r, theta } = rel.toPolar();
trail[0] = center.add(Vec2d.fromPolar(r, theta + dt));
updateTrail();
}
function updateTrail() {
// "follow the leader" algorithm: keep segments at a fixed distance
// from each other by moving each segment towards the segment in front of it.
for (let i = 1; i < trail.length; i++) {
const leader = trail[i - 1];
const delta = leader.sub(trail[i]);
const dist = delta.length();
if (dist > SEGMENT_SPACING) {
const remainder = dist - SEGMENT_SPACING; // excess distance to be covered
const p = remainder / dist; // [0, 1]
trail[i] = trail[i].lerp(leader, p);
}
}
}
function init() {
const canvas = document.getElementById("canvas") as HTMLCanvasElement | null;
if (!canvas) throw new Error("unable to get canvas HTML element");
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D | null;
if (!ctx) throw new Error("unable to get canvas 2D context");
ctx.canvas.width = ctx.canvas.clientWidth;
ctx.canvas.height = ctx.canvas.clientHeight;
canvas.onmousemove = (evt) => {
const {clientX, clientY} = evt;
target = new Vec2d(clientX, clientY);
}
canvas.onclick = () => {
pause = !pause;
}
window.onkeydown = (evt) => {
if (mode === "bounce") mode = "follow";
else if (mode === "follow") mode = "circle";
else if (mode === "circle") mode = "bounce";
console.log(mode);
}
window.addEventListener('resize', () => resizeCanvas(ctx));
window.requestAnimationFrame(t => update(ctx, t));
}
init();