Inital commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user