move: implement follow the leader algorithm

This commit is contained in:
2026-08-05 19:40:07 +02:00
parent 4ad9e76c9e
commit 63d5236924
4 changed files with 63 additions and 37 deletions
+2 -2
View File
@@ -61,8 +61,8 @@ export function cubicBezier(a: Point, b: Point, c: Point, d: Point, res=0.05) {
export function resizeCanvas(ctx: CanvasRenderingContext2D) { export function resizeCanvas(ctx: CanvasRenderingContext2D) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.canvas.width = window.innerWidth; ctx.canvas.width = ctx.canvas.clientWidth;
ctx.canvas.height = window.innerHeight; ctx.canvas.height = ctx.canvas.clientHeight;
} }
export function drawCircle(ctx: CanvasRenderingContext2D, center: Point, radius: number, color: number) { export function drawCircle(ctx: CanvasRenderingContext2D, center: Point, radius: number, color: number) {
+55 -28
View File
@@ -1,49 +1,80 @@
import Vec2d from "./vec.js"; import Vec2d from "./vec.js";
import { drawCircle, resizeCanvas } from "./common.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(0, -1).scale(i * SEGMENT_SPACING)));
}
// state // state
let target: Vec2d | undefined = undefined; let target: Vec2d | undefined = undefined;
let pos = new Vec2d(200, 200); let trail = initTrail(new Vec2d(200, 200), 5);
let velocity = new Vec2d(500, 500); let velocity = new Vec2d(500, 500);
let pause = false; let pause = false;
let mode: "follow" | "bounce" = "bounce"; let mode: "follow" | "bounce" = "bounce";
let start: number | undefined = undefined; let lastFrameTime: number | undefined = undefined;
function update(ctx: CanvasRenderingContext2D, timestamp: number) { function update(ctx: CanvasRenderingContext2D, timestamp: number) {
if (!lastFrameTime) lastFrameTime = timestamp;
const dt = (timestamp - lastFrameTime) / 1000; // seconds
lastFrameTime = timestamp;
switch (mode) { switch (mode) {
case "bounce": case "bounce": updateBounce(ctx, dt); break;
updateBounce(ctx, timestamp); case "follow": updateFollow(ctx, dt); break;
break; default: throw new Error(`Unknown mode: ${mode}`);
case "follow":
updateFollow(ctx);
break;
default:
throw new Error(`Unknown mode: ${mode}`);
} }
if (!pause) window.requestAnimationFrame(t => update(ctx, t)); if (!pause) window.requestAnimationFrame(t => update(ctx, t));
} }
function updateFollow(ctx: CanvasRenderingContext2D) { function updateFollow(ctx: CanvasRenderingContext2D, dt: number) {
if (target) pos = pos.lerp(target, 0.01); if (target) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); // update head position towards the mouse cursor
drawCircle(ctx, pos, 100, 0xFF00FF); // 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:
const alpha = Math.exp(-10 * dt)
trail[0] = trail[0].lerp(target, alpha);
updateTrail();
} }
function updateBounce(ctx: CanvasRenderingContext2D, timestamp: number) { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
if (!start) start = timestamp; for (const pos of trail) {
const dt = timestamp - start; drawCircle(ctx, pos, 20, 0xFF00FF);
}
}
function updateBounce(ctx: CanvasRenderingContext2D, dt: number) {
// update head pos
// P_t+1 = P_t + V * t // P_t+1 = P_t + V * t
const newPos = pos.add(velocity.scale(0.001*dt)); const newPos = trail[0].add(velocity.scale(0.001*dt));
if (newPos.x > ctx.canvas.width - 100) { velocity.x *= -1; newPos.x = ctx.canvas.width - 100; } 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.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.x < 100) { velocity.x *= -1; newPos.x = 100; }
if (newPos.y < 100) { velocity.y *= -1; newPos.y = 100; } if (newPos.y < 100) { velocity.y *= -1; newPos.y = 100; }
//console.log(velocity); trail[0] = newPos;
updateTrail();
pos = newPos;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
drawCircle(ctx, pos, 100, 0xFF00FF); for (const pos of trail) {
start = timestamp; drawCircle(ctx, pos, 20, 0xFF00FF);
}
}
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() { function init() {
@@ -51,10 +82,8 @@ function init() {
if (!canvas) throw new Error("unable to get canvas HTML element"); if (!canvas) throw new Error("unable to get canvas HTML element");
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D | null; const ctx = canvas.getContext("2d") as CanvasRenderingContext2D | null;
if (!ctx) throw new Error("unable to get canvas 2D context"); if (!ctx) throw new Error("unable to get canvas 2D context");
ctx.canvas.width = ctx.canvas.clientWidth;
ctx.canvas.width = window.innerWidth; ctx.canvas.height = ctx.canvas.clientHeight;
ctx.canvas.height = window.innerHeight;
canvas.onmousemove = (evt) => { canvas.onmousemove = (evt) => {
const {clientX, clientY} = evt; const {clientX, clientY} = evt;
@@ -69,10 +98,8 @@ function init() {
} }
window.onkeydown = (evt) => { window.onkeydown = (evt) => {
console.log("key down", evt);
if (mode === "follow") mode = "bounce"; if (mode === "follow") mode = "bounce";
else mode = "follow" else mode = "follow"
console.log("mode", mode);
} }
window.addEventListener('resize', () => resizeCanvas(ctx)); window.addEventListener('resize', () => resizeCanvas(ctx));
+1 -7
View File
@@ -1,5 +1,5 @@
import Vec2d from "./vec.js"; import Vec2d from "./vec.js";
import { fillCircle, lerp, lerpRgb } from "./common.js"; import { resizeCanvas, fillCircle, lerp, lerpRgb } from "./common.js";
// constants // constants
const SNAKE_RADIUS = 10; const SNAKE_RADIUS = 10;
@@ -27,12 +27,6 @@ let speed = 200; // px/s
let interpolation_factor = 10; let interpolation_factor = 10;
let paused = false; let paused = false;
function resizeCanvas(ctx: CanvasRenderingContext2D) {
ctx.canvas.width = ctx.canvas.clientWidth;
ctx.canvas.height = ctx.canvas.clientHeight;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
let lastTime: number | undefined = undefined; let lastTime: number | undefined = undefined;
function drawSnake(ctx: CanvasRenderingContext2D, bounds: Vec2d) { function drawSnake(ctx: CanvasRenderingContext2D, bounds: Vec2d) {
+5
View File
@@ -21,6 +21,11 @@ export default class Vec2d implements Point {
return `[${this.x}, ${this.y}]`; return `[${this.x}, ${this.y}]`;
} }
normalize(): Vec2d {
const len = this.length();
return new Vec2d(this.x / len, this.y / len);
}
scale(scalar: number): Vec2d { scale(scalar: number): Vec2d {
return new Vec2d(this.x * scalar, this.y * scalar); return new Vec2d(this.x * scalar, this.y * scalar);
} }