Inital commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
public/js/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# slither.ts 🐍
|
||||||
|
|
||||||
|
A full **slither.io clone** written in TypeScript — authoritative Node.js + WebSocket
|
||||||
|
server, Canvas 2D client, zero asset files (every texture is generated at runtime,
|
||||||
|
every sound is synthesized with the Web Audio API).
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open **http://localhost:8080** in your browser. Open more tabs (or other
|
||||||
|
machines on your LAN) for more human players — the arena is automatically
|
||||||
|
filled to 12 snakes with AI bots.
|
||||||
|
|
||||||
|
For development: `npm run dev` builds and starts the server.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Smooth movement** — 30 Hz authoritative simulation, 15 Hz snapshots,
|
||||||
|
client-side dead reckoning (the client runs the same movement model every
|
||||||
|
frame) with decaying snapshot-correction offsets, plus dt-based exponential
|
||||||
|
camera smoothing
|
||||||
|
- **Grow by eating glowing pellets** scattered across the arena
|
||||||
|
- **Boost** with `SPACE` (or hold left mouse / second touch finger) — moves
|
||||||
|
~2× faster while burning your length and shedding pellets from your tail
|
||||||
|
- **Eliminations** — make others crash into your body; you cannot collide with
|
||||||
|
yourself
|
||||||
|
- **Death explosions** — dead snakes burst into glowing pellets along their
|
||||||
|
whole body for everyone else to harvest
|
||||||
|
- **Live leaderboard** (top 10, updates in real time) + score / rank / kills HUD
|
||||||
|
- **Instant respawning** — click or hit `Enter`, no countdown
|
||||||
|
- **Large circular arena** with a glowing red border that kills on contact
|
||||||
|
- **Minimap** showing the entire map, all players and your position
|
||||||
|
- **Slither.io-style background** — dark grey gradient, repeating hexagon grid,
|
||||||
|
faint glowing hexagon outlines, soft ambient light + vignette
|
||||||
|
- **AI bots** with pellet-seeking, obstacle avoidance and hunting behaviour
|
||||||
|
- **Main menu** with nickname entry, **settings** (volume, quality, minimap),
|
||||||
|
and **fullscreen** support — all persisted to localStorage
|
||||||
|
- **Sound effects** (eat, boost whoosh, death explosion, UI clicks) and
|
||||||
|
**particle effects** (eat sparkles, boost bubbles, death bursts)
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
| ---------------- | ------------------------------- |
|
||||||
|
| Mouse / touch | Steer (snake follows cursor) |
|
||||||
|
| `SPACE` / click | Boost (drains length) |
|
||||||
|
| `Enter` | Instant respawn when dead |
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
shared/ constants + protocol shared by client & server
|
||||||
|
server/ world simulation (movement, collisions, bots) + HTTP/WS server
|
||||||
|
client/src/ renderer, background, minimap, particles, audio, UI, input
|
||||||
|
public/ index.html, style.css and the compiled client JS
|
||||||
|
scripts/ smoke.mjs — end-to-end WebSocket smoke test
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The server simulates everything at 30 ticks/s and broadcasts compact JSON
|
||||||
|
snapshots at 15 Hz (snake bodies as flat integer arrays; pellets as incremental
|
||||||
|
add/remove deltas after a one-time full list). The client does **not** render
|
||||||
|
the snapshots directly — it runs the same movement model locally every frame
|
||||||
|
(dead reckoning): your own snake is steered straight from your input angle
|
||||||
|
with the server's turn-rate limit (zero perceived input latency), remote
|
||||||
|
snakes use headings estimated from snapshot deltas. Each snapshot captures
|
||||||
|
the prediction error as per-point correction offsets that decay smoothly over
|
||||||
|
the following frames, so server authority is kept without any visible steps.
|
||||||
|
Collision and eating use a uniform-grid spatial hash, so the sim stays fast
|
||||||
|
with a full arena of long snakes.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// All sound effects are synthesized with the Web Audio API — no asset files.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class AudioSys {
|
||||||
|
private ctx: AudioContext | null = null;
|
||||||
|
private master: GainNode | null = null;
|
||||||
|
private noiseBuf: AudioBuffer | null = null;
|
||||||
|
private boostNodes: { src: AudioBufferSourceNode; gain: GainNode } | null = null;
|
||||||
|
private volume = 0.6;
|
||||||
|
private lastEat = 0;
|
||||||
|
|
||||||
|
/** Must be called from a user gesture at least once. */
|
||||||
|
ensure(): void {
|
||||||
|
if (!this.ctx) {
|
||||||
|
const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (!AC) return;
|
||||||
|
this.ctx = new AC();
|
||||||
|
this.master = this.ctx.createGain();
|
||||||
|
this.master.gain.value = this.volume;
|
||||||
|
this.master.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
// 1 second of white noise, reused by several effects
|
||||||
|
const len = this.ctx.sampleRate;
|
||||||
|
this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
|
||||||
|
const d = this.noiseBuf.getChannelData(0);
|
||||||
|
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
|
||||||
|
}
|
||||||
|
if (this.ctx.state === 'suspended') void this.ctx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(v: number): void {
|
||||||
|
this.volume = v;
|
||||||
|
if (this.master && this.ctx) this.master.gain.setTargetAtTime(v, this.ctx.currentTime, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
private env(gain: GainNode, peak: number, attack: number, decay: number): void {
|
||||||
|
const t = this.ctx!.currentTime;
|
||||||
|
gain.gain.setValueAtTime(0.0001, t);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t + attack);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
|
||||||
|
}
|
||||||
|
|
||||||
|
click(): void {
|
||||||
|
if (!this.ctx || !this.master) return;
|
||||||
|
const o = this.ctx.createOscillator();
|
||||||
|
const g = this.ctx.createGain();
|
||||||
|
o.type = 'triangle';
|
||||||
|
o.frequency.value = 340;
|
||||||
|
o.frequency.exponentialRampToValueAtTime(520, this.ctx.currentTime + 0.06);
|
||||||
|
this.env(g, 0.25, 0.005, 0.09);
|
||||||
|
o.connect(g).connect(this.master);
|
||||||
|
o.start();
|
||||||
|
o.stop(this.ctx.currentTime + 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
eat(): void {
|
||||||
|
if (!this.ctx || !this.master) return;
|
||||||
|
// avoid machine-gunning when hoovering many pellets at once
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - this.lastEat < 45) return;
|
||||||
|
this.lastEat = now;
|
||||||
|
|
||||||
|
const f = 480 + Math.random() * 320;
|
||||||
|
const o = this.ctx.createOscillator();
|
||||||
|
const g = this.ctx.createGain();
|
||||||
|
o.type = 'sine';
|
||||||
|
o.frequency.setValueAtTime(f, this.ctx.currentTime);
|
||||||
|
o.frequency.exponentialRampToValueAtTime(f * 1.9, this.ctx.currentTime + 0.07);
|
||||||
|
this.env(g, 0.16, 0.004, 0.09);
|
||||||
|
o.connect(g).connect(this.master);
|
||||||
|
o.start();
|
||||||
|
o.stop(this.ctx.currentTime + 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
death(): void {
|
||||||
|
if (!this.ctx || !this.master || !this.noiseBuf) return;
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
|
||||||
|
// noise burst through a sweeping lowpass
|
||||||
|
const src = this.ctx.createBufferSource();
|
||||||
|
src.buffer = this.noiseBuf;
|
||||||
|
const lp = this.ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass';
|
||||||
|
lp.frequency.setValueAtTime(1400, t);
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(90, t + 0.55);
|
||||||
|
const g = this.ctx.createGain();
|
||||||
|
this.env(g, 0.5, 0.008, 0.6);
|
||||||
|
src.connect(lp).connect(g).connect(this.master);
|
||||||
|
src.start();
|
||||||
|
src.stop(t + 0.7);
|
||||||
|
|
||||||
|
// low "whump"
|
||||||
|
const o = this.ctx.createOscillator();
|
||||||
|
const og = this.ctx.createGain();
|
||||||
|
o.type = 'sine';
|
||||||
|
o.frequency.setValueAtTime(180, t);
|
||||||
|
o.frequency.exponentialRampToValueAtTime(38, t + 0.45);
|
||||||
|
this.env(og, 0.4, 0.005, 0.5);
|
||||||
|
o.connect(og).connect(this.master);
|
||||||
|
o.start();
|
||||||
|
o.stop(t + 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** soft pop when a nearby enemy dies */
|
||||||
|
pop(): void {
|
||||||
|
if (!this.ctx || !this.master) return;
|
||||||
|
const o = this.ctx.createOscillator();
|
||||||
|
const g = this.ctx.createGain();
|
||||||
|
o.type = 'square';
|
||||||
|
o.frequency.setValueAtTime(220, this.ctx.currentTime);
|
||||||
|
o.frequency.exponentialRampToValueAtTime(70, this.ctx.currentTime + 0.12);
|
||||||
|
this.env(g, 0.1, 0.004, 0.14);
|
||||||
|
o.connect(g).connect(this.master);
|
||||||
|
o.start();
|
||||||
|
o.stop(this.ctx.currentTime + 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
respawn(): void {
|
||||||
|
if (!this.ctx || !this.master) return;
|
||||||
|
const o = this.ctx.createOscillator();
|
||||||
|
const g = this.ctx.createGain();
|
||||||
|
o.type = 'triangle';
|
||||||
|
o.frequency.setValueAtTime(240, this.ctx.currentTime);
|
||||||
|
o.frequency.exponentialRampToValueAtTime(760, this.ctx.currentTime + 0.18);
|
||||||
|
this.env(g, 0.2, 0.01, 0.24);
|
||||||
|
o.connect(g).connect(this.master);
|
||||||
|
o.start();
|
||||||
|
o.stop(this.ctx.currentTime + 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** looping whoosh while boosting */
|
||||||
|
boost(on: boolean): void {
|
||||||
|
if (!this.ctx || !this.master || !this.noiseBuf) return;
|
||||||
|
if (on && !this.boostNodes) {
|
||||||
|
const src = this.ctx.createBufferSource();
|
||||||
|
src.buffer = this.noiseBuf;
|
||||||
|
src.loop = true;
|
||||||
|
const bp = this.ctx.createBiquadFilter();
|
||||||
|
bp.type = 'bandpass';
|
||||||
|
bp.frequency.value = 520;
|
||||||
|
bp.Q.value = 0.8;
|
||||||
|
const g = this.ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, this.ctx.currentTime);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.09, this.ctx.currentTime + 0.15);
|
||||||
|
src.connect(bp).connect(g).connect(this.master);
|
||||||
|
src.start();
|
||||||
|
this.boostNodes = { src, gain: g };
|
||||||
|
} else if (!on && this.boostNodes) {
|
||||||
|
const { src, gain } = this.boostNodes;
|
||||||
|
this.boostNodes = null;
|
||||||
|
gain.gain.setTargetAtTime(0.0001, this.ctx.currentTime, 0.08);
|
||||||
|
setTimeout(() => { try { src.stop(); } catch { /* already stopped */ } }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slither.io-style arena background:
|
||||||
|
// - dark grey radial gradient
|
||||||
|
// - subtle repeating hexagon grid pattern spanning the whole map
|
||||||
|
// - a few faint glowing hexagon outlines
|
||||||
|
// - soft ambient center light + vignette for depth
|
||||||
|
// All textures are generated at runtime (no asset files).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const HEX_SIZE = 46; // hexagon "radius" in world px
|
||||||
|
|
||||||
|
export class Background {
|
||||||
|
private hexPattern: CanvasPattern | null = null;
|
||||||
|
private glowHex: HTMLCanvasElement;
|
||||||
|
private gradient: CanvasGradient | null = null;
|
||||||
|
private vignette: CanvasGradient | null = null;
|
||||||
|
private ambient: CanvasGradient | null = null;
|
||||||
|
private gw = 0;
|
||||||
|
private gh = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.glowHex = this.makeGlowHex();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pointy-top hexagon path */
|
||||||
|
private static hexPath(ctx: CanvasRenderingContext2D, cx: number, cy: number, s: number): void {
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const a = (Math.PI / 3) * i - Math.PI / 6;
|
||||||
|
const x = cx + Math.cos(a) * s;
|
||||||
|
const y = cy + Math.sin(a) * s;
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tile containing two staggered hexagon outlines -> seamless hex grid */
|
||||||
|
private makeHexPattern(ctx: CanvasRenderingContext2D): CanvasPattern | null {
|
||||||
|
const s = HEX_SIZE;
|
||||||
|
const w = Math.sqrt(3) * s; // horizontal spacing
|
||||||
|
const h = 1.5 * s; // vertical spacing
|
||||||
|
const tile = document.createElement('canvas');
|
||||||
|
tile.width = Math.ceil(w * 2);
|
||||||
|
tile.height = Math.ceil(h * 2);
|
||||||
|
const t = tile.getContext('2d')!;
|
||||||
|
t.strokeStyle = 'rgba(255,255,255,0.045)';
|
||||||
|
t.lineWidth = 1.5;
|
||||||
|
// draw the hexagons that overlap the tile (with wrap-around copies)
|
||||||
|
const centers: [number, number][] = [
|
||||||
|
[0, 0], [w, 0], [2 * w, 0],
|
||||||
|
[w / 2, h], [1.5 * w, h],
|
||||||
|
[0, 2 * h], [w, 2 * h], [2 * w, 2 * h],
|
||||||
|
[w / 2, -h], [1.5 * w, -h],
|
||||||
|
];
|
||||||
|
for (const [cx, cy] of centers) {
|
||||||
|
Background.hexPath(t, cx, cy, s);
|
||||||
|
t.stroke();
|
||||||
|
}
|
||||||
|
return ctx.createPattern(tile, 'repeat');
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeGlowHex(): HTMLCanvasElement {
|
||||||
|
const s = HEX_SIZE;
|
||||||
|
const pad = 30;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = c.height = Math.ceil(s * 2 + pad * 2);
|
||||||
|
const t = c.getContext('2d')!;
|
||||||
|
t.translate(c.width / 2, c.height / 2);
|
||||||
|
t.strokeStyle = 'rgba(120,190,255,0.85)';
|
||||||
|
t.lineWidth = 2.5;
|
||||||
|
t.shadowColor = 'rgba(90,160,255,0.9)';
|
||||||
|
t.shadowBlur = 16;
|
||||||
|
Background.hexPath(t, 0, 0, s);
|
||||||
|
t.stroke();
|
||||||
|
t.stroke(); // second pass to intensify the glow
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** deterministic pseudo-random in [0,1) for a hex cell */
|
||||||
|
private static hash(col: number, row: number): number {
|
||||||
|
let h = (col * 374761393 + row * 668265263) | 0;
|
||||||
|
h = (h ^ (h >> 13)) * 1274126177;
|
||||||
|
h = h ^ (h >> 16);
|
||||||
|
return (h >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** screen-space gradient layers; rebuilt on resize */
|
||||||
|
resize(ctx: CanvasRenderingContext2D, w: number, h: number): void {
|
||||||
|
this.gw = w; this.gh = h;
|
||||||
|
const cx = w / 2, cy = h / 2;
|
||||||
|
const R = Math.hypot(w, h) / 2;
|
||||||
|
|
||||||
|
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, R);
|
||||||
|
g.addColorStop(0, '#22252c');
|
||||||
|
g.addColorStop(0.55, '#171a1f');
|
||||||
|
g.addColorStop(1, '#0d0f13');
|
||||||
|
this.gradient = g;
|
||||||
|
|
||||||
|
const v = ctx.createRadialGradient(cx, cy, R * 0.35, cx, cy, R);
|
||||||
|
v.addColorStop(0, 'rgba(0,0,0,0)');
|
||||||
|
v.addColorStop(1, 'rgba(0,0,0,0.42)');
|
||||||
|
this.vignette = v;
|
||||||
|
|
||||||
|
const a = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.min(w, h) * 0.55);
|
||||||
|
a.addColorStop(0, 'rgba(130,170,255,0.05)');
|
||||||
|
a.addColorStop(1, 'rgba(130,170,255,0)');
|
||||||
|
this.ambient = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw the background. Called in screen space; this method temporarily
|
||||||
|
* switches to the world transform for the hex grid so it scrolls and zooms
|
||||||
|
* exactly with the world.
|
||||||
|
*/
|
||||||
|
draw(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
w: number, h: number,
|
||||||
|
cam: { x: number; y: number; zoom: number },
|
||||||
|
highQuality: boolean,
|
||||||
|
): void {
|
||||||
|
if (!this.gradient || w !== this.gw || h !== this.gh) this.resize(ctx, w, h);
|
||||||
|
if (!this.hexPattern) this.hexPattern = this.makeHexPattern(ctx);
|
||||||
|
|
||||||
|
// base gradient
|
||||||
|
ctx.fillStyle = this.gradient!;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// hex grid in world space
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(w / 2, h / 2);
|
||||||
|
ctx.scale(cam.zoom, cam.zoom);
|
||||||
|
ctx.translate(-cam.x, -cam.y);
|
||||||
|
const vw = w / cam.zoom, vh = h / cam.zoom;
|
||||||
|
const vx = cam.x - vw / 2, vy = cam.y - vh / 2;
|
||||||
|
ctx.fillStyle = this.hexPattern!;
|
||||||
|
ctx.fillRect(vx - 4, vy - 4, vw + 8, vh + 8);
|
||||||
|
|
||||||
|
// faint glowing hexagon outlines sprinkled deterministically over the map
|
||||||
|
if (highQuality) {
|
||||||
|
const s = HEX_SIZE;
|
||||||
|
const stepX = Math.sqrt(3) * s;
|
||||||
|
const stepY = 1.5 * s;
|
||||||
|
const row0 = Math.floor(vy / stepY) - 1;
|
||||||
|
const row1 = Math.ceil((vy + vh) / stepY) + 1;
|
||||||
|
const sprite = this.glowHex.width / 2;
|
||||||
|
ctx.globalAlpha = 0.10;
|
||||||
|
for (let row = row0; row <= row1; row++) {
|
||||||
|
const offset = (row & 1) ? stepX / 2 : 0;
|
||||||
|
const col0 = Math.floor((vx - offset) / stepX) - 1;
|
||||||
|
const col1 = Math.ceil((vx + vw - offset) / stepX) + 1;
|
||||||
|
for (let col = col0; col <= col1; col++) {
|
||||||
|
if (Background.hash(col, row) < 0.055) {
|
||||||
|
const cx = col * stepX + offset;
|
||||||
|
const cy = row * stepY;
|
||||||
|
ctx.drawImage(this.glowHex, cx - sprite, cy - sprite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// soft ambient light + vignette (screen space)
|
||||||
|
ctx.fillStyle = this.ambient!;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.fillStyle = this.vignette!;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mouse / keyboard / touch input. The snake steers towards the cursor
|
||||||
|
// direction relative to the screen center, slither.io style.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class Input {
|
||||||
|
mouseX = 0;
|
||||||
|
mouseY = 0;
|
||||||
|
spaceDown = false;
|
||||||
|
mouseDown = false;
|
||||||
|
onRespawnKey: () => void = () => {};
|
||||||
|
/** set while the death overlay is up so Enter triggers respawn */
|
||||||
|
dead = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.mouseX = window.innerWidth / 2;
|
||||||
|
this.mouseY = window.innerHeight / 2 - 100; // start heading "up"
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', (e) => {
|
||||||
|
this.mouseX = e.clientX;
|
||||||
|
this.mouseY = e.clientY;
|
||||||
|
});
|
||||||
|
window.addEventListener('mousedown', (e) => {
|
||||||
|
if (e.button === 0 && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLButtonElement)) {
|
||||||
|
this.mouseDown = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('mouseup', () => { this.mouseDown = false; });
|
||||||
|
window.addEventListener('contextmenu', (e) => {
|
||||||
|
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
this.spaceDown = true;
|
||||||
|
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
|
||||||
|
}
|
||||||
|
if (e.code === 'Enter' && this.dead) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.onRespawnKey();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('keyup', (e) => {
|
||||||
|
if (e.code === 'Space') this.spaceDown = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// basic touch support: drag to steer, second finger to boost
|
||||||
|
window.addEventListener('touchstart', (e) => {
|
||||||
|
const t = e.touches[0];
|
||||||
|
this.mouseX = t.clientX; this.mouseY = t.clientY;
|
||||||
|
if (e.touches.length > 1) this.mouseDown = true;
|
||||||
|
}, { passive: true });
|
||||||
|
window.addEventListener('touchmove', (e) => {
|
||||||
|
const t = e.touches[0];
|
||||||
|
this.mouseX = t.clientX; this.mouseY = t.clientY;
|
||||||
|
if (e.cancelable && !(e.target instanceof HTMLInputElement)) e.preventDefault();
|
||||||
|
}, { passive: false });
|
||||||
|
window.addEventListener('touchend', (e) => {
|
||||||
|
if (e.touches.length <= 1) this.mouseDown = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desired heading angle in world space (screen center = snake head). */
|
||||||
|
angle(): number {
|
||||||
|
return Math.atan2(this.mouseY - window.innerHeight / 2, this.mouseX - window.innerWidth / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
boosting(): boolean {
|
||||||
|
return this.spaceDown || this.mouseDown;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Client entry point: networking, camera, HUD, effects, and the render loop.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { Net } from './net.js';
|
||||||
|
import { State, applySnakeSnaps, simulateSnakes } from './state.js';
|
||||||
|
import { Renderer } from './renderer.js';
|
||||||
|
import { Particles } from './particles.js';
|
||||||
|
import { AudioSys } from './audio.js';
|
||||||
|
import { UI } from './ui.js';
|
||||||
|
import { Input } from './input.js';
|
||||||
|
import { Minimap } from './minimap.js';
|
||||||
|
import {
|
||||||
|
SNAKE_COLORS, PELLET_COLORS, snakeRadius, clamp,
|
||||||
|
} from '../../shared/constants.js';
|
||||||
|
import type { GameEvent, InitMsg, SnapMsg } from '../../shared/protocol.js';
|
||||||
|
|
||||||
|
const canvas = document.getElementById('game') as HTMLCanvasElement;
|
||||||
|
const minimapCanvas = document.getElementById('minimap') as HTMLCanvasElement;
|
||||||
|
|
||||||
|
const state = new State();
|
||||||
|
const renderer = new Renderer(canvas);
|
||||||
|
const particles = new Particles();
|
||||||
|
const audio = new AudioSys();
|
||||||
|
const ui = new UI();
|
||||||
|
const input = new Input();
|
||||||
|
const minimap = new Minimap(minimapCanvas);
|
||||||
|
const net = new Net();
|
||||||
|
|
||||||
|
let joined = false; // clicked Play and joined the arena
|
||||||
|
let wasAlive = false;
|
||||||
|
let pendingDeathAt = 0; // show the death overlay shortly after exploding
|
||||||
|
let deathHandled = false;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ networking --
|
||||||
|
|
||||||
|
net.onInit = (m: InitMsg) => {
|
||||||
|
state.myId = m.id;
|
||||||
|
state.arena = m.arena;
|
||||||
|
state.pellets.clear();
|
||||||
|
for (const p of m.pellets) state.pellets.set(p.id, p);
|
||||||
|
joined = true;
|
||||||
|
ui.showGame();
|
||||||
|
};
|
||||||
|
|
||||||
|
net.onSnap = (m: SnapMsg) => {
|
||||||
|
applySnakeSnaps(state, m.snakes);
|
||||||
|
|
||||||
|
for (const p of m.add) state.pellets.set(p.id, p);
|
||||||
|
for (const id of m.rem) handlePelletRemoved(id);
|
||||||
|
|
||||||
|
state.lb = m.lb;
|
||||||
|
state.total = m.total;
|
||||||
|
if (m.you) {
|
||||||
|
const prevAlive = state.you.alive;
|
||||||
|
state.you = m.you;
|
||||||
|
if (m.you.alive && !prevAlive && joined) {
|
||||||
|
// respawned
|
||||||
|
ui.hideDeath();
|
||||||
|
input.dead = false;
|
||||||
|
deathHandled = false;
|
||||||
|
audio.respawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ev of m.ev) handleEvent(ev);
|
||||||
|
};
|
||||||
|
|
||||||
|
net.onClose = () => {
|
||||||
|
if (joined) ui.showError('Connection lost. Please refresh the page.');
|
||||||
|
else if (playRequested) ui.showError('Could not reach the server. Is it running?');
|
||||||
|
};
|
||||||
|
|
||||||
|
function handlePelletRemoved(id: number): void {
|
||||||
|
const p = state.pellets.get(id);
|
||||||
|
if (!p) return;
|
||||||
|
state.pellets.delete(id);
|
||||||
|
const color = PELLET_COLORS[p.c % PELLET_COLORS.length];
|
||||||
|
const head = scratchHead();
|
||||||
|
if (head) {
|
||||||
|
const r = snakeRadius(state.you.score);
|
||||||
|
const d = Math.hypot(p.x - head.x, p.y - head.y);
|
||||||
|
if (d < r + 26) {
|
||||||
|
// we ate it
|
||||||
|
audio.eat();
|
||||||
|
particles.eat(p.x, p.y, color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// someone else ate it near the camera: tiny sparkle for ambience
|
||||||
|
if (Math.hypot(p.x - state.cam.x, p.y - state.cam.y) < 700) {
|
||||||
|
particles.eat(p.x, p.y, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEvent(ev: GameEvent): void {
|
||||||
|
if (ev.t === 'die') {
|
||||||
|
const color = SNAKE_COLORS[ev.c % SNAKE_COLORS.length];
|
||||||
|
// explosion along the whole body
|
||||||
|
for (let i = 0; i < ev.p.length; i += 2) {
|
||||||
|
particles.burst(ev.p[i], ev.p[i + 1], color, 0.45);
|
||||||
|
}
|
||||||
|
particles.burst(ev.x, ev.y, color, 1.4);
|
||||||
|
|
||||||
|
if (ev.id === state.myId) {
|
||||||
|
audio.death();
|
||||||
|
audio.boost(false);
|
||||||
|
state.lastDeathPos = { x: ev.x, y: ev.y };
|
||||||
|
pendingDeathAt = performance.now() + 750;
|
||||||
|
} else if (Math.hypot(ev.x - state.cam.x, ev.y - state.cam.y) < 900) {
|
||||||
|
audio.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ game flow --
|
||||||
|
|
||||||
|
let playRequested = false;
|
||||||
|
|
||||||
|
ui.onPlay = (name) => {
|
||||||
|
playRequested = true;
|
||||||
|
audio.ensure();
|
||||||
|
audio.click();
|
||||||
|
particles.clear();
|
||||||
|
state.snakes.clear();
|
||||||
|
net.send({ t: 'join', name });
|
||||||
|
};
|
||||||
|
|
||||||
|
ui.onRespawn = () => {
|
||||||
|
audio.ensure();
|
||||||
|
audio.click();
|
||||||
|
net.send({ t: 'respawn' });
|
||||||
|
};
|
||||||
|
|
||||||
|
input.onRespawnKey = () => ui.onRespawn();
|
||||||
|
|
||||||
|
ui.onSettingsChanged = () => {
|
||||||
|
audio.setVolume(ui.settings.volume);
|
||||||
|
particles.qualityScale = ui.settings.quality === 'low' ? 0.5 : 1;
|
||||||
|
particles.maxParticles = ui.settings.quality === 'low' ? 350 : 700;
|
||||||
|
};
|
||||||
|
|
||||||
|
// connect right away so Play is instant
|
||||||
|
net.connect();
|
||||||
|
|
||||||
|
// send inputs at ~33 Hz
|
||||||
|
setInterval(() => {
|
||||||
|
if (!joined || !state.you.alive) return;
|
||||||
|
net.send({ t: 'in', a: input.angle(), b: input.boosting() });
|
||||||
|
}, 30);
|
||||||
|
|
||||||
|
function scratchHead(): { x: number; y: number } | null {
|
||||||
|
const me = state.snakes.get(state.myId);
|
||||||
|
if (!me || me.pts.length < 2) return null;
|
||||||
|
return { x: me.pts[0], y: me.pts[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- main loop --
|
||||||
|
|
||||||
|
let last = performance.now();
|
||||||
|
let hudTimer = 0;
|
||||||
|
|
||||||
|
function frame(now: number): void {
|
||||||
|
const dt = Math.min(0.05, (now - last) / 1000);
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
// --- local movement simulation (dead reckoning + server reconciliation) ---
|
||||||
|
simulateSnakes(state, dt, input.angle(), state.you.alive && input.boosting());
|
||||||
|
|
||||||
|
// --- camera ---
|
||||||
|
const head = scratchHead();
|
||||||
|
let tx = state.cam.x, ty = state.cam.y, tz = state.cam.zoom;
|
||||||
|
if (head && state.you.alive) {
|
||||||
|
tx = head.x; ty = head.y;
|
||||||
|
tz = clamp(1.55 - Math.sqrt(Math.max(1, state.you.score)) * 0.011, 0.55, 1.55);
|
||||||
|
if (!state.camInit) {
|
||||||
|
state.cam.x = tx; state.cam.y = ty; state.cam.zoom = tz;
|
||||||
|
state.camInit = true;
|
||||||
|
}
|
||||||
|
} else if (state.lastDeathPos) {
|
||||||
|
tx = state.lastDeathPos.x; ty = state.lastDeathPos.y;
|
||||||
|
tz = 0.55; // zoom out a little while dead
|
||||||
|
}
|
||||||
|
const k = 1 - Math.exp(-6 * dt);
|
||||||
|
state.cam.x += (tx - state.cam.x) * k;
|
||||||
|
state.cam.y += (ty - state.cam.y) * k;
|
||||||
|
state.cam.zoom += (tz - state.cam.zoom) * (1 - Math.exp(-3 * dt));
|
||||||
|
|
||||||
|
// --- effects ---
|
||||||
|
particles.update(dt);
|
||||||
|
|
||||||
|
// boost bubbles at tails of boosting snakes + boost sound for ourselves
|
||||||
|
if (joined) {
|
||||||
|
const meBoost = state.you.alive && input.boosting();
|
||||||
|
for (const s of state.snakes.values()) {
|
||||||
|
const b = s.id === state.myId ? meBoost : s.boosting;
|
||||||
|
if (!b) continue;
|
||||||
|
const n = s.pts.length / 2;
|
||||||
|
if (n < 2) continue;
|
||||||
|
const tx2 = s.pts[(n - 1) * 2], ty2 = s.pts[(n - 1) * 2 + 1];
|
||||||
|
const ang = Math.atan2(ty2 - s.pts[(n - 2) * 2 + 1], tx2 - s.pts[(n - 2) * 2]);
|
||||||
|
particles.boost(tx2, ty2, SNAKE_COLORS[s.colorIdx % SNAKE_COLORS.length], ang);
|
||||||
|
}
|
||||||
|
audio.boost(meBoost && !document.hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- death overlay (slight delay so the explosion is visible) ---
|
||||||
|
if (pendingDeathAt && now >= pendingDeathAt && !state.you.alive && !deathHandled) {
|
||||||
|
pendingDeathAt = 0;
|
||||||
|
deathHandled = true;
|
||||||
|
input.dead = true;
|
||||||
|
ui.showDeath(state.you);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HUD ---
|
||||||
|
hudTimer -= dt;
|
||||||
|
if (joined && hudTimer <= 0) {
|
||||||
|
hudTimer = 0.3;
|
||||||
|
ui.updateHud(state.lb, state.myId, state.you, state.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- render ---
|
||||||
|
renderer.draw(state, now, particles, { quality: ui.settings.quality });
|
||||||
|
if (joined) minimap.draw(state, now, ui.settings.minimap);
|
||||||
|
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
|
||||||
|
// unlock audio on first interaction (browser autoplay policy)
|
||||||
|
window.addEventListener('pointerdown', () => audio.ensure(), { once: true });
|
||||||
|
window.addEventListener('keydown', () => audio.ensure(), { once: true });
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Circular minimap: shows the whole arena, the red border, every snake and
|
||||||
|
// your own highlighted position.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { State } from './state.js';
|
||||||
|
import { SNAKE_COLORS } from '../../shared/constants.js';
|
||||||
|
|
||||||
|
export class Minimap {
|
||||||
|
private ctx: CanvasRenderingContext2D;
|
||||||
|
|
||||||
|
constructor(private canvas: HTMLCanvasElement) {
|
||||||
|
this.ctx = canvas.getContext('2d')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(state: State, now: number, visible: boolean): void {
|
||||||
|
this.canvas.style.display = visible ? 'block' : 'none';
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const W = this.canvas.width, H = this.canvas.height;
|
||||||
|
const cx = W / 2, cy = H / 2;
|
||||||
|
const scale = (W / 2 - 8) / state.arena;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// arena disc + red rim
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, state.arena * scale + 3, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(14,16,20,0.72)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.strokeStyle = 'rgba(255,45,60,0.85)';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
for (const s of state.snakes.values()) {
|
||||||
|
const pts = s.pts;
|
||||||
|
const n = pts.length / 2;
|
||||||
|
if (n < 2) continue;
|
||||||
|
const isMe = s.id === state.myId;
|
||||||
|
const color = SNAKE_COLORS[s.colorIdx % SNAKE_COLORS.length];
|
||||||
|
|
||||||
|
// body polyline (downsampled)
|
||||||
|
const step = Math.max(1, Math.floor(n / 18));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + pts[0] * scale, cy + pts[1] * scale);
|
||||||
|
for (let i = step; i < n; i += step) {
|
||||||
|
ctx.lineTo(cx + pts[i * 2] * scale, cy + pts[i * 2 + 1] * scale);
|
||||||
|
}
|
||||||
|
ctx.lineWidth = isMe ? 2.6 : 1.6;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.globalAlpha = isMe ? 1 : 0.75;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// head dot
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx + pts[0] * scale, cy + pts[1] * scale, isMe ? 3.4 : 2.4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
if (isMe) {
|
||||||
|
ctx.lineWidth = 1.4;
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Thin WebSocket client wrapper.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type { ClientMsg, InitMsg, SnapMsg } from '../../shared/protocol.js';
|
||||||
|
|
||||||
|
export class Net {
|
||||||
|
onInit: (m: InitMsg) => void = () => {};
|
||||||
|
onSnap: (m: SnapMsg) => void = () => {};
|
||||||
|
onOpen: () => void = () => {};
|
||||||
|
onClose: () => void = () => {};
|
||||||
|
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private queue: ClientMsg[] = [];
|
||||||
|
connected = false;
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||||
|
this.ws = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
this.connected = true;
|
||||||
|
for (const m of this.queue) ws.send(JSON.stringify(m));
|
||||||
|
this.queue.length = 0;
|
||||||
|
this.onOpen();
|
||||||
|
};
|
||||||
|
ws.onclose = () => { this.connected = false; this.onClose(); };
|
||||||
|
ws.onerror = () => { /* onclose follows */ };
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
let m: InitMsg | SnapMsg;
|
||||||
|
try { m = JSON.parse(e.data as string); } catch { return; }
|
||||||
|
if (m.t === 'init') this.onInit(m);
|
||||||
|
else if (m.t === 'snap') this.onSnap(m);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
send(m: ClientMsg): void {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(m));
|
||||||
|
} else {
|
||||||
|
this.queue.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lightweight additive-blended particle system (eat sparkles, boost bubbles,
|
||||||
|
// death explosions).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number; y: number;
|
||||||
|
vx: number; vy: number;
|
||||||
|
life: number; maxLife: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
drag: number;
|
||||||
|
shrink: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Particles {
|
||||||
|
private list: Particle[] = [];
|
||||||
|
maxParticles = 700;
|
||||||
|
qualityScale = 1; // 0.5 on low quality
|
||||||
|
|
||||||
|
private push(p: Particle): void {
|
||||||
|
if (this.list.length >= this.maxParticles) this.list.shift();
|
||||||
|
this.list.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** small sparkle when a pellet is eaten */
|
||||||
|
eat(x: number, y: number, color: string): void {
|
||||||
|
const n = Math.max(2, Math.round(5 * this.qualityScale));
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = Math.random() * Math.PI * 2;
|
||||||
|
const sp = 30 + Math.random() * 90;
|
||||||
|
this.push({
|
||||||
|
x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
|
||||||
|
life: 0.3 + Math.random() * 0.2, maxLife: 0.5,
|
||||||
|
size: 1.5 + Math.random() * 2, color, drag: 4, shrink: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bubble shed behind a boosting snake */
|
||||||
|
boost(x: number, y: number, color: string, angle: number): void {
|
||||||
|
const spread = 0.7;
|
||||||
|
const a = angle + Math.PI + (Math.random() - 0.5) * spread;
|
||||||
|
const sp = 40 + Math.random() * 60;
|
||||||
|
this.push({
|
||||||
|
x: x + (Math.random() - 0.5) * 8, y: y + (Math.random() - 0.5) * 8,
|
||||||
|
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
|
||||||
|
life: 0.35 + Math.random() * 0.25, maxLife: 0.6,
|
||||||
|
size: 2 + Math.random() * 2.5, color, drag: 2.5, shrink: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** big explosion when a snake dies */
|
||||||
|
burst(x: number, y: number, color: string, power = 1): void {
|
||||||
|
const n = Math.max(6, Math.round(26 * power * this.qualityScale));
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = Math.random() * Math.PI * 2;
|
||||||
|
const sp = 60 + Math.random() * 240 * power;
|
||||||
|
this.push({
|
||||||
|
x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
|
||||||
|
life: 0.5 + Math.random() * 0.6, maxLife: 1.1,
|
||||||
|
size: 2 + Math.random() * 4 * power, color, drag: 2.2, shrink: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
const l = this.list;
|
||||||
|
for (let i = l.length - 1; i >= 0; i--) {
|
||||||
|
const p = l[i];
|
||||||
|
p.life -= dt;
|
||||||
|
if (p.life <= 0) { l.splice(i, 1); continue; }
|
||||||
|
const d = Math.max(0, 1 - p.drag * dt);
|
||||||
|
p.vx *= d; p.vy *= d;
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** called with the world transform already applied */
|
||||||
|
draw(ctx: CanvasRenderingContext2D): void {
|
||||||
|
if (this.list.length === 0) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = 'lighter';
|
||||||
|
for (const p of this.list) {
|
||||||
|
const t = p.life / p.maxLife;
|
||||||
|
const size = p.shrink ? p.size * (0.3 + 0.7 * t) : p.size;
|
||||||
|
ctx.globalAlpha = Math.min(1, t * 1.6) * 0.9;
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.list.length = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// World renderer: red border ring, glowing pellet sprites, snakes with
|
||||||
|
// tapered striped bodies + eyes + name tags. All sprites generated at runtime.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { State, ClientSnake } from './state.js';
|
||||||
|
import { Background } from './background.js';
|
||||||
|
import { Particles } from './particles.js';
|
||||||
|
import { SNAKE_COLORS, PELLET_COLORS, snakeRadius } from '../../shared/constants.js';
|
||||||
|
|
||||||
|
export interface RenderSettings {
|
||||||
|
quality: 'high' | 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shade(hex: string, amt: number): string {
|
||||||
|
// amt in [-1,1]: negative darkens, positive lightens
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||||||
|
if (amt < 0) { r *= 1 + amt; g *= 1 + amt; b *= 1 + amt; }
|
||||||
|
else { r += (255 - r) * amt; g += (255 - g) * amt; b += (255 - b) * amt; }
|
||||||
|
return `rgb(${r | 0},${g | 0},${b | 0})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Renderer {
|
||||||
|
private ctx: CanvasRenderingContext2D;
|
||||||
|
private bg = new Background();
|
||||||
|
private dpr = 1;
|
||||||
|
private w = 0;
|
||||||
|
private h = 0;
|
||||||
|
private pelletSprites: HTMLCanvasElement[] = [];
|
||||||
|
|
||||||
|
constructor(private canvas: HTMLCanvasElement) {
|
||||||
|
this.ctx = canvas.getContext('2d')!;
|
||||||
|
for (const color of PELLET_COLORS) this.pelletSprites.push(this.makePelletSprite(color));
|
||||||
|
this.resize();
|
||||||
|
window.addEventListener('resize', () => this.resize());
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(): void {
|
||||||
|
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
this.w = window.innerWidth;
|
||||||
|
this.h = window.innerHeight;
|
||||||
|
this.canvas.width = Math.round(this.w * this.dpr);
|
||||||
|
this.canvas.height = Math.round(this.h * this.dpr);
|
||||||
|
this.canvas.style.width = `${this.w}px`;
|
||||||
|
this.canvas.style.height = `${this.h}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private makePelletSprite(color: string): HTMLCanvasElement {
|
||||||
|
const S = 64;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = c.height = S;
|
||||||
|
const t = c.getContext('2d')!;
|
||||||
|
const g = t.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
|
||||||
|
g.addColorStop(0, 'rgba(255,255,255,0.95)');
|
||||||
|
g.addColorStop(0.18, color);
|
||||||
|
g.addColorStop(0.42, color + 'aa');
|
||||||
|
g.addColorStop(1, color + '00');
|
||||||
|
t.fillStyle = g;
|
||||||
|
t.fillRect(0, 0, S, S);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginWorld(cam: { x: number; y: number; zoom: number }): void {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(this.w / 2, this.h / 2);
|
||||||
|
ctx.scale(cam.zoom, cam.zoom);
|
||||||
|
ctx.translate(-cam.x, -cam.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(state: State, now: number, particles: Particles, settings: RenderSettings): void {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const high = settings.quality === 'high';
|
||||||
|
const cam = state.cam;
|
||||||
|
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||||
|
|
||||||
|
this.bg.draw(ctx, this.w, this.h, cam, high);
|
||||||
|
|
||||||
|
this.beginWorld(cam);
|
||||||
|
const vw = this.w / cam.zoom, vh = this.h / cam.zoom;
|
||||||
|
const vx = cam.x - vw / 2, vy = cam.y - vh / 2;
|
||||||
|
|
||||||
|
this.drawArenaEdge(state, vx, vy, vw, vh, high);
|
||||||
|
this.drawPellets(state, now, vx, vy, vw, vh);
|
||||||
|
this.drawSnakes(state, now, vx, vy, vw, vh, high);
|
||||||
|
particles.draw(ctx);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- border --
|
||||||
|
|
||||||
|
private drawArenaEdge(state: State, vx: number, vy: number, vw: number, vh: number, high: boolean): void {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const R = state.arena;
|
||||||
|
|
||||||
|
// darken everything outside the arena
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(vx - 8, vy - 8, vw + 16, vh + 16);
|
||||||
|
ctx.arc(0, 0, R, 0, Math.PI * 2, true);
|
||||||
|
ctx.fillStyle = 'rgba(30, 6, 10, 0.62)';
|
||||||
|
ctx.fill('evenodd');
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// the red kill-ring
|
||||||
|
ctx.save();
|
||||||
|
if (high) { ctx.shadowColor = 'rgba(255,45,60,0.9)'; ctx.shadowBlur = 26; }
|
||||||
|
ctx.strokeStyle = 'rgba(255,45,60,0.95)';
|
||||||
|
ctx.lineWidth = 7;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, R, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.strokeStyle = 'rgba(255,80,90,0.22)';
|
||||||
|
ctx.lineWidth = 42;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, R + 14, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ pellets --
|
||||||
|
|
||||||
|
private drawPellets(state: State, now: number, vx: number, vy: number, vw: number, vh: number): void {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const pulse = now * 0.004;
|
||||||
|
for (const p of state.pellets.values()) {
|
||||||
|
if (p.x < vx - 30 || p.x > vx + vw + 30 || p.y < vy - 30 || p.y > vy + vh + 30) continue;
|
||||||
|
const r = 2.5 + p.v * 0.9;
|
||||||
|
const scale = (1 + 0.13 * Math.sin(pulse + p.id * 1.7)) * (r / 14);
|
||||||
|
const half = 32 * scale;
|
||||||
|
ctx.drawImage(this.pelletSprites[p.c % this.pelletSprites.length], p.x - half, p.y - half, half * 2, half * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- snakes --
|
||||||
|
|
||||||
|
private drawSnakes(state: State, now: number, vx: number, vy: number, vw: number, vh: number, high: boolean): void {
|
||||||
|
// draw smaller snakes first so bigger ones overlap them
|
||||||
|
const list = [...state.snakes.values()].sort((a, b) => a.score - b.score);
|
||||||
|
for (const s of list) {
|
||||||
|
const pts = s.pts;
|
||||||
|
const n = pts.length / 2;
|
||||||
|
if (n < 2) continue;
|
||||||
|
|
||||||
|
// view culling via bounding box
|
||||||
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = pts[i * 2], y = pts[i * 2 + 1];
|
||||||
|
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
const r = snakeRadius(s.score);
|
||||||
|
if (maxX < vx - r - 60 || minX > vx + vw + r + 60 || maxY < vy - r - 60 || minY > vy + vh + r + 60) continue;
|
||||||
|
|
||||||
|
this.drawSnake(s, pts, n, r, state, now, high);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawSnake(s: ClientSnake, pts: number[], n: number, r: number, state: State, now: number, high: boolean): void {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const base = SNAKE_COLORS[s.colorIdx % SNAKE_COLORS.length];
|
||||||
|
const dark = shade(base, -0.38);
|
||||||
|
const ghost = s.sp;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
if (ghost) ctx.globalAlpha = 0.45 + 0.15 * Math.sin(now * 0.02);
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
|
// soft glow underlay
|
||||||
|
if (high) {
|
||||||
|
ctx.strokeStyle = base;
|
||||||
|
ctx.globalAlpha = (ghost ? 0.25 : 0.16);
|
||||||
|
ctx.lineWidth = r * 2 + 7;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0], pts[1]);
|
||||||
|
for (let i = 1; i < n; i++) ctx.lineTo(pts[i * 2], pts[i * 2 + 1]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = ghost ? 0.45 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// striped tapered body, tail -> head
|
||||||
|
const stripe = 3;
|
||||||
|
for (let i = n - 1; i > 0; i--) {
|
||||||
|
const fromTail = n - 1 - i;
|
||||||
|
const taper = Math.min(1, 0.4 + fromTail / (Math.max(6, n * 0.28)));
|
||||||
|
ctx.lineWidth = Math.max(1, r * 2 * taper);
|
||||||
|
ctx.strokeStyle = ((i / stripe) | 0) % 2 === 0 ? base : dark;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[i * 2], pts[i * 2 + 1]);
|
||||||
|
ctx.lineTo(pts[(i - 1) * 2], pts[(i - 1) * 2 + 1]);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// head
|
||||||
|
const hx = pts[0], hy = pts[1];
|
||||||
|
const angle = Math.atan2(hy - pts[3], hx - pts[2]);
|
||||||
|
ctx.fillStyle = base;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(hx, hy, r * 1.12, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// eyes (perpendicular pair, pupils look along the heading)
|
||||||
|
const ex = Math.cos(angle), ey = Math.sin(angle);
|
||||||
|
const pxv = -ey, pyv = ex;
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const exx = hx + pxv * side * r * 0.55 + ex * r * 0.28;
|
||||||
|
const eyy = hy + pyv * side * r * 0.55 + ey * r * 0.28;
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(exx, eyy, r * 0.42, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#14161c';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(exx + ex * r * 0.18, eyy + ey * r * 0.18, r * 0.22, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// name tag + score
|
||||||
|
if (r * state.cam.zoom > 4.5) {
|
||||||
|
ctx.font = `600 ${Math.max(9, r * 0.95)}px 'Segoe UI', sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'bottom';
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.75)';
|
||||||
|
ctx.shadowColor = 'rgba(0,0,0,0.8)';
|
||||||
|
ctx.shadowBlur = 4;
|
||||||
|
const label = s.id === state.myId ? `${s.name} (you)` : s.name;
|
||||||
|
ctx.fillText(label, hx, hy - r * 1.7);
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Client-side world state + local movement simulation.
|
||||||
|
//
|
||||||
|
// Rendering raw snapshots (even interpolated) looks jittery at 15 Hz, so the
|
||||||
|
// client runs the SAME movement model as the server every render frame
|
||||||
|
// (dead reckoning) and only softly reconciles toward the latest snapshot.
|
||||||
|
// Your own snake is steered with your local input angle = zero input latency.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import {
|
||||||
|
BASE_SPEED, BOOST_MULT,
|
||||||
|
snakeSpacing, snakePointCount, normAngle,
|
||||||
|
} from '../../shared/constants.js';
|
||||||
|
import type { PelletData, SnakeSnap, LeaderboardRow, YouState } from '../../shared/protocol.js';
|
||||||
|
|
||||||
|
/** If the local sim drifts further than this from the server, hard-reset. */
|
||||||
|
const RESET_DIST = 140;
|
||||||
|
/** Per-second decay rate of snapshot correction offsets (smaller = smoother). */
|
||||||
|
const CORRECT_DECAY = 8;
|
||||||
|
|
||||||
|
export interface ClientSnake {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
colorIdx: number;
|
||||||
|
score: number;
|
||||||
|
kills: number;
|
||||||
|
boosting: boolean;
|
||||||
|
sp: boolean;
|
||||||
|
pts: number[]; // flat float points [x0,y0,...], simulated every frame
|
||||||
|
heading: number; // current simulated heading
|
||||||
|
estHeading: number; // heading estimated from snapshots (remote snakes)
|
||||||
|
corr: number[]; // decaying per-point correction offsets from snapshots
|
||||||
|
snapHeadX: number; // previous snapshot head (for heading estimation)
|
||||||
|
snapHeadY: number;
|
||||||
|
targetLen: number; // target point count
|
||||||
|
}
|
||||||
|
|
||||||
|
export class State {
|
||||||
|
myId = -1;
|
||||||
|
arena = 4200;
|
||||||
|
snakes = new Map<number, ClientSnake>();
|
||||||
|
pellets = new Map<number, PelletData>();
|
||||||
|
lb: LeaderboardRow[] = [];
|
||||||
|
you: YouState = { score: 0, rank: 0, alive: false, kills: 0, best: 0 };
|
||||||
|
total = 0;
|
||||||
|
cam = { x: 0, y: 0, zoom: 1.35 };
|
||||||
|
camInit = false;
|
||||||
|
lastDeathPos: { x: number; y: number } | null = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approximate direction of travel from the first few body points. */
|
||||||
|
function bodyHeading(p: number[]): number {
|
||||||
|
const k = Math.max(1, Math.min(3, p.length / 2 - 1));
|
||||||
|
return Math.atan2(p[1] - p[k * 2 + 1], p[0] - p[k * 2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge a snapshot's snake list into the local simulated state. */
|
||||||
|
export function applySnakeSnaps(state: State, snaps: SnakeSnap[]): void {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
for (const snap of snaps) {
|
||||||
|
seen.add(snap.id);
|
||||||
|
const n = snap.p.length / 2;
|
||||||
|
const s = state.snakes.get(snap.id);
|
||||||
|
|
||||||
|
if (!s) {
|
||||||
|
const h = bodyHeading(snap.p);
|
||||||
|
state.snakes.set(snap.id, {
|
||||||
|
id: snap.id, name: snap.n, colorIdx: snap.c, score: snap.s, kills: snap.k,
|
||||||
|
boosting: snap.b, sp: !!snap.sp,
|
||||||
|
pts: snap.p.slice(), heading: h, estHeading: h,
|
||||||
|
corr: new Array(snap.p.length).fill(0),
|
||||||
|
snapHeadX: snap.p[0], snapHeadY: snap.p[1],
|
||||||
|
targetLen: n,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// heading estimate from server head movement since the last snapshot
|
||||||
|
const dx = snap.p[0] - s.snapHeadX;
|
||||||
|
const dy = snap.p[1] - s.snapHeadY;
|
||||||
|
const moved = Math.hypot(dx, dy);
|
||||||
|
s.snapHeadX = snap.p[0];
|
||||||
|
s.snapHeadY = snap.p[1];
|
||||||
|
|
||||||
|
const headErr = Math.hypot(s.pts[0] - snap.p[0], s.pts[1] - snap.p[1]);
|
||||||
|
if (headErr > RESET_DIST) {
|
||||||
|
// respawn / heavy desync: hard reset to the server state
|
||||||
|
s.pts = snap.p.slice();
|
||||||
|
s.corr = new Array(snap.p.length).fill(0);
|
||||||
|
s.heading = bodyHeading(snap.p);
|
||||||
|
s.estHeading = s.heading;
|
||||||
|
if (snap.id === state.myId) state.camInit = false; // snap camera too
|
||||||
|
} else {
|
||||||
|
if (moved > 4 && snap.id !== state.myId) {
|
||||||
|
const est = Math.atan2(dy, dx);
|
||||||
|
s.estHeading += normAngle(est - s.estHeading) * 0.5;
|
||||||
|
}
|
||||||
|
// capture the current prediction error as decaying correction offsets;
|
||||||
|
// they are spread smoothly over the next few frames by simulateSnakes
|
||||||
|
const m = Math.min(s.pts.length, snap.p.length) / 2;
|
||||||
|
if (s.corr.length !== s.pts.length) s.corr = new Array(s.pts.length).fill(0);
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
s.corr[i * 2] = snap.p[i * 2] - s.pts[i * 2];
|
||||||
|
s.corr[i * 2 + 1] = snap.p[i * 2 + 1] - s.pts[i * 2 + 1];
|
||||||
|
}
|
||||||
|
for (let i = m * 2; i < s.corr.length; i++) s.corr[i] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
s.targetLen = n;
|
||||||
|
s.name = snap.n;
|
||||||
|
s.colorIdx = snap.c;
|
||||||
|
s.score = snap.s;
|
||||||
|
s.kills = snap.k;
|
||||||
|
s.boosting = snap.b;
|
||||||
|
s.sp = !!snap.sp;
|
||||||
|
}
|
||||||
|
for (const id of [...state.snakes.keys()]) {
|
||||||
|
if (!seen.has(id)) state.snakes.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance every snake by one render frame using the same movement model as
|
||||||
|
* the server, then apply decaying snapshot-correction offsets.
|
||||||
|
*/
|
||||||
|
export function simulateSnakes(state: State, dt: number, myAngle: number, myBoost: boolean): void {
|
||||||
|
const ck = 1 - Math.exp(-CORRECT_DECAY * dt);
|
||||||
|
|
||||||
|
for (const s of state.snakes.values()) {
|
||||||
|
const n = s.pts.length / 2;
|
||||||
|
if (n < 2) continue;
|
||||||
|
const isMe = s.id === state.myId;
|
||||||
|
const boost = isMe ? myBoost : s.boosting;
|
||||||
|
|
||||||
|
// own snake faces the mouse instantly — no turn-rate lag
|
||||||
|
if (isMe) {
|
||||||
|
s.heading = myAngle;
|
||||||
|
} else {
|
||||||
|
s.heading += normAngle(s.estHeading - s.heading) * (1 - Math.exp(-8 * dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// advance the head
|
||||||
|
const speed = BASE_SPEED * (boost ? BOOST_MULT : 1);
|
||||||
|
s.pts[0] += Math.cos(s.heading) * speed * dt;
|
||||||
|
s.pts[1] += Math.sin(s.heading) * speed * dt;
|
||||||
|
|
||||||
|
// pull the body chain along at fixed spacing (same as server)
|
||||||
|
const sp = snakeSpacing(s.score);
|
||||||
|
for (let i = 1; i < n; i++) {
|
||||||
|
const ax = s.pts[(i - 1) * 2], ay = s.pts[(i - 1) * 2 + 1];
|
||||||
|
const bx = s.pts[i * 2], by = s.pts[i * 2 + 1];
|
||||||
|
const dx = bx - ax, dy = by - ay;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d > sp) {
|
||||||
|
const f = (d - sp) / d;
|
||||||
|
s.pts[i * 2] = bx - dx * f;
|
||||||
|
s.pts[i * 2 + 1] = by - dy * f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// grow / shrink toward the server-reported length
|
||||||
|
if (n > s.targetLen) {
|
||||||
|
s.pts.length -= Math.min(n - s.targetLen, 3) * 2;
|
||||||
|
} else if (n < s.targetLen) {
|
||||||
|
s.pts.push(s.pts[(n - 1) * 2], s.pts[(n - 1) * 2 + 1]);
|
||||||
|
}
|
||||||
|
if (s.corr.length !== s.pts.length) {
|
||||||
|
const old = s.corr;
|
||||||
|
s.corr = new Array(s.pts.length).fill(0);
|
||||||
|
for (let i = 0; i < Math.min(old.length, s.corr.length); i++) s.corr[i] = old[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply a slice of the decaying correction offsets (smooth, no steps)
|
||||||
|
let anyCorr = false;
|
||||||
|
for (let i = 0; i < s.corr.length; i++) if (s.corr[i] !== 0) { anyCorr = true; break; }
|
||||||
|
if (anyCorr) {
|
||||||
|
for (let i = 0; i < s.pts.length; i++) {
|
||||||
|
const c = s.corr[i];
|
||||||
|
if (c !== 0) {
|
||||||
|
const d = c * ck;
|
||||||
|
s.pts[i] += d;
|
||||||
|
s.corr[i] = c - d;
|
||||||
|
if (s.corr[i] > -0.001 && s.corr[i] < 0.001) s.corr[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DOM UI: main menu, settings, HUD (leaderboard + score), death overlay,
|
||||||
|
// fullscreen support, localStorage persistence.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type { LeaderboardRow, YouState } from '../../shared/protocol.js';
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
volume: number; // 0..1
|
||||||
|
quality: 'high' | 'low';
|
||||||
|
minimap: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'slither.settings';
|
||||||
|
const NAME_KEY = 'slither.name';
|
||||||
|
const BEST_KEY = 'slither.bestEver';
|
||||||
|
|
||||||
|
function el<T extends HTMLElement>(id: string): T {
|
||||||
|
const e = document.getElementById(id);
|
||||||
|
if (!e) throw new Error(`missing element #${id}`);
|
||||||
|
return e as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UI {
|
||||||
|
settings: Settings = { volume: 0.6, quality: 'high', minimap: true };
|
||||||
|
bestEver = 0;
|
||||||
|
|
||||||
|
onPlay: (name: string) => void = () => {};
|
||||||
|
onRespawn: () => void = () => {};
|
||||||
|
onSettingsChanged: () => void = () => {};
|
||||||
|
|
||||||
|
private menu = el<HTMLDivElement>('menu');
|
||||||
|
private settingsEl = el<HTMLDivElement>('settings');
|
||||||
|
private hud = el<HTMLDivElement>('hud');
|
||||||
|
private death = el<HTMLDivElement>('death');
|
||||||
|
private nick = el<HTMLInputElement>('nick');
|
||||||
|
private lbRows = el<HTMLDivElement>('lb-rows');
|
||||||
|
private scoreEl = el<HTMLSpanElement>('score');
|
||||||
|
private scoreSub = el<HTMLSpanElement>('score-sub');
|
||||||
|
private deathVisible = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// persisted settings / nickname / best score
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SETTINGS_KEY);
|
||||||
|
if (raw) Object.assign(this.settings, JSON.parse(raw));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
this.nick.value = localStorage.getItem(NAME_KEY) || '';
|
||||||
|
this.bestEver = Number(localStorage.getItem(BEST_KEY) || 0) || 0;
|
||||||
|
|
||||||
|
el<HTMLButtonElement>('play').addEventListener('click', () => {
|
||||||
|
const name = this.nick.value.trim() || 'Player';
|
||||||
|
localStorage.setItem(NAME_KEY, name);
|
||||||
|
this.onPlay(name);
|
||||||
|
});
|
||||||
|
this.nick.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') el<HTMLButtonElement>('play').click();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// settings panel
|
||||||
|
const vol = el<HTMLInputElement>('opt-volume');
|
||||||
|
const qual = el<HTMLSelectElement>('opt-quality');
|
||||||
|
const mm = el<HTMLInputElement>('opt-minimap');
|
||||||
|
const fs = el<HTMLInputElement>('opt-fullscreen');
|
||||||
|
vol.value = String(Math.round(this.settings.volume * 100));
|
||||||
|
qual.value = this.settings.quality;
|
||||||
|
mm.checked = this.settings.minimap;
|
||||||
|
fs.checked = !!document.fullscreenElement;
|
||||||
|
|
||||||
|
el<HTMLButtonElement>('settings-btn').addEventListener('click', () => {
|
||||||
|
this.menu.classList.add('hidden');
|
||||||
|
this.settingsEl.classList.remove('hidden');
|
||||||
|
this.onSettingsChanged();
|
||||||
|
});
|
||||||
|
el<HTMLButtonElement>('settings-back').addEventListener('click', () => {
|
||||||
|
this.settingsEl.classList.add('hidden');
|
||||||
|
this.menu.classList.remove('hidden');
|
||||||
|
this.onSettingsChanged();
|
||||||
|
});
|
||||||
|
vol.addEventListener('input', () => { this.settings.volume = Number(vol.value) / 100; this.save(); });
|
||||||
|
qual.addEventListener('change', () => { this.settings.quality = qual.value === 'low' ? 'low' : 'high'; this.save(); });
|
||||||
|
mm.addEventListener('change', () => { this.settings.minimap = mm.checked; this.save(); });
|
||||||
|
fs.addEventListener('change', () => { UI.toggleFullscreen(); });
|
||||||
|
|
||||||
|
el<HTMLButtonElement>('fullscreen-btn').addEventListener('click', () => UI.toggleFullscreen());
|
||||||
|
document.addEventListener('fullscreenchange', () => { fs.checked = !!document.fullscreenElement; });
|
||||||
|
|
||||||
|
el<HTMLButtonElement>('respawn').addEventListener('click', () => this.onRespawn());
|
||||||
|
}
|
||||||
|
|
||||||
|
private save(): void {
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(this.settings));
|
||||||
|
this.onSettingsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
static toggleFullscreen(): void {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
void document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
void document.documentElement.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(msg: string): void {
|
||||||
|
const e = el<HTMLDivElement>('menu-error');
|
||||||
|
e.textContent = msg;
|
||||||
|
e.classList.remove('hidden');
|
||||||
|
// if we were in game, fall back to the menu
|
||||||
|
if (!this.hud.classList.contains('hidden')) this.showMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
showMenu(): void {
|
||||||
|
this.menu.classList.remove('hidden');
|
||||||
|
this.hud.classList.add('hidden');
|
||||||
|
this.death.classList.add('hidden');
|
||||||
|
this.deathVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
showGame(): void {
|
||||||
|
this.menu.classList.add('hidden');
|
||||||
|
this.settingsEl.classList.add('hidden');
|
||||||
|
this.hud.classList.remove('hidden');
|
||||||
|
this.death.classList.add('hidden');
|
||||||
|
this.deathVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeath(you: YouState): void {
|
||||||
|
if (this.deathVisible) return;
|
||||||
|
this.deathVisible = true;
|
||||||
|
if (you.best > this.bestEver) {
|
||||||
|
this.bestEver = you.best;
|
||||||
|
localStorage.setItem(BEST_KEY, String(you.best));
|
||||||
|
}
|
||||||
|
el<HTMLSpanElement>('d-score').textContent = String(you.score);
|
||||||
|
el<HTMLSpanElement>('d-best').textContent = String(Math.max(you.best, this.bestEver));
|
||||||
|
el<HTMLSpanElement>('d-kills').textContent = String(you.kills);
|
||||||
|
this.death.classList.remove('hidden');
|
||||||
|
el<HTMLButtonElement>('respawn').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
hideDeath(): void {
|
||||||
|
this.death.classList.add('hidden');
|
||||||
|
this.deathVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHud(lb: LeaderboardRow[], myId: number, you: YouState, total: number): void {
|
||||||
|
this.scoreEl.textContent = String(you.score);
|
||||||
|
this.scoreSub.textContent =
|
||||||
|
`rank #${you.alive ? you.rank : '–'} of ${total} · best ${Math.max(you.best, this.bestEver)} · kills ${you.kills}`;
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (let i = 0; i < lb.length; i++) {
|
||||||
|
const row = lb[i];
|
||||||
|
const me = row.id === myId ? ' me' : '';
|
||||||
|
const name = row.n.replace(/&/g, '&').replace(/</g, '<');
|
||||||
|
html += `<div class="lb-row${me}"><span class="lb-rank">${i + 1}</span>` +
|
||||||
|
`<span class="lb-name">${name}</span><span class="lb-score">${row.s}</span></div>`;
|
||||||
|
}
|
||||||
|
this.lbRows.innerHTML = html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+82
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"name": "slither-clone",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "slither-clone",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.19.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
|
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||||
|
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "slither-clone",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A slither.io clone in TypeScript: multiplayer over WebSocket, AI bots, minimap, particles, synthesized sound.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.server.json && tsc -p tsconfig.client.json",
|
||||||
|
"start": "node dist/server/server.js",
|
||||||
|
"dev": "npm run build && npm start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
|
<title>slither.ts</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐍</text></svg>">
|
||||||
|
<link rel="stylesheet" href="./style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="game"></canvas>
|
||||||
|
|
||||||
|
<!-- ======================= HUD ======================= -->
|
||||||
|
<div id="hud" class="hidden">
|
||||||
|
<div id="leaderboard" class="panel">
|
||||||
|
<div class="lb-title">Leaderboard</div>
|
||||||
|
<div id="lb-rows"></div>
|
||||||
|
</div>
|
||||||
|
<div id="scorebar">
|
||||||
|
<span id="score">0</span>
|
||||||
|
<span id="score-sub">rank #– · best 0 · kills 0</span>
|
||||||
|
</div>
|
||||||
|
<div id="hint">move: mouse · boost: <b>SPACE</b> / click</div>
|
||||||
|
<canvas id="minimap" width="180" height="180"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======================= MAIN MENU ======================= -->
|
||||||
|
<div id="menu">
|
||||||
|
<div class="menu-box">
|
||||||
|
<h1 class="title">slither<span class="title-dot">.</span>ts</h1>
|
||||||
|
<div class="subtitle">a typescript slither.io clone</div>
|
||||||
|
<input id="nick" maxlength="16" placeholder="Nickname" autocomplete="off" spellcheck="false">
|
||||||
|
<button id="play" class="btn btn-play">Play</button>
|
||||||
|
<button id="settings-btn" class="btn btn-ghost">Settings</button>
|
||||||
|
<div id="menu-error" class="error hidden"></div>
|
||||||
|
</div>
|
||||||
|
<button id="fullscreen-btn" title="Fullscreen">⛶</button>
|
||||||
|
<div class="footer">eat pellets · grow huge · cut others off · don't touch the red border</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======================= SETTINGS ======================= -->
|
||||||
|
<div id="settings" class="hidden">
|
||||||
|
<div class="menu-box">
|
||||||
|
<h2 class="title-small">Settings</h2>
|
||||||
|
<label class="row">
|
||||||
|
<span>Sound volume</span>
|
||||||
|
<input id="opt-volume" type="range" min="0" max="100" value="60">
|
||||||
|
</label>
|
||||||
|
<label class="row">
|
||||||
|
<span>Graphics quality</span>
|
||||||
|
<select id="opt-quality">
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="row">
|
||||||
|
<span>Show minimap</span>
|
||||||
|
<input id="opt-minimap" type="checkbox" checked>
|
||||||
|
</label>
|
||||||
|
<label class="row">
|
||||||
|
<span>Fullscreen</span>
|
||||||
|
<input id="opt-fullscreen" type="checkbox">
|
||||||
|
</label>
|
||||||
|
<button id="settings-back" class="btn btn-play">Back</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======================= DEATH SCREEN ======================= -->
|
||||||
|
<div id="death" class="hidden">
|
||||||
|
<div class="menu-box death-box">
|
||||||
|
<h2 class="title-small">You died!</h2>
|
||||||
|
<div class="death-stats">
|
||||||
|
<div><span class="stat-label">Score</span><span id="d-score" class="stat-value">0</span></div>
|
||||||
|
<div><span class="stat-label">Best</span><span id="d-best" class="stat-value">0</span></div>
|
||||||
|
<div><span class="stat-label">Kills</span><span id="d-kills" class="stat-value">0</span></div>
|
||||||
|
</div>
|
||||||
|
<button id="respawn" class="btn btn-play">Respawn</button>
|
||||||
|
<div class="hint-small">press <b>Enter</b> to respawn instantly</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="./js/client/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #101216;
|
||||||
|
font-family: 'Segoe UI', 'Trebuchet MS', Verdana, sans-serif;
|
||||||
|
color: #e8ecf1;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game { position: fixed; inset: 0; display: block; cursor: crosshair; }
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* ---------------- overlay screens ---------------- */
|
||||||
|
#menu, #settings, #death {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
background: radial-gradient(ellipse at center, rgba(16,18,22,0.55) 0%, rgba(10,11,14,0.88) 100%);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-box {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||||
|
padding: 44px 56px;
|
||||||
|
background: rgba(20, 23, 29, 0.82);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.06);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 64px; font-weight: 800; letter-spacing: 1px;
|
||||||
|
background: linear-gradient(90deg, #7dff5b, #4dffd2, #4dc9ff, #c95bff);
|
||||||
|
-webkit-background-clip: text; background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent; color: transparent;
|
||||||
|
filter: drop-shadow(0 0 18px rgba(77, 255, 210, 0.25));
|
||||||
|
}
|
||||||
|
.title-dot { -webkit-text-fill-color: #ff5b5b; color: #ff5b5b; }
|
||||||
|
.title-small { font-size: 34px; font-weight: 700; color: #e8ecf1; }
|
||||||
|
.subtitle { margin-top: -10px; color: #8b93a1; font-size: 14px; letter-spacing: 2px; text-transform: uppercase; }
|
||||||
|
|
||||||
|
#nick {
|
||||||
|
width: 260px; padding: 12px 16px;
|
||||||
|
font-size: 17px; text-align: center; color: #e8ecf1;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
border-radius: 10px; outline: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
#nick:focus { border-color: #4dffd2; box-shadow: 0 0 14px rgba(77,255,210,0.25); }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 260px; padding: 13px 0;
|
||||||
|
font-size: 19px; font-weight: 700; letter-spacing: 0.5px;
|
||||||
|
border: none; border-radius: 10px; cursor: pointer;
|
||||||
|
transition: transform 0.08s, box-shadow 0.15s, filter 0.15s;
|
||||||
|
}
|
||||||
|
.btn:active { transform: scale(0.97); }
|
||||||
|
.btn-play {
|
||||||
|
color: #06281c;
|
||||||
|
background: linear-gradient(180deg, #6dff9e, #2fd977);
|
||||||
|
box-shadow: 0 4px 24px rgba(61, 230, 137, 0.35), inset 0 1px 0 rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
.btn-play:hover { filter: brightness(1.08); box-shadow: 0 4px 32px rgba(61, 230, 137, 0.55); }
|
||||||
|
.btn-ghost {
|
||||||
|
color: #c6cdd8;
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: rgba(255,255,255,0.12); }
|
||||||
|
|
||||||
|
#fullscreen-btn {
|
||||||
|
position: fixed; top: 18px; right: 18px;
|
||||||
|
width: 46px; height: 46px; font-size: 24px; line-height: 1;
|
||||||
|
color: #c6cdd8; background: rgba(255,255,255,0.07);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12); border-radius: 10px;
|
||||||
|
cursor: pointer; z-index: 30;
|
||||||
|
}
|
||||||
|
#fullscreen-btn:hover { background: rgba(255,255,255,0.14); }
|
||||||
|
|
||||||
|
.footer { position: fixed; bottom: 22px; color: #6b7280; font-size: 13px; letter-spacing: 1px; }
|
||||||
|
.error { color: #ff7070; font-size: 14px; }
|
||||||
|
|
||||||
|
/* settings rows */
|
||||||
|
.row {
|
||||||
|
width: 300px; display: flex; align-items: center; justify-content: space-between;
|
||||||
|
font-size: 15px; color: #c6cdd8; gap: 16px;
|
||||||
|
}
|
||||||
|
.row input[type=range] { width: 150px; accent-color: #2fd977; }
|
||||||
|
.row input[type=checkbox] { width: 20px; height: 20px; accent-color: #2fd977; }
|
||||||
|
.row select {
|
||||||
|
padding: 6px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: #1a1e25; color: #e8ecf1; outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- HUD ---------------- */
|
||||||
|
#hud { position: fixed; inset: 0; pointer-events: none; z-index: 10; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: rgba(12, 14, 18, 0.55);
|
||||||
|
border: 1px solid rgba(255,255,255,0.07);
|
||||||
|
border-radius: 12px;
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#leaderboard {
|
||||||
|
position: fixed; top: 16px; right: 16px;
|
||||||
|
width: 210px; padding: 10px 14px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.lb-title {
|
||||||
|
font-weight: 700; font-size: 14px; letter-spacing: 1px; text-transform: uppercase;
|
||||||
|
color: #9aa3b2; margin-bottom: 6px; text-align: center;
|
||||||
|
}
|
||||||
|
.lb-row { display: flex; gap: 8px; padding: 2px 4px; border-radius: 6px; color: #cfd6e0; white-space: nowrap; overflow: hidden; }
|
||||||
|
.lb-row .lb-rank { width: 20px; color: #7c8494; text-align: right; }
|
||||||
|
.lb-row .lb-name { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.lb-row .lb-score { color: #9aa3b2; }
|
||||||
|
.lb-row.me { background: rgba(77, 255, 210, 0.14); color: #7dffd8; font-weight: 700; }
|
||||||
|
.lb-row.me .lb-score, .lb-row.me .lb-rank { color: #7dffd8; }
|
||||||
|
|
||||||
|
#scorebar {
|
||||||
|
position: fixed; left: 18px; bottom: 16px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
text-shadow: 0 2px 8px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
#score { font-size: 34px; font-weight: 800; color: #fff; line-height: 1; }
|
||||||
|
#score-sub { font-size: 13px; color: #9aa3b2; margin-top: 4px; }
|
||||||
|
|
||||||
|
#hint {
|
||||||
|
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||||
|
font-size: 12px; color: #6b7280; letter-spacing: 1px;
|
||||||
|
text-shadow: 0 2px 6px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#minimap {
|
||||||
|
position: fixed; right: 16px; bottom: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255, 60, 70, 0.5);
|
||||||
|
box-shadow: 0 0 24px rgba(0,0,0,0.6), 0 0 12px rgba(255,60,70,0.25);
|
||||||
|
background: rgba(10, 12, 16, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* death screen */
|
||||||
|
.death-box { min-width: 340px; }
|
||||||
|
.death-stats { display: flex; gap: 34px; margin: 6px 0 10px; }
|
||||||
|
.death-stats > div { display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.stat-label { font-size: 12px; color: #8b93a1; text-transform: uppercase; letter-spacing: 1px; }
|
||||||
|
.stat-value { font-size: 30px; font-weight: 800; color: #fff; }
|
||||||
|
.hint-small { font-size: 12px; color: #6b7280; }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Validates the client-side dead-reckoning: applies real server snapshots,
|
||||||
|
// simulates at 60 fps like the browser, and measures prediction error
|
||||||
|
// between the locally simulated head and the server's authoritative head.
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import { applySnakeSnaps, simulateSnakes, State } from '../public/js/client/src/state.js';
|
||||||
|
|
||||||
|
const state = new State();
|
||||||
|
const ws = new WebSocket('ws://localhost:8080/ws');
|
||||||
|
let myAngle = 0.7;
|
||||||
|
let myBoost = false;
|
||||||
|
let samples = 0, sumErr = 0, maxErr = 0, resets = 0;
|
||||||
|
|
||||||
|
ws.on('open', () => ws.send(JSON.stringify({ t: 'join', name: 'SimTest' })));
|
||||||
|
ws.on('message', (d) => {
|
||||||
|
const m = JSON.parse(d);
|
||||||
|
if (m.t === 'init') { state.myId = m.id; state.arena = m.arena; return; }
|
||||||
|
if (m.t !== 'snap') return;
|
||||||
|
const before = state.snakes.get(state.myId);
|
||||||
|
const prevHead = before ? [before.pts[0], before.pts[1]] : null;
|
||||||
|
applySnakeSnaps(state, m.snakes);
|
||||||
|
if (m.you) state.you = m.you;
|
||||||
|
for (const ev of m.ev) if (ev.t === 'die' && ev.id === state.myId) ws.send(JSON.stringify({ t: 'respawn' }));
|
||||||
|
const me = state.snakes.get(state.myId);
|
||||||
|
const snap = m.snakes.find((s) => s.id === state.myId);
|
||||||
|
if (me && snap) {
|
||||||
|
if (prevHead && Math.hypot(prevHead[0] - snap.p[0], prevHead[1] - snap.p[1]) > 140) resets++;
|
||||||
|
const err = Math.hypot(me.pts[0] - snap.p[0], me.pts[1] - snap.p[1]);
|
||||||
|
sumErr += err; samples++;
|
||||||
|
if (err > maxErr) maxErr = err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// mimic the browser: input at ~33 Hz, simulation at 60 fps
|
||||||
|
setInterval(() => {
|
||||||
|
myAngle += (Math.random() - 0.5) * 0.3; // wander like a mouse would
|
||||||
|
myBoost = Math.random() < 0.1; // sometimes boost
|
||||||
|
const me = state.snakes.get(state.myId);
|
||||||
|
if (me && Math.hypot(me.pts[0], me.pts[1]) > 3000) {
|
||||||
|
myAngle = Math.atan2(-me.pts[1], -me.pts[0]); // steer back to safety
|
||||||
|
myBoost = false;
|
||||||
|
}
|
||||||
|
ws.send(JSON.stringify({ t: 'in', a: myAngle, b: myBoost }));
|
||||||
|
}, 30);
|
||||||
|
setInterval(() => simulateSnakes(state, 1 / 60, myAngle, myBoost && state.you.score > 20), 1000 / 60);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(`samples=${samples} avgErr=${(sumErr / Math.max(1, samples)).toFixed(2)}px maxErr=${maxErr.toFixed(2)}px hardResets=${resets}`);
|
||||||
|
if (samples > 100 && sumErr / samples < 25 && resets === 0) {
|
||||||
|
console.log('SIM TEST PASSED (prediction tracks server closely)');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.log('SIM TEST FAILED');
|
||||||
|
process.exit(1);
|
||||||
|
}, 20000);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Smoke test: join the game, verify snapshots flow, movement works,
|
||||||
|
// boosting drains score, and steering toward the border kills us.
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
const ws = new WebSocket('ws://localhost:8080/ws');
|
||||||
|
let init = null;
|
||||||
|
let snaps = 0;
|
||||||
|
let firstHead = null;
|
||||||
|
let lastHead = null;
|
||||||
|
let maxScore = 0;
|
||||||
|
let died = false;
|
||||||
|
let respawned = false;
|
||||||
|
let phase = 0;
|
||||||
|
|
||||||
|
ws.on('open', () => ws.send(JSON.stringify({ t: 'join', name: 'SmokeTest' })));
|
||||||
|
|
||||||
|
ws.on('message', (d) => {
|
||||||
|
const m = JSON.parse(d);
|
||||||
|
if (m.t === 'init') {
|
||||||
|
init = m;
|
||||||
|
console.log(`[init] id=${m.id} arena=${m.arena} pellets=${m.pellets.length}`);
|
||||||
|
ws.send(JSON.stringify({ t: 'in', a: 0, b: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m.t !== 'snap') return;
|
||||||
|
snaps++;
|
||||||
|
|
||||||
|
const me = m.snakes.find((s) => s.id === init.id);
|
||||||
|
if (me) {
|
||||||
|
lastHead = [me.p[0], me.p[1]];
|
||||||
|
if (!firstHead) firstHead = [...lastHead];
|
||||||
|
maxScore = Math.max(maxScore, m.you.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snaps === 30) {
|
||||||
|
const moved = Math.hypot(lastHead[0] - firstHead[0], lastHead[1] - firstHead[1]);
|
||||||
|
console.log(`[move] head moved ${moved.toFixed(1)}px over ~2s (${m.snakes.length} snakes, lb=${m.lb.length}, total=${m.total})`);
|
||||||
|
if (moved < 50) fail('snake did not move');
|
||||||
|
// start boosting toward the border to test boost drain + border death
|
||||||
|
phase = 1;
|
||||||
|
ws.send(JSON.stringify({ t: 'in', a: Math.atan2(lastHead[1], lastHead[0]), b: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ev of m.ev) {
|
||||||
|
if (ev.t === 'die' && ev.id === init.id && !died) {
|
||||||
|
died = true;
|
||||||
|
console.log(`[death] died as expected (score=${ev.s}, pellets sample pts=${ev.p.length / 2})`);
|
||||||
|
ws.send(JSON.stringify({ t: 'respawn' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (died && me && m.you.alive && !respawned) {
|
||||||
|
respawned = true;
|
||||||
|
console.log(`[respawn] instant respawn OK, alive with score=${m.you.score}`);
|
||||||
|
console.log('SMOKE TEST PASSED');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (snaps === 150 && phase === 1) {
|
||||||
|
console.log(`[boost] max score observed while boosting: ${maxScore}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function fail(msg) {
|
||||||
|
console.error('SMOKE TEST FAILED: ' + msg);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => fail(`timeout (snaps=${snaps}, died=${died}, respawned=${respawned})`), 90000);
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP static file server + WebSocket game server.
|
||||||
|
// Run with: npm start (after npm run build)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { extname, join, normalize, resolve } from 'node:path';
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import { World, Snake } from './world.js';
|
||||||
|
import { TICK_RATE, SNAP_EVERY, ARENA_RADIUS } from '../shared/constants.js';
|
||||||
|
import type { ClientMsg, InitMsg } from '../shared/protocol.js';
|
||||||
|
|
||||||
|
const PORT = process.env.PORT ? Number(process.env.PORT) : 8080;
|
||||||
|
const PUBLIC_ROOT = resolve(process.cwd(), 'public');
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.map': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const httpServer = createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
let url = (req.url || '/').split('?')[0];
|
||||||
|
if (url === '/') url = '/index.html';
|
||||||
|
const filePath = normalize(join(PUBLIC_ROOT, url));
|
||||||
|
if (!filePath.startsWith(PUBLIC_ROOT)) { res.writeHead(403); res.end(); return; }
|
||||||
|
const data = await readFile(filePath);
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': MIME[extname(filePath).toLowerCase()] || 'application/octet-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
});
|
||||||
|
res.end(data);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ game setup --
|
||||||
|
|
||||||
|
const world = new World();
|
||||||
|
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
||||||
|
|
||||||
|
interface Client { ws: WebSocket; snake: Snake | null }
|
||||||
|
const clients = new Set<Client>();
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
const client: Client = { ws, snake: null };
|
||||||
|
clients.add(client);
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let m: ClientMsg;
|
||||||
|
try { m = JSON.parse(raw.toString()) as ClientMsg; } catch { return; }
|
||||||
|
|
||||||
|
if (m.t === 'join' && !client.snake) {
|
||||||
|
const name = String(m.name || 'Player').replace(/[<>&"']/g, '').trim().slice(0, 16) || 'Player';
|
||||||
|
client.snake = world.addPlayer(name);
|
||||||
|
const init: InitMsg = { t: 'init', id: client.snake.id, arena: ARENA_RADIUS, pellets: world.pelletList() };
|
||||||
|
ws.send(JSON.stringify(init));
|
||||||
|
} else if (m.t === 'in' && client.snake && client.snake.alive) {
|
||||||
|
if (typeof m.a === 'number' && Number.isFinite(m.a)) client.snake.targetAngle = m.a;
|
||||||
|
client.snake.boosting = !!m.b;
|
||||||
|
} else if (m.t === 'respawn' && client.snake && !client.snake.alive) {
|
||||||
|
world.respawn(client.snake); // instant respawn, no countdown
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
clients.delete(client);
|
||||||
|
if (client.snake) world.removePlayer(client.snake.id);
|
||||||
|
});
|
||||||
|
ws.on('error', () => { /* handled by close */ });
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- game loop --
|
||||||
|
|
||||||
|
let last = process.hrtime.bigint();
|
||||||
|
setInterval(() => {
|
||||||
|
const now = process.hrtime.bigint();
|
||||||
|
const dt = Math.min(0.1, Number(now - last) / 1e9);
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
world.update(dt);
|
||||||
|
|
||||||
|
if (world.tick % SNAP_EVERY === 0) {
|
||||||
|
for (const c of clients) {
|
||||||
|
if (!c.snake || c.ws.readyState !== WebSocket.OPEN) continue;
|
||||||
|
c.ws.send(world.snapshotFor(c.snake));
|
||||||
|
}
|
||||||
|
world.postSnapshot(); // deltas were just broadcast; start a fresh batch
|
||||||
|
}
|
||||||
|
}, 1000 / TICK_RATE);
|
||||||
|
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`slither-clone server running at http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
+447
@@ -0,0 +1,447 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Authoritative server-side world simulation for the slither clone.
|
||||||
|
// Handles snake movement/growth, boosting, pellets, collisions, deaths,
|
||||||
|
// death-explosions into pellets and simple AI bots.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import {
|
||||||
|
ARENA_RADIUS, BASE_SPEED, BOOST_MULT, BOOST_DRAIN, BOOST_DROP_INTERVAL,
|
||||||
|
MIN_BOOST_SCORE, PELLET_COUNT, START_SCORE, TICK_RATE, TURN_RATE,
|
||||||
|
BOOST_TURN_RATE, BOT_TARGET_TOTAL, RESPAWN_PROTECT_TICKS, SNAKE_COLORS,
|
||||||
|
PELLET_COLORS, BOT_NAMES,
|
||||||
|
snakeRadius, snakePointCount, snakeSpacing, clamp, normAngle,
|
||||||
|
} from '../shared/constants.js';
|
||||||
|
import type { GameEvent, PelletData, SnapMsg, SnakeSnap } from '../shared/protocol.js';
|
||||||
|
|
||||||
|
export interface Pellet { id: number; x: number; y: number; v: number; c: number }
|
||||||
|
|
||||||
|
interface GridItem { x: number; y: number; r: number; sid: number }
|
||||||
|
|
||||||
|
export interface Snake {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
isBot: boolean;
|
||||||
|
colorIdx: number;
|
||||||
|
angle: number;
|
||||||
|
targetAngle: number;
|
||||||
|
boosting: boolean;
|
||||||
|
score: number;
|
||||||
|
best: number;
|
||||||
|
kills: number;
|
||||||
|
points: { x: number; y: number }[]; // head first
|
||||||
|
alive: boolean;
|
||||||
|
protect: number; // remaining spawn-protection ticks
|
||||||
|
dropTimer: number;
|
||||||
|
// bot brain state
|
||||||
|
think: number;
|
||||||
|
botBoostT: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uniform-grid spatial hash for cheap neighborhood queries. */
|
||||||
|
class Grid<T extends { x: number; y: number }> {
|
||||||
|
private map = new Map<number, T[]>();
|
||||||
|
constructor(private cell: number) {}
|
||||||
|
private key(cx: number, cy: number): number { return (cx + 128) * 512 + (cy + 128); }
|
||||||
|
clear(): void { this.map.clear(); }
|
||||||
|
insert(item: T): void {
|
||||||
|
const k = this.key(Math.floor(item.x / this.cell), Math.floor(item.y / this.cell));
|
||||||
|
let a = this.map.get(k);
|
||||||
|
if (!a) { a = []; this.map.set(k, a); }
|
||||||
|
a.push(item);
|
||||||
|
}
|
||||||
|
/** Return all items in cells overlapping the circle (caller distance-checks). */
|
||||||
|
query(x: number, y: number, r: number): T[] {
|
||||||
|
const out: T[] = [];
|
||||||
|
const c = this.cell;
|
||||||
|
const x0 = Math.floor((x - r) / c), x1 = Math.floor((x + r) / c);
|
||||||
|
const y0 = Math.floor((y - r) / c), y1 = Math.floor((y + r) / c);
|
||||||
|
for (let cx = x0; cx <= x1; cx++) {
|
||||||
|
for (let cy = y0; cy <= y1; cy++) {
|
||||||
|
const a = this.map.get(this.key(cx, cy));
|
||||||
|
if (a) for (const it of a) out.push(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_PELLETS = 1800; // ambient ~950 + room for death/boost drops
|
||||||
|
|
||||||
|
export class World {
|
||||||
|
snakes = new Map<number, Snake>();
|
||||||
|
pellets = new Map<number, Pellet>();
|
||||||
|
tick = 0;
|
||||||
|
|
||||||
|
private nextSnakeId = 1;
|
||||||
|
private nextPelletId = 1;
|
||||||
|
private events: GameEvent[] = [];
|
||||||
|
private added: PelletData[] = [];
|
||||||
|
private removed: number[] = [];
|
||||||
|
|
||||||
|
private grid = new Grid<GridItem>(72); // snake body points (rebuilt each tick)
|
||||||
|
private pelletGrid = new Grid<Pellet>(72); // pellets (rebuilt each tick)
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
for (let i = 0; i < PELLET_COUNT; i++) this.spawnAmbientPellet(false);
|
||||||
|
this.added = []; // initial pellets are delivered via the init message instead
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- spawning --
|
||||||
|
|
||||||
|
private randPos(margin: number): { x: number; y: number } {
|
||||||
|
const a = Math.random() * Math.PI * 2;
|
||||||
|
const d = Math.sqrt(Math.random()) * (ARENA_RADIUS - margin);
|
||||||
|
return { x: Math.cos(a) * d, y: Math.sin(a) * d };
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnAmbientPellet(announce = true): void {
|
||||||
|
const p = this.randPos(80);
|
||||||
|
const v = 1 + Math.floor(Math.pow(Math.random(), 2.2) * 5); // 1..5, weighted low
|
||||||
|
const pel: Pellet = { id: this.nextPelletId++, x: Math.round(p.x), y: Math.round(p.y), v, c: (Math.random() * PELLET_COLORS.length) | 0 };
|
||||||
|
this.pellets.set(pel.id, pel);
|
||||||
|
if (announce) this.added.push({ ...pel });
|
||||||
|
}
|
||||||
|
|
||||||
|
private dropPellet(x: number, y: number, v: number): void {
|
||||||
|
// hard cap so long sessions don't accumulate pellets forever
|
||||||
|
if (this.pellets.size >= MAX_PELLETS) return;
|
||||||
|
// keep dropped pellets inside the arena
|
||||||
|
const d = Math.hypot(x, y);
|
||||||
|
if (d > ARENA_RADIUS - 40) {
|
||||||
|
const f = (ARENA_RADIUS - 40) / d;
|
||||||
|
x *= f; y *= f;
|
||||||
|
}
|
||||||
|
const pel: Pellet = {
|
||||||
|
id: this.nextPelletId++, x: Math.round(x), y: Math.round(y),
|
||||||
|
v: clamp(Math.round(v), 1, 12), c: (Math.random() * PELLET_COLORS.length) | 0,
|
||||||
|
};
|
||||||
|
this.pellets.set(pel.id, pel);
|
||||||
|
this.added.push({ ...pel });
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeSnake(name: string, isBot: boolean): Snake {
|
||||||
|
const id = this.nextSnakeId++;
|
||||||
|
const colorIdx = (Math.random() * SNAKE_COLORS.length) | 0;
|
||||||
|
return {
|
||||||
|
id, name, isBot, colorIdx,
|
||||||
|
angle: Math.random() * Math.PI * 2, targetAngle: 0,
|
||||||
|
boosting: false, score: START_SCORE, best: START_SCORE, kills: 0,
|
||||||
|
points: [], alive: false, protect: 0, dropTimer: 0,
|
||||||
|
think: (Math.random() * 6) | 0, botBoostT: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
addPlayer(name: string): Snake {
|
||||||
|
const s = this.makeSnake(name, false);
|
||||||
|
this.snakes.set(s.id, s);
|
||||||
|
this.spawnSnake(s);
|
||||||
|
this.events.push({ t: 'join', id: s.id, n: s.name });
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnBot(): void {
|
||||||
|
const name = BOT_NAMES[(Math.random() * BOT_NAMES.length) | 0] + (Math.random() < 0.4 ? String((Math.random() * 99) | 0) : '');
|
||||||
|
const s = this.makeSnake(name, true);
|
||||||
|
s.score = START_SCORE * (1 + ((Math.random() * 14) | 0)); // varied sizes
|
||||||
|
this.snakes.set(s.id, s);
|
||||||
|
this.spawnSnake(s);
|
||||||
|
this.events.push({ t: 'join', id: s.id, n: s.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnSnake(s: Snake): void {
|
||||||
|
let x = 0, y = 0;
|
||||||
|
for (let tries = 0; tries < 24; tries++) {
|
||||||
|
const p = this.randPos(500);
|
||||||
|
x = p.x; y = p.y;
|
||||||
|
if (this.clearAround(x, y, 320)) break;
|
||||||
|
}
|
||||||
|
s.angle = Math.random() * Math.PI * 2;
|
||||||
|
s.targetAngle = s.angle;
|
||||||
|
if (!s.isBot) s.score = START_SCORE;
|
||||||
|
s.boosting = false;
|
||||||
|
s.alive = true;
|
||||||
|
s.protect = RESPAWN_PROTECT_TICKS;
|
||||||
|
s.dropTimer = 0;
|
||||||
|
const n = snakePointCount(s.score);
|
||||||
|
const sp = snakeSpacing(s.score);
|
||||||
|
s.points = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
s.points.push({ x: x - Math.cos(s.angle) * sp * i, y: y - Math.sin(s.angle) * sp * i });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAround(x: number, y: number, r: number): boolean {
|
||||||
|
for (const s of this.snakes.values()) {
|
||||||
|
if (!s.alive) continue;
|
||||||
|
for (const p of s.points) {
|
||||||
|
if ((p.x - x) * (p.x - x) + (p.y - y) * (p.y - y) < r * r) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
respawn(s: Snake): void {
|
||||||
|
if (!s.alive) this.spawnSnake(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
removePlayer(id: number): void {
|
||||||
|
const s = this.snakes.get(id);
|
||||||
|
if (!s) return;
|
||||||
|
if (s.alive) this.killSnake(s); // leave a pellet trail behind
|
||||||
|
this.snakes.delete(id);
|
||||||
|
this.events.push({ t: 'leave', id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- update --
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
this.tick++;
|
||||||
|
// NOTE: events/pellet deltas accumulate across ticks and are cleared by
|
||||||
|
// postSnapshot() once a snapshot carrying them has been broadcast.
|
||||||
|
|
||||||
|
// rebuild spatial grids
|
||||||
|
this.grid.clear();
|
||||||
|
for (const s of this.snakes.values()) {
|
||||||
|
if (!s.alive || s.protect > 0) continue; // protected snakes are ghosts
|
||||||
|
const r = snakeRadius(s.score);
|
||||||
|
for (const p of s.points) this.grid.insert({ x: p.x, y: p.y, r, sid: s.id });
|
||||||
|
}
|
||||||
|
this.pelletGrid.clear();
|
||||||
|
for (const p of this.pellets.values()) this.pelletGrid.insert(p);
|
||||||
|
|
||||||
|
// bot brains
|
||||||
|
for (const s of this.snakes.values()) if (s.isBot && s.alive) this.botThink(s);
|
||||||
|
|
||||||
|
// move, then resolve collisions
|
||||||
|
const dead: { s: Snake; by?: Snake }[] = [];
|
||||||
|
for (const s of this.snakes.values()) {
|
||||||
|
if (!s.alive) continue;
|
||||||
|
this.moveSnake(s, dt);
|
||||||
|
|
||||||
|
const h = s.points[0];
|
||||||
|
const r = snakeRadius(s.score);
|
||||||
|
|
||||||
|
// red border kills
|
||||||
|
if (Math.hypot(h.x, h.y) > ARENA_RADIUS - r * 0.4) { dead.push({ s }); continue; }
|
||||||
|
|
||||||
|
if (s.protect > 0) {
|
||||||
|
s.protect--;
|
||||||
|
} else {
|
||||||
|
// crashed into another snake's body?
|
||||||
|
let killer: Snake | undefined;
|
||||||
|
let hit = false;
|
||||||
|
for (const q of this.grid.query(h.x, h.y, r + 40)) {
|
||||||
|
if (q.sid === s.id) continue; // you cannot collide with yourself
|
||||||
|
if (Math.hypot(q.x - h.x, q.y - h.y) < r * 0.8 + q.r * 0.85) { hit = true; killer = this.snakes.get(q.sid); break; }
|
||||||
|
}
|
||||||
|
if (hit) { dead.push({ s, by: killer }); continue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// eat pellets
|
||||||
|
for (const p of this.pelletGrid.query(h.x, h.y, r + 24)) {
|
||||||
|
if (!this.pellets.has(p.id)) continue;
|
||||||
|
const pr = 2.5 + p.v * 0.9;
|
||||||
|
if (Math.hypot(p.x - h.x, p.y - h.y) < r + pr) {
|
||||||
|
this.pellets.delete(p.id);
|
||||||
|
this.removed.push(p.id);
|
||||||
|
s.score += p.v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.score > s.best) s.best = s.score;
|
||||||
|
}
|
||||||
|
for (const d of dead) this.killSnake(d.s, d.by);
|
||||||
|
|
||||||
|
// keep ambient pellet population topped up (trickle in)
|
||||||
|
let want = PELLET_COUNT - this.pellets.size;
|
||||||
|
while (want-- > 0 && Math.random() < 0.7) this.spawnAmbientPellet();
|
||||||
|
|
||||||
|
// keep the arena filled with bots
|
||||||
|
let humans = 0, bots = 0;
|
||||||
|
for (const s of this.snakes.values()) (s.isBot ? bots++ : humans++);
|
||||||
|
const targetBots = Math.max(0, BOT_TARGET_TOTAL - humans);
|
||||||
|
if (bots < targetBots) this.spawnBot();
|
||||||
|
else if (bots > targetBots) {
|
||||||
|
// make room for humans: retire the weakest bot
|
||||||
|
let weakest: Snake | null = null;
|
||||||
|
for (const s of this.snakes.values()) {
|
||||||
|
if (s.isBot && (!weakest || s.score < weakest.score)) weakest = s;
|
||||||
|
}
|
||||||
|
if (weakest) { this.killSnake(weakest); this.snakes.delete(weakest.id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveSnake(s: Snake, dt: number): void {
|
||||||
|
if (s.boosting && s.score <= MIN_BOOST_SCORE) s.boosting = false;
|
||||||
|
const speed = BASE_SPEED * (s.boosting ? BOOST_MULT : 1);
|
||||||
|
|
||||||
|
// players face their cursor instantly; bots keep a capped turn rate
|
||||||
|
if (s.isBot) {
|
||||||
|
const maxTurn = (s.boosting ? BOOST_TURN_RATE : TURN_RATE) * dt;
|
||||||
|
const da = normAngle(s.targetAngle - s.angle);
|
||||||
|
s.angle += clamp(da, -maxTurn, maxTurn);
|
||||||
|
} else {
|
||||||
|
s.angle = s.targetAngle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// advance head, then pull the body chain along at fixed spacing
|
||||||
|
const h = s.points[0];
|
||||||
|
s.points.unshift({ x: h.x + Math.cos(s.angle) * speed * dt, y: h.y + Math.sin(s.angle) * speed * dt });
|
||||||
|
const sp = snakeSpacing(s.score);
|
||||||
|
for (let i = 1; i < s.points.length; i++) {
|
||||||
|
const a = s.points[i - 1], b = s.points[i];
|
||||||
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d > sp) {
|
||||||
|
const f = (d - sp) / d;
|
||||||
|
b.x -= dx * f; b.y -= dy * f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const want = snakePointCount(s.score);
|
||||||
|
while (s.points.length > want) s.points.pop();
|
||||||
|
|
||||||
|
// boosting burns length and sheds pellets from the tail
|
||||||
|
if (s.boosting) {
|
||||||
|
s.score = Math.max(1, s.score - BOOST_DRAIN * dt);
|
||||||
|
s.dropTimer -= dt;
|
||||||
|
if (s.dropTimer <= 0) {
|
||||||
|
s.dropTimer = BOOST_DROP_INTERVAL;
|
||||||
|
const tail = s.points[s.points.length - 1];
|
||||||
|
this.dropPellet(tail.x + (Math.random() - 0.5) * 14, tail.y + (Math.random() - 0.5) * 14, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
killSnake(s: Snake, by?: Snake): void {
|
||||||
|
if (!s.alive) return;
|
||||||
|
s.alive = false;
|
||||||
|
if (by && by.id !== s.id) by.kills++;
|
||||||
|
|
||||||
|
// explode into glowing pellets along the whole body
|
||||||
|
const pts = s.points;
|
||||||
|
const n = pts.length;
|
||||||
|
const r = snakeRadius(s.score);
|
||||||
|
const baseVal = clamp((s.score / Math.max(1, n / 2)) * 0.8, 1, 12);
|
||||||
|
const sample: number[] = [];
|
||||||
|
for (let i = 0; i < n; i += 2) {
|
||||||
|
const p = pts[i];
|
||||||
|
this.dropPellet(p.x + (Math.random() - 0.5) * r * 1.6, p.y + (Math.random() - 0.5) * r * 1.6, baseVal * (0.7 + Math.random() * 0.6));
|
||||||
|
if (sample.length < 40) sample.push(Math.round(p.x), Math.round(p.y));
|
||||||
|
}
|
||||||
|
this.events.push({ t: 'die', id: s.id, x: Math.round(pts[0].x), y: Math.round(pts[0].y), c: s.colorIdx, s: Math.round(s.score), p: sample });
|
||||||
|
|
||||||
|
if (s.isBot) {
|
||||||
|
this.snakes.delete(s.id);
|
||||||
|
this.events.push({ t: 'leave', id: s.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- bot AI --
|
||||||
|
|
||||||
|
private botThink(s: Snake): void {
|
||||||
|
if (--s.think > 0) return;
|
||||||
|
s.think = 5 + ((Math.random() * 4) | 0);
|
||||||
|
|
||||||
|
const h = s.points[0];
|
||||||
|
const r = snakeRadius(s.score);
|
||||||
|
|
||||||
|
// attraction: nearest pellet
|
||||||
|
let px = 0, py = 0, pd = Infinity;
|
||||||
|
for (const p of this.pelletGrid.query(h.x, h.y, 460)) {
|
||||||
|
const d = Math.hypot(p.x - h.x, p.y - h.y);
|
||||||
|
if (d < pd) { pd = d; px = p.x; py = p.y; }
|
||||||
|
}
|
||||||
|
let want = pd < 800 ? Math.atan2(py - h.y, px - h.x) : s.angle + (Math.random() - 0.5);
|
||||||
|
|
||||||
|
// hunting: chase a clearly smaller snake if one is close
|
||||||
|
let huntX = 0, huntY = 0, huntD = Infinity, huntBoost = false;
|
||||||
|
for (const o of this.snakes.values()) {
|
||||||
|
if (!o.alive || o.id === s.id || o.score > s.score * 0.8) continue;
|
||||||
|
const oh = o.points[0];
|
||||||
|
const d = Math.hypot(oh.x - h.x, oh.y - h.y);
|
||||||
|
if (d < 430 && d < huntD) { huntD = d; huntX = oh.x; huntY = oh.y; huntBoost = true; }
|
||||||
|
}
|
||||||
|
if (huntD < 430) want = Math.atan2(huntY - h.y, huntX - h.x);
|
||||||
|
|
||||||
|
// score a fan of candidate headings, pick the safest one
|
||||||
|
const look = 190 + BASE_SPEED * 0.55;
|
||||||
|
let bestOff = 0, bestScore = -Infinity;
|
||||||
|
for (let off = -100; off <= 100; off += 20) {
|
||||||
|
const dir = s.angle + (off * Math.PI) / 180;
|
||||||
|
const qx = h.x + Math.cos(dir) * look;
|
||||||
|
const qy = h.y + Math.sin(dir) * look;
|
||||||
|
let sc = -off * off * 0.02;
|
||||||
|
|
||||||
|
const dc = Math.hypot(qx, qy);
|
||||||
|
const over = dc - (ARENA_RADIUS - 280);
|
||||||
|
if (over > 0) sc -= over * 0.6 + 80;
|
||||||
|
|
||||||
|
for (const q of this.grid.query(qx, qy, 130)) {
|
||||||
|
if (q.sid === s.id) continue;
|
||||||
|
const d = Math.hypot(q.x - qx, q.y - qy);
|
||||||
|
const danger = r * 0.8 + q.r * 0.85 + 40;
|
||||||
|
if (d < danger) sc -= (danger - d) * 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
sc += Math.cos(normAngle(want - dir)) * 34;
|
||||||
|
if (sc > bestScore) { bestScore = sc; bestOff = off; }
|
||||||
|
}
|
||||||
|
s.targetAngle = normAngle(s.angle + (bestOff * Math.PI) / 180);
|
||||||
|
|
||||||
|
// boost decisions
|
||||||
|
if (--s.botBoostT <= 0) {
|
||||||
|
s.boosting = false;
|
||||||
|
if (s.score > 90 && Math.abs(bestOff) <= 20) {
|
||||||
|
if (huntBoost && Math.random() < 0.6) { s.boosting = true; s.botBoostT = TICK_RATE; }
|
||||||
|
else if (Math.random() < 0.05) { s.boosting = true; s.botBoostT = TICK_RATE / 2; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- serialization --
|
||||||
|
|
||||||
|
/** Called by the server after a snapshot has been broadcast to all clients. */
|
||||||
|
postSnapshot(): void {
|
||||||
|
this.events = [];
|
||||||
|
this.added = [];
|
||||||
|
this.removed = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
pelletList(): PelletData[] {
|
||||||
|
const out: PelletData[] = [];
|
||||||
|
for (const p of this.pellets.values()) out.push({ ...p });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotFor(me: Snake | null): string {
|
||||||
|
const snakes: SnakeSnap[] = [];
|
||||||
|
const ranked: Snake[] = [];
|
||||||
|
for (const o of this.snakes.values()) {
|
||||||
|
if (!o.alive) continue;
|
||||||
|
ranked.push(o);
|
||||||
|
const p: number[] = new Array(o.points.length * 2);
|
||||||
|
for (let i = 0; i < o.points.length; i++) {
|
||||||
|
p[i * 2] = Math.round(o.points[i].x);
|
||||||
|
p[i * 2 + 1] = Math.round(o.points[i].y);
|
||||||
|
}
|
||||||
|
snakes.push({
|
||||||
|
id: o.id, n: o.name, c: o.colorIdx, s: Math.round(o.score),
|
||||||
|
p, b: o.boosting, k: o.kills, sp: o.protect > 0 || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ranked.sort((a, b) => b.score - a.score);
|
||||||
|
const lb = ranked.slice(0, 10).map(o => ({ id: o.id, n: o.name, s: Math.round(o.score) }));
|
||||||
|
|
||||||
|
let you: SnapMsg['you'];
|
||||||
|
if (me) {
|
||||||
|
const rank = me.alive ? ranked.findIndex(o => o.id === me.id) + 1 : 0;
|
||||||
|
you = { score: Math.round(me.score), rank, alive: me.alive, kills: me.kills, best: Math.round(me.best) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg: SnapMsg = {
|
||||||
|
t: 'snap', tick: this.tick, snakes,
|
||||||
|
add: this.added, rem: this.removed, ev: this.events,
|
||||||
|
lb, you, total: ranked.length,
|
||||||
|
};
|
||||||
|
return JSON.stringify(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared game constants & helpers (used by both the server sim and the client)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const TICK_RATE = 30; // server simulation ticks per second
|
||||||
|
export const SNAP_EVERY = 2; // send a snapshot every N ticks (15 Hz)
|
||||||
|
|
||||||
|
export const ARENA_RADIUS = 4200; // playable circle radius in world px
|
||||||
|
|
||||||
|
export const BASE_SPEED = 178; // px per second
|
||||||
|
export const BOOST_MULT = 1.95; // speed multiplier while boosting
|
||||||
|
export const TURN_RATE = 4.4; // radians per second
|
||||||
|
export const BOOST_TURN_RATE = 3.8; // slightly stiffer steering while boosting
|
||||||
|
|
||||||
|
export const START_SCORE = 25;
|
||||||
|
export const MIN_BOOST_SCORE = 18; // cannot boost below this score
|
||||||
|
export const BOOST_DRAIN = 16; // score per second burned while boosting
|
||||||
|
export const BOOST_DROP_INTERVAL = 0.26; // seconds between tail pellets while boosting
|
||||||
|
|
||||||
|
export const PELLET_COUNT = 950; // ambient pellets kept alive in the arena
|
||||||
|
export const BOT_TARGET_TOTAL = 12; // arena is kept filled to this many snakes
|
||||||
|
export const RESPAWN_PROTECT_TICKS = TICK_RATE * 2; // ~2s of spawn protection
|
||||||
|
|
||||||
|
// --- derived snake geometry -------------------------------------------------
|
||||||
|
|
||||||
|
export function snakeRadius(score: number): number {
|
||||||
|
return 5 + Math.sqrt(Math.max(0, score)) * 0.32;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snakePointCount(score: number): number {
|
||||||
|
return Math.min(420, Math.round(14 + Math.sqrt(Math.max(0, score)) * 2.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snakeSpacing(score: number): number {
|
||||||
|
return Math.max(5.5, snakeRadius(score) * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- math helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
export function clamp(v: number, lo: number, hi: number): number {
|
||||||
|
return v < lo ? lo : v > hi ? hi : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize an angle to (-PI, PI]. */
|
||||||
|
export function normAngle(a: number): number {
|
||||||
|
while (a > Math.PI) a -= Math.PI * 2;
|
||||||
|
while (a <= -Math.PI) a += Math.PI * 2;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dist(x1: number, y1: number, x2: number, y2: number): number {
|
||||||
|
return Math.hypot(x2 - x1, y2 - y1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- palettes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export const SNAKE_COLORS = [
|
||||||
|
'#ff5b5b', '#ffb84d', '#ffe14d', '#7dff5b', '#4dffd2', '#4dc9ff',
|
||||||
|
'#8a7dff', '#c95bff', '#ff5bd0', '#9dff4d', '#5bff9d', '#ffd24d',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PELLET_COLORS = [
|
||||||
|
'#ff6b6b', '#ffd93d', '#6bff8f', '#4dd2ff',
|
||||||
|
'#c586ff', '#ff8fe1', '#8ffcff', '#ffe28f',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BOT_NAMES = [
|
||||||
|
'Noodle', 'Viper', 'Slinky', 'Zigzag', 'Cobra', 'Python', 'Nagini',
|
||||||
|
'Basilisk', 'Wiggles', 'Turbo', 'Boa', 'Rattles', 'Medusa',
|
||||||
|
'Sidewinder', 'Anaconda', 'DangerNoodle', 'Hiss', 'Monty',
|
||||||
|
];
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Network protocol shared between server and client (JSON over WebSocket)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PelletData {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
v: number; // food value (also drives render size)
|
||||||
|
c: number; // color index into PELLET_COLORS
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnakeSnap {
|
||||||
|
id: number;
|
||||||
|
n: string; // nickname
|
||||||
|
c: number; // color index into SNAKE_COLORS
|
||||||
|
s: number; // score
|
||||||
|
p: number[]; // flat body points [x0,y0,x1,y1,...], head first
|
||||||
|
b: boolean; // boosting
|
||||||
|
k: number; // kills
|
||||||
|
sp?: boolean; // spawn protection active (ghost)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameEvent =
|
||||||
|
| { t: 'die'; id: number; x: number; y: number; c: number; s: number; p: number[] }
|
||||||
|
| { t: 'join'; id: number; n: string }
|
||||||
|
| { t: 'leave'; id: number };
|
||||||
|
|
||||||
|
export interface LeaderboardRow {
|
||||||
|
id: number;
|
||||||
|
n: string;
|
||||||
|
s: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YouState {
|
||||||
|
score: number;
|
||||||
|
rank: number;
|
||||||
|
alive: boolean;
|
||||||
|
kills: number;
|
||||||
|
best: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- server -> client -------------------------------------------------------
|
||||||
|
|
||||||
|
export interface InitMsg {
|
||||||
|
t: 'init';
|
||||||
|
id: number; // your snake id
|
||||||
|
arena: number; // arena radius
|
||||||
|
pellets: PelletData[]; // full pellet list (deltas arrive afterwards)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapMsg {
|
||||||
|
t: 'snap';
|
||||||
|
tick: number;
|
||||||
|
snakes: SnakeSnap[];
|
||||||
|
add: PelletData[]; // pellets added since last snapshot
|
||||||
|
rem: number[]; // pellet ids removed since last snapshot
|
||||||
|
ev: GameEvent[]; // events since last snapshot
|
||||||
|
lb: LeaderboardRow[];
|
||||||
|
you?: YouState;
|
||||||
|
total: number; // total snakes alive
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMsg = InitMsg | SnapMsg;
|
||||||
|
|
||||||
|
// --- client -> server -------------------------------------------------------
|
||||||
|
|
||||||
|
export interface JoinMsg { t: 'join'; name: string }
|
||||||
|
export interface InputMsg { t: 'in'; a: number; b: boolean } // angle, boosting
|
||||||
|
export interface RespawnMsg { t: 'respawn' }
|
||||||
|
|
||||||
|
export type ClientMsg = JoinMsg | InputMsg | RespawnMsg;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "public/js",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||||
|
},
|
||||||
|
"include": ["client/**/*.ts", "shared/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["server/**/*.ts", "shared/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user