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,149 @@
|
||||
// ---------------------------------------------------------------
|
||||
// Minimal RFC6455 WebSocket server — zero dependencies
|
||||
// ---------------------------------------------------------------
|
||||
'use strict';
|
||||
const crypto = require('crypto');
|
||||
|
||||
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
function acceptKey(key) {
|
||||
return crypto.createHash('sha1').update(key + GUID).digest('base64');
|
||||
}
|
||||
|
||||
class WSConn {
|
||||
constructor(socket) {
|
||||
this.socket = socket;
|
||||
this.alive = true;
|
||||
this.fragOp = 0;
|
||||
this.fragBufs = [];
|
||||
this.onmessage = null;
|
||||
this.onclose = null;
|
||||
socket.setNoDelay(true);
|
||||
socket.on('data', (buf) => this._onData(buf));
|
||||
const end = () => this._closed();
|
||||
socket.on('close', end);
|
||||
socket.on('error', end);
|
||||
}
|
||||
|
||||
get writable() {
|
||||
return this.socket && this.socket.writable && this.alive;
|
||||
}
|
||||
|
||||
_closed() {
|
||||
if (!this.alive) return;
|
||||
this.alive = false;
|
||||
if (this.onclose) this.onclose();
|
||||
try { this.socket.destroy(); } catch (_) {}
|
||||
}
|
||||
|
||||
_onData(buf) {
|
||||
let off = 0;
|
||||
try {
|
||||
while (off + 2 <= buf.length) {
|
||||
const b0 = buf[off];
|
||||
const b1 = buf[off + 1];
|
||||
const fin = (b0 & 0x80) !== 0;
|
||||
const op = b0 & 0x0f;
|
||||
const masked = (b1 & 0x80) !== 0;
|
||||
let len = b1 & 0x7f;
|
||||
off += 2;
|
||||
if (len === 126) {
|
||||
if (off + 2 > buf.length) break;
|
||||
len = buf.readUInt16BE(off); off += 2;
|
||||
} else if (len === 127) {
|
||||
if (off + 8 > buf.length) break;
|
||||
const big = buf.readBigUInt64BE(off);
|
||||
off += 8;
|
||||
len = Number(big);
|
||||
}
|
||||
if (len > 10 * 1024 * 1024) { this.close(); return; } // sanity cap
|
||||
let maskKey = null;
|
||||
if (masked) {
|
||||
if (off + 4 > buf.length) break;
|
||||
maskKey = buf.subarray(off, off + 4); off += 4;
|
||||
}
|
||||
if (off + len > buf.length) break; // wait for more data (rare; messages are small)
|
||||
let payload = buf.subarray(off, off + len);
|
||||
off += len;
|
||||
if (maskKey) {
|
||||
payload = Buffer.from(payload); // copy so we can unmask
|
||||
for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i & 3];
|
||||
}
|
||||
switch (op) {
|
||||
case 0x0: // continuation
|
||||
this.fragBufs.push(payload);
|
||||
if (fin) {
|
||||
const full = Buffer.concat(this.fragBufs);
|
||||
this.fragBufs = [];
|
||||
this._emit(this.fragOp, full);
|
||||
}
|
||||
break;
|
||||
case 0x1: case 0x2: // text / binary
|
||||
if (fin) this._emit(op, payload);
|
||||
else { this.fragOp = op; this.fragBufs = [payload]; }
|
||||
break;
|
||||
case 0x8: this.close(); return; // close
|
||||
case 0x9: this._sendFrame(0xA, payload); break; // ping -> pong
|
||||
case 0xA: break; // pong
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
} catch (_) { this.close(); }
|
||||
}
|
||||
|
||||
_emit(op, payload) {
|
||||
if (op === 0x1) {
|
||||
const str = payload.toString('utf8');
|
||||
if (this.onmessage) this.onmessage(str);
|
||||
}
|
||||
}
|
||||
|
||||
_sendFrame(op, payload) {
|
||||
if (!this.writable) return;
|
||||
const len = payload.length;
|
||||
let header;
|
||||
if (len < 126) {
|
||||
header = Buffer.from([0x80 | op, len]);
|
||||
} else if (len < 65536) {
|
||||
header = Buffer.alloc(4);
|
||||
header[0] = 0x80 | op; header[1] = 126;
|
||||
header.writeUInt16BE(len, 2);
|
||||
} else {
|
||||
header = Buffer.alloc(10);
|
||||
header[0] = 0x80 | op; header[1] = 127;
|
||||
header.writeBigUInt64BE(BigInt(len), 2);
|
||||
}
|
||||
try { this.socket.write(Buffer.concat([header, payload])); } catch (_) {}
|
||||
}
|
||||
|
||||
send(str) { this._sendFrame(0x1, Buffer.from(str, 'utf8')); }
|
||||
ping() { this._sendFrame(0x9, Buffer.alloc(0)); }
|
||||
close() {
|
||||
if (this.writable) { try { this._sendFrame(0x8, Buffer.alloc(0)); } catch (_) {} }
|
||||
this._closed();
|
||||
}
|
||||
}
|
||||
|
||||
function attach(server, path, onConn) {
|
||||
server.on('upgrade', (req, socket) => {
|
||||
try {
|
||||
const url = req.url.split('?')[0];
|
||||
if (url !== path) { socket.destroy(); return; }
|
||||
const key = req.headers['sec-websocket-key'];
|
||||
if (!key) { socket.destroy(); return; }
|
||||
const headers = [
|
||||
'HTTP/1.1 101 Switching Protocols',
|
||||
'Upgrade: websocket',
|
||||
'Connection: Upgrade',
|
||||
`Sec-WebSocket-Accept: ${acceptKey(key)}`,
|
||||
'\r\n',
|
||||
].join('\r\n');
|
||||
socket.write(headers);
|
||||
onConn(new WSConn(socket));
|
||||
} catch (_) {
|
||||
try { socket.destroy(); } catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { attach, WSConn };
|
||||
Reference in New Issue
Block a user