diff --git a/common.ts b/common.ts index e6fdea3..7fd27cf 100644 --- a/common.ts +++ b/common.ts @@ -61,8 +61,8 @@ export function cubicBezier(a: Point, b: Point, c: Point, d: Point, res=0.05) { export function resizeCanvas(ctx: CanvasRenderingContext2D) { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); - ctx.canvas.width = window.innerWidth; - ctx.canvas.height = window.innerHeight; + ctx.canvas.width = ctx.canvas.clientWidth; + ctx.canvas.height = ctx.canvas.clientHeight; } export function drawCircle(ctx: CanvasRenderingContext2D, center: Point, radius: number, color: number) { diff --git a/move.ts b/move.ts index ef5a478..acd966b 100644 --- a/move.ts +++ b/move.ts @@ -1,49 +1,80 @@ 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(0, -1).scale(i * SEGMENT_SPACING))); +} + // state 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 pause = false; let mode: "follow" | "bounce" = "bounce"; -let start: number | undefined = undefined; +let lastFrameTime: number | undefined = undefined; function update(ctx: CanvasRenderingContext2D, timestamp: number) { + if (!lastFrameTime) lastFrameTime = timestamp; + const dt = (timestamp - lastFrameTime) / 1000; // seconds + lastFrameTime = timestamp; switch (mode) { - case "bounce": - updateBounce(ctx, timestamp); - break; - case "follow": - updateFollow(ctx); - break; - default: - throw new Error(`Unknown mode: ${mode}`); + case "bounce": updateBounce(ctx, dt); break; + case "follow": updateFollow(ctx, dt); break; + default: throw new Error(`Unknown mode: ${mode}`); } if (!pause) window.requestAnimationFrame(t => update(ctx, t)); } -function updateFollow(ctx: CanvasRenderingContext2D) { - if (target) pos = pos.lerp(target, 0.01); - ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); - drawCircle(ctx, pos, 100, 0xFF00FF); +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: + const alpha = Math.exp(-10 * dt) + trail[0] = trail[0].lerp(target, alpha); + updateTrail(); + } + + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + for (const pos of trail) { + drawCircle(ctx, pos, 20, 0xFF00FF); + } } -function updateBounce(ctx: CanvasRenderingContext2D, timestamp: number) { - if (!start) start = timestamp; - const dt = timestamp - start; +function updateBounce(ctx: CanvasRenderingContext2D, dt: number) { + // update head pos // 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.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; } - //console.log(velocity); + trail[0] = newPos; + updateTrail(); - pos = newPos; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); - drawCircle(ctx, pos, 100, 0xFF00FF); - start = timestamp; + for (const pos of trail) { + 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() { @@ -51,10 +82,8 @@ function init() { 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 = window.innerWidth; - ctx.canvas.height = window.innerHeight; - + ctx.canvas.width = ctx.canvas.clientWidth; + ctx.canvas.height = ctx.canvas.clientHeight; canvas.onmousemove = (evt) => { const {clientX, clientY} = evt; @@ -69,10 +98,8 @@ function init() { } window.onkeydown = (evt) => { - console.log("key down", evt); if (mode === "follow") mode = "bounce"; else mode = "follow" - console.log("mode", mode); } window.addEventListener('resize', () => resizeCanvas(ctx)); diff --git a/snake.ts b/snake.ts index 0f55541..2d504cd 100644 --- a/snake.ts +++ b/snake.ts @@ -1,5 +1,5 @@ import Vec2d from "./vec.js"; -import { fillCircle, lerp, lerpRgb } from "./common.js"; +import { resizeCanvas, fillCircle, lerp, lerpRgb } from "./common.js"; // constants const SNAKE_RADIUS = 10; @@ -27,12 +27,6 @@ let speed = 200; // px/s let interpolation_factor = 10; 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; function drawSnake(ctx: CanvasRenderingContext2D, bounds: Vec2d) { diff --git a/vec.ts b/vec.ts index 97f9345..042d7d7 100644 --- a/vec.ts +++ b/vec.ts @@ -21,6 +21,11 @@ export default class Vec2d implements Point { return `[${this.x}, ${this.y}]`; } + normalize(): Vec2d { + const len = this.length(); + return new Vec2d(this.x / len, this.y / len); + } + scale(scalar: number): Vec2d { return new Vec2d(this.x * scalar, this.y * scalar); }