diff --git a/index.html b/index.html
index 1e6d573..1ecd3d5 100644
--- a/index.html
+++ b/index.html
@@ -13,7 +13,7 @@
}
-
+
diff --git a/package-lock.json b/package-lock.json
index a0b00da..29400ca 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,13 +9,13 @@
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
- "typescript": "^5.7.3"
+ "typescript": "^6.0.2"
}
},
"node_modules/typescript": {
- "version": "5.7.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
- "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
+ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
diff --git a/package.json b/package.json
index 380135d..e8b7a81 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,6 @@
"author": "",
"license": "ISC",
"devDependencies": {
- "typescript": "^5.7.3"
+ "typescript": "^6.0.2"
}
}
diff --git a/snake.css b/snake.css
new file mode 100644
index 0000000..bcc2379
--- /dev/null
+++ b/snake.css
@@ -0,0 +1,37 @@
+@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;700&display=swap');
+
+:root {
+ --bg-color: #0f172a;
+ --canvas-color: #1e293b;
+ --text-color: #f8fafc;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ background-color: var(--bg-color);
+ background-image:
+ radial-gradient(at 0% 0%, rgba(30, 41, 59, 0.5) 0, transparent 50%),
+ radial-gradient(at 100% 100%, rgba(15, 23, 42, 0.8) 0, transparent 50%);
+ font-family: 'Outfit', sans-serif;
+ color: var(--text-color);
+ overflow: hidden;
+}
+
+.canvas-wrapper {
+ width: 90vmin; /* 90% of the smaller viewport dimension */
+ height: 90vmin;
+ aspect-ratio: 1 / 1;
+ background-color: var(--canvas-color);
+ border-radius: 16px;
+}
+
+#game-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
diff --git a/snake.html b/snake.html
new file mode 100644
index 0000000..870479a
--- /dev/null
+++ b/snake.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+ Game
+
+
+
+ Game
+
+
+
+
+
+
diff --git a/snake.ts b/snake.ts
new file mode 100644
index 0000000..fa813a8
--- /dev/null
+++ b/snake.ts
@@ -0,0 +1,188 @@
+class Vec2d {
+ x: number;
+ y: number;
+
+ constructor(x: number, y: number) {
+ this.x = x;
+ this.y = y;
+ }
+
+ toString() {
+ return `[${this.x}, ${this.y}]`;
+ }
+
+ scale(scalar: number): Vec2d {
+ return new Vec2d(this.x * scalar, this.y * scalar);
+ }
+
+ add(other: Vec2d): Vec2d {
+ return new Vec2d(this.x + other.x, this.y + other.y);
+ }
+
+ sub(other: Vec2d): Vec2d {
+ return new Vec2d(this.x - other.x, this.y - other.y);
+ }
+
+ length(): number {
+ return Math.sqrt(this.x * this.x + this.y * this.y);
+ }
+
+ distance(other: Vec2d): number {
+ return this.sub(other).length();
+ }
+
+ lerp(other: Vec2d, t: number): Vec2d {
+ // (1-t)*A + B*t
+ return this.scale(1 - t).add(other.scale(t));
+ }
+
+ clamp(min: Vec2d, max: Vec2d): Vec2d {
+ return new Vec2d(
+ Math.min(Math.max(this.x, min.x), max.x),
+ Math.min(Math.max(this.y, min.y), max.y)
+ );
+ }
+
+ wrap(bounds: Vec2d) {
+ return new Vec2d(trueMod(this.x, bounds.x), trueMod(this.y, bounds.y));
+ }
+
+}
+
+function trueMod(v: number, max: number): number {
+ return ((v % max) + max) % max;
+}
+
+// constants
+const SNAKE_SIZE = 20;
+const SNAKE_PADDING = 10;
+const SEGMENT_SPACING = SNAKE_SIZE + SNAKE_PADDING;
+const HEAD_COLOR = 0xf43f5e;
+const TAIL_COLOR = 0xf4c3cc;
+const APPLE_COLOR = 0x58f474;
+
+// state
+let snake_body = [new Vec2d(100, 100), new Vec2d(100 - SEGMENT_SPACING, 100), new Vec2d(100 - SEGMENT_SPACING * 2, 100)];
+let apple: Vec2d | undefined = undefined;
+let velocity: Vec2d = new Vec2d(1, 0); // unit vector. Start by moving right
+let speed = 200; // px/s
+
+let interpolation_factor = 10;
+
+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);
+}
+
+function drawCircle(ctx: CanvasRenderingContext2D, center: Vec2d, radius: number, color: string) {
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(center.x, center.y, radius, 0, 2 * Math.PI);
+ ctx.fillStyle = color;
+ ctx.fill();
+ ctx.restore();
+}
+
+let lastTime: number | undefined = undefined;
+
+function update(ctx: CanvasRenderingContext2D, t: number) {
+ if (lastTime == null) lastTime = t;
+ const dt = (t - lastTime) / 1000; // s
+ lastTime = t;
+ apple = apple as Vec2d;
+ const bounds = new Vec2d(ctx.canvas.width, ctx.canvas.height);
+
+ // update head position
+ snake_body[0] = snake_body[0].add(velocity.scale(speed * dt))
+ // update body position by following the segment in front
+ for (let i = 1; i < snake_body.length; i++) {
+ const leader = snake_body[i - 1];
+ const delta = leader.sub(snake_body[i]);
+ const dist = delta.length();
+ if (dist > SEGMENT_SPACING) {
+ const unit = delta.scale(1 / dist); // unit vector in direction of delta
+ snake_body[i] = leader.sub(unit.scale(SEGMENT_SPACING));
+ }
+ }
+
+ // collisions
+ // convert snake_head coords to screen space
+ //const head = wrapCoords(snake_body[0], bounds);
+ const head = snake_body[0].wrap(bounds)
+ // check if snake head is overlapping apple
+ if (head.distance(apple) < SNAKE_SIZE) {
+ apple = getRandomPos(ctx.canvas.width, ctx.canvas.height);
+ const { x: lastX, y: lastY } = snake_body[snake_body.length - 1];
+ snake_body.push(new Vec2d(lastX, lastY));
+ speed += 10;
+ }
+
+ // draw apple
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+ drawCircle(ctx, apple, SNAKE_SIZE / 2, `#${APPLE_COLOR.toString(16)}`)
+
+ // draw snake
+ for (let i = 0; i < snake_body.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_body[i].lerp(snake_body[i + 1], j / interpolation_factor).wrap(bounds);
+ const color = lerpColor(HEAD_COLOR, TAIL_COLOR, (i + interpolation_factor / 10) / snake_body.length)
+ drawCircle(ctx, pos, SNAKE_SIZE / 2, `#${color.toString(16)}`)
+ }
+ }
+ const tail = snake_body[snake_body.length - 1].wrap(bounds)
+ drawCircle(ctx, tail, SNAKE_SIZE / 2, `#${TAIL_COLOR.toString(16)}`)
+
+ window.requestAnimationFrame((t) => update(ctx, t));
+}
+
+function getRandomPos(maxX: number, maxY: number): Vec2d {
+ return new Vec2d(Math.random() * maxX, Math.random() * maxY)
+}
+
+function lerpColor(a: number, b: number, t: number) {
+ const red = lerp(a >> 16, b >> 16, t)
+ const green = lerp((a >> 8) & 0xff, (b >> 8) & 0xff, t)
+ const blue = lerp(a & 0xff, b & 0xff, t)
+ return (red << 16) | (green << 8) | blue
+}
+
+function lerp(a: number, b: number, t: number) {
+ return (1-t)*a + b*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");
+
+ apple = getRandomPos(canvas.width, canvas.height)
+ resizeCanvas(ctx);
+
+ canvas.onmousemove = () => {};
+
+ canvas.onclick = () => {};
+
+ window.onkeydown = (evt) => {
+ let new_direction: Vec2d | undefined;
+ switch (evt.key) {
+ case "z": new_direction = new Vec2d(0, -1); break;
+ case "s": new_direction = new Vec2d(0, 1); break;
+ case "q": new_direction = new Vec2d(-1, 0); break;
+ case "d": new_direction = new Vec2d(1, 0); break;
+ case "j": interpolation_factor++; break;
+ case "k": interpolation_factor = Math.max(1, interpolation_factor-1); break;
+ }
+ console.log(`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; // no 180° turns
+ velocity = new_direction;
+ };
+
+ window.requestAnimationFrame((t) => update(ctx, t));
+}
+
+init();