Files
slither-2.3/server/world.ts
T
2026-07-22 00:34:09 -05:00

448 lines
16 KiB
TypeScript

// ---------------------------------------------------------------------------
// 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);
}
}