159 lines
5.3 KiB
TypeScript
159 lines
5.3 KiB
TypeScript
import Vec2d from "./vec.js";
|
|
import { resizeCanvas, fillCircle, lerp, lerpRgb } from "./common.js";
|
|
|
|
// constants
|
|
const SNAKE_RADIUS = 10;
|
|
const SNAKE_PADDING = 5;
|
|
const SEGMENT_SPACING = SNAKE_RADIUS * 2 + SNAKE_PADDING;
|
|
const HEAD_COLOR = 0xf43f5e;
|
|
const TAIL_COLOR = 0xf4c3cc;
|
|
const APPLE_COLOR = 0x58f474;
|
|
|
|
const DIRECTIONS = {
|
|
up: new Vec2d(0, -1),
|
|
down: new Vec2d(0, 1),
|
|
left: new Vec2d(-1, 0),
|
|
right: new Vec2d(1, 0)
|
|
}
|
|
|
|
function initSnake(pos: Vec2d, len: number) {
|
|
return Array.from({ length: len }, (_, i) => pos.add(DIRECTIONS.left.scale(i * SEGMENT_SPACING)));
|
|
}
|
|
// state
|
|
let bounds: Vec2d = new Vec2d(0, 0);
|
|
let snake = initSnake(new Vec2d(200, 200), 5);
|
|
let apple: Vec2d | undefined = undefined;
|
|
let velocity: Vec2d = new Vec2d(0, 0); // unit vector
|
|
let speed = 200; // px/s
|
|
let interpolation_factor = 10;
|
|
let paused = false;
|
|
|
|
let lastTime: number | undefined = undefined;
|
|
|
|
function drawSnake(ctx: CanvasRenderingContext2D, bounds: Vec2d) {
|
|
for (let i = 0; i < snake.length - 1; ++i) {
|
|
// draw a trail of overlapping circles by interpolating between the body positions
|
|
for (let j = 0; j < interpolation_factor; ++j) {
|
|
const pos = snake[i].lerp(snake[i + 1], j / interpolation_factor).wrap(bounds);
|
|
const t = (i + j / interpolation_factor) / snake.length;
|
|
const color = lerpRgb(HEAD_COLOR, TAIL_COLOR, t);
|
|
const size = lerp(SNAKE_RADIUS, SNAKE_RADIUS / 2, t);
|
|
fillCircle(ctx, pos, size, color)
|
|
}
|
|
}
|
|
const tail = snake[snake.length - 1].wrap(bounds)
|
|
fillCircle(ctx, tail, SNAKE_RADIUS / 2, TAIL_COLOR)
|
|
}
|
|
|
|
function drawSnakeTube(ctx: CanvasRenderingContext2D, pts: Vec2d[]) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(pts[0].x, pts[0].y);
|
|
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
|
ctx.strokeStyle = "#f43f5e";
|
|
ctx.lineWidth = SNAKE_RADIUS * 2;
|
|
ctx.lineCap = "round";
|
|
ctx.lineJoin = "round";
|
|
ctx.stroke();
|
|
}
|
|
|
|
function randomApple(bounds: Vec2d) {
|
|
return Vec2d.random(bounds.x - 2 * SNAKE_RADIUS + SNAKE_PADDING, bounds.y - 2 * SNAKE_RADIUS + SNAKE_PADDING);
|
|
}
|
|
|
|
function update(ctx: CanvasRenderingContext2D, time: number) {
|
|
if (lastTime == null) lastTime = time;
|
|
const dt = (time - lastTime) / 1000; // s
|
|
lastTime = time;
|
|
apple = apple as Vec2d;
|
|
|
|
if (!paused) {
|
|
// update head position
|
|
snake[0] = snake[0].add(velocity.scale(speed * dt))
|
|
// update body position by following the segment in front
|
|
for (let i = 1; i < snake.length; i++) {
|
|
const leader = snake[i - 1];
|
|
const delta = leader.sub(snake[i]);
|
|
const dist = delta.length();
|
|
if (dist > SEGMENT_SPACING) {
|
|
const unit = delta.scale(1 / dist); // unit vector in direction of delta
|
|
const target = leader.sub(unit.scale(SEGMENT_SPACING));
|
|
snake[i] = target;
|
|
}
|
|
}
|
|
|
|
// collisions
|
|
// convert snake_head coords to screen space
|
|
const head = snake[0].wrap(bounds)
|
|
if (head.distance(apple) < SNAKE_RADIUS * 2) {
|
|
apple = Vec2d.random(bounds.x, bounds.y)
|
|
const { x: lastX, y: lastY } = snake[snake.length - 1];
|
|
snake.push(new Vec2d(lastX, lastY));
|
|
speed += 10;
|
|
setStatusLine(`speed = ${speed}`)
|
|
}
|
|
}
|
|
|
|
// 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, bounds);
|
|
|
|
window.requestAnimationFrame((t) => update(ctx, t));
|
|
}
|
|
|
|
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);
|
|
bounds = new Vec2d(ctx.canvas.width, ctx.canvas.height)
|
|
|
|
apple = randomApple(bounds);
|
|
|
|
canvas.onmousemove = () => {};
|
|
|
|
canvas.onclick = () => {};
|
|
|
|
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 "j": interpolation_factor++; break;
|
|
case "k": interpolation_factor = Math.max(1, interpolation_factor-1); break;
|
|
case "Space": case "p":
|
|
paused = !paused;
|
|
setStatusLine(paused ? "paused" : "", null);
|
|
break;
|
|
}
|
|
if (evt.key === "k" || evt.key === "j") setStatusLine(`interpolation factor: ${interpolation_factor}`)
|
|
if (!new_direction) return;
|
|
if (new_direction.x === -velocity.x && new_direction.y === -velocity.y) return; // no 180° turns
|
|
if (new_direction.x === velocity.x && new_direction.y === velocity.y) return;
|
|
velocity = new_direction;
|
|
};
|
|
|
|
window.requestAnimationFrame((t) => update(ctx, t));
|
|
}
|
|
|
|
function setStatusLine(text: string, timeout_ms: number | null = 2000) {
|
|
const status_line = document.getElementById("status-line");
|
|
if (!status_line) {
|
|
throw new Error("unable to get status line HTML element");
|
|
}
|
|
status_line.innerText = text;
|
|
|
|
if (timeout_ms != null) {
|
|
setTimeout(() => {
|
|
status_line.innerText = "";
|
|
}, timeout_ms);
|
|
}
|
|
}
|
|
|
|
init();
|