diff --git a/index.html b/index.html index d542743..6b924f0 100644 --- a/index.html +++ b/index.html @@ -24,7 +24,7 @@
  • Game of life
  • Ray
  • Snake
  • -
  • Snake (trail)
  • +
  • Snake (trail based)
  • diff --git a/snake2.html b/snake2.html new file mode 100644 index 0000000..2a8a8f0 --- /dev/null +++ b/snake2.html @@ -0,0 +1,16 @@ + + + + + + Snake (trail) + + + +
    +
    + +
    + + + \ No newline at end of file diff --git a/snake2.ts b/snake2.ts new file mode 100644 index 0000000..f80a6cd --- /dev/null +++ b/snake2.ts @@ -0,0 +1,143 @@ +import Vec2d from "./vec.js"; +import { fillCircle, lerp, resizeCanvas, rgbToString } from "./common.js"; + +const HEAD_COLOR = 0xf43f5e; +const APPLE_COLOR = 0x58f474; +const SNAKE_RADIUS = 10; +const TRAIL_SPACING = 2; +const GROWTH_RATE = 10; // px per apple +const INITIAL_BODY_LENGTH = 40; // px of visible body at startup + +const DIRECTIONS = { + up: new Vec2d(0, -1), + down: new Vec2d(0, 1), + left: new Vec2d(-1, 0), + right: new Vec2d(1, 0) +} + +// state +let apple: Vec2d | undefined; +let head: Vec2d = new Vec2d(200, 200); +let trail: Vec2d[] = []; // history of head positions +let bodyLength = INITIAL_BODY_LENGTH; // px of snake body +let direction: Vec2d = DIRECTIONS.right; // unit vector +let speed = 200; // px/s + +let lastTime: number | undefined; +let paused = false; + +function update(ctx: CanvasRenderingContext2D, time: number) { + if (lastTime == null) lastTime = time; + const dt = (time - lastTime) / 1000; // s + lastTime = time; + const bounds = new Vec2d(ctx.canvas.width, ctx.canvas.height); + // update state + if (!paused) { + // move the head + head = head.add(direction.scale(speed * dt)); + + // record the trail (history of head positions) + const last_head = trail[0]; + if (last_head.distance(head) > 0.5) trail.unshift(head) + + // collisions + if (head.wrap(bounds).distance(apple!) < SNAKE_RADIUS * 2) { + apple = randomApple(bounds); + bodyLength += GROWTH_RATE; + speed += 10; + } + + trimTrail(); + } + + // drawing + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + const t = (Math.sin(0.01 * time) + 1.0) * 0.5; + const apple_size = lerp(SNAKE_RADIUS, SNAKE_RADIUS * 0.9, t) + fillCircle(ctx, apple!, apple_size, APPLE_COLOR); // draw apple + drawSnake(ctx); + + window.requestAnimationFrame((t) => update(ctx, t)); +} + +function drawSnake(ctx: CanvasRenderingContext2D) { + const bounds = new Vec2d(ctx.canvas.width, ctx.canvas.height); + ctx.strokeStyle = rgbToString(HEAD_COLOR); + ctx.lineWidth = SNAKE_RADIUS * 2; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + ctx.beginPath(); + let prev = head.wrap(bounds); + ctx.moveTo(prev.x, prev.y); + let acc = 0; + for (let i = 0; i < trail.length - 1; ++i) { + const len = trail[i].distance(trail[i + 1]); + acc += len; + const pos = trail[i + 1].wrap(bounds); + const isWrapped = prev && (Math.abs(pos.x - prev.x) > bounds.x / 2 || Math.abs(pos.y - prev.y) > bounds.y / 2); + if (isWrapped) { + ctx.stroke(); + ctx.beginPath(); + } + ctx.lineTo(pos.x, pos.y); + prev = pos; + + if (acc >= bodyLength) { ctx.stroke(); return; } + } + ctx.stroke(); +} + +function trimTrail() { + const trail_margin = 50; // px of slack to keep in the trail + let remaining = bodyLength + trail_margin; + let cut = trail.length; + for (let i = 0; i < trail.length - 1; ++i) { + const seg = trail[i].distance(trail[i + 1]); + if (seg > remaining) { + cut = i + 1; + break; + } + remaining -= seg; + } + if (cut < trail.length) trail.length = cut; +} + +function randomApple(bounds: Vec2d) { + return Vec2d.random(bounds.x - 2 * SNAKE_RADIUS, bounds.y - 2 * SNAKE_RADIUS); +} + +function init() { + const canvas = document.getElementById("game-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"); + resizeCanvas(ctx); + + const bounds = new Vec2d(canvas.width, canvas.height); + apple = randomApple(bounds); + + // seed the trail behind the head so the body is visible immediately + for (let d = 0; d < INITIAL_BODY_LENGTH; d += TRAIL_SPACING) { + trail.push(new Vec2d(head.x - d, head.y)); + } + + window.onkeydown = (evt) => { + let new_direction: Vec2d | undefined; + switch (evt.key) { + case "ArrowUp": case "z": new_direction = DIRECTIONS.up; break; + case "ArrowDown": case "s": new_direction = DIRECTIONS.down; break; + case "ArrowLeft": case "q": new_direction = DIRECTIONS.left; break; + case "ArrowRight": case "d": new_direction = DIRECTIONS.right; break; + case "p": paused = !paused; break; + } + if (!new_direction) return; + if (new_direction.x === -direction.x && new_direction.y === -direction.y) return; + if (new_direction.x === direction.x && new_direction.y === direction.y) return; + direction = new_direction; + }; + + window.requestAnimationFrame((t) => update(ctx, t)); +} + +init();