104 lines
3.6 KiB
TypeScript
104 lines
3.6 KiB
TypeScript
// ---------------------------------------------------------------------------
|
|
// 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}`);
|
|
});
|