Dino Isle Online: multiplayer dino survival game (The Isle-like)
- Zero-dependency Node server: HTTP static + hand-rolled RFC6455 WebSocket + 20Hz authoritative simulation - Seeded island worldgen (tiles, forest decor, spawns) - 4 playable species, needs (food/water/stamina), rest, growth stages Hatchling->Apex - AI fauna: critters, fish shoals, Dryosaurus/Psittacosaurus herds; carcass feasting - PvP with knockback, kill feed, chat, leaderboard, minimap, day/night cycle - True top-down procedural dino renderer shared by menu cards and in-game - Developer mode (` key): god-mode, x6 XP, instant evolve, spawn AI, Alt+Click tp - Headless smoke tests, real-browser visual tests, pixel match checks
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// ---------------------------------------------------------------
|
||||
// Dino Isle Online — main server: static files + websocket + game loop
|
||||
// Run: node server.js (PORT env optional, default 8090)
|
||||
// ---------------------------------------------------------------
|
||||
'use strict';
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { Game, TICK_MS } = require('./sim');
|
||||
const wsattach = require('./ws').attach;
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '8095', 10);
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
const CHEATS = process.env.DEBUG_CHEATS === '1';
|
||||
|
||||
const MIME = {
|
||||
'.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',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
let urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
|
||||
if (urlPath === '/') urlPath = '/index.html';
|
||||
const filePath = path.normalize(path.join(PUBLIC_DIR, urlPath));
|
||||
if (!filePath.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[ext] || 'application/octet-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
|
||||
const game = new Game();
|
||||
|
||||
let nextAllowedMsg = new WeakMap(); // conn -> timestamp budget
|
||||
|
||||
wsattach(server, '/ws', (conn) => {
|
||||
conn.onmessage = (str) => {
|
||||
let m;
|
||||
try { m = JSON.parse(str); } catch (_) { return; }
|
||||
if (!m || typeof m !== 'object') return;
|
||||
|
||||
// simple flood guard: max ~40 msgs/sec
|
||||
const now = Date.now();
|
||||
const budget = nextAllowedMsg.get(conn) || now;
|
||||
if (now < budget - 1000) return; // way over -> drop
|
||||
nextAllowedMsg.set(conn, Math.max(budget + 25, now));
|
||||
|
||||
const p = game.players.get(conn);
|
||||
switch (m.t) {
|
||||
case 'join': {
|
||||
const player = game.join(conn, String(m.name || '').slice(0, 16), m.sp);
|
||||
conn.send(JSON.stringify(game.welcomePayload(player)));
|
||||
break;
|
||||
}
|
||||
case 'in':
|
||||
if (p) game.onInput(p, m);
|
||||
break;
|
||||
case 'chat':
|
||||
if (p && p.alive !== undefined) game.onChat(p, m.msg);
|
||||
break;
|
||||
case 'dev':
|
||||
if (p) game.onDev(p, m);
|
||||
break;
|
||||
case 'devact':
|
||||
if (p) game.onDevAct(p, String(m.act || ''));
|
||||
break;
|
||||
case 'ping': conn.send('{"t":"pong"}'); break;
|
||||
case 'tp':
|
||||
if (p && (CHEATS || p.dev)) {
|
||||
p.x = Math.max(40, Math.min(7680 - 40, +m.x || p.x));
|
||||
p.y = Math.max(40, Math.min(7680 - 40, +m.y || p.y));
|
||||
if (typeof m.a === 'number') p.dir = m.a;
|
||||
p.vx = 0; p.vy = 0;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
conn.onclose = () => game.leave(conn);
|
||||
});
|
||||
|
||||
// heartbeat
|
||||
setInterval(() => {
|
||||
for (const p of [...game.players.keys()]) {
|
||||
const c = p; // key is conn
|
||||
if (c.writable) c.ping();
|
||||
}
|
||||
}, 25000);
|
||||
|
||||
// main loop with drift compensation
|
||||
let last = Date.now();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
let dt = (now - last) / 1000;
|
||||
last = now;
|
||||
dt = Math.min(dt, 0.25); // clamp big pauses
|
||||
try { game.tick(dt); } catch (e) { console.error('tick error', e); }
|
||||
}, TICK_MS);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[dino-isle] listening on http://localhost:${PORT} (seed=${game.seed})`);
|
||||
console.log(`[dino-isle] open multiple browser tabs for multiplayer fun!`);
|
||||
});
|
||||
Reference in New Issue
Block a user