File size: 10,630 Bytes
ecb7e47 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | // Online play with real people, no server of our own: WebRTC peer-to-peer through the free PeerJS broker.
// A room is a well-known peer id; the first player to claim it is the host and relays everyone's car to everyone.
// If the host leaves, the others race to claim it again. Voice chat is a mesh of audio calls, off until you
// switch your mic on, and you hear people louder the closer their car is. Chat is quick emotes only (strangers
// can join the public city), and names are short and filtered.
const PREFIX = "voidcity-awsaa-v1-";
const ICE = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }, { urls: "stun:stun1.l.google.com:19302" }, { urls: "stun:global.stun.twilio.com:3478" }] };
const BAD = /(fuck|shit|bitch|nigg|cunt|dick|pussy|sex|porn|kill|nazi|hitler|rape|fag)/i;
export const cleanName = s => { s = String(s || "").replace(/[^\p{L}\p{N} _-]/gu, "").trim().slice(0, 14); return !s || BAD.test(s) ? "Driver" : s; };
// what strangers may send: only these events, only sane numbers, and not too often
const EVENTS = new Set(["race", "emote"]);
const num = (v, lim) => Number.isFinite(v) && Math.abs(v) <= lim;
const okState = st => st && Array.isArray(st.p) && st.p.length === 6 && num(st.p[0], 8000) && num(st.p[1], 4000) && num(st.p[2], 8000) && st.p.slice(3).every(v => num(v, 50));
const flood = c => { const now = performance.now(); if (now - (c._t || 0) > 1000) { c._t = now; c._n = 0; } return ++c._n > 60; };
export const EMOTES = ["👋 Hi!", "🏁 Race?", "😂", "🔥 Nice!", "GG", "🚓 Cops!", "Follow me", "Wait up"];
export function createNet() {
let Peer = null, peer = null, host = false, me = null, room = null, name = "Driver", hostConn = null, sendT = 0, lastState = null, closing = false;
const conns = new Map(); // host: everyone's connection
const players = new Map(); // id -> { id, name, s: [samples], model, paint, race, rp, rf, vc, emote, emoteT }
const handlers = {};
const emit = (k, ...a) => (handlers[k] || []).forEach(f => f(...a));
const status = t => emit("status", t);
let stream = null, micOn = false, calls = new Map(), audios = new Map();
async function lib() { if (!Peer) ({ Peer } = await import("https://cdn.jsdelivr.net/npm/peerjs@1.5.4/+esm")); return Peer; }
async function join(r, nm) {
await leave(); closing = false; room = String(r).toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 24) || "public-1"; name = cleanName(nm);
status("Connecting…"); await lib(); claim();
}
function claim() {
if (closing) return;
const p = new Peer(PREFIX + room, { config: ICE, debug: 0 }); peer = p;
p.on("open", id => { host = true; me = id; status(`Online · room ${room} · you are hosting`); emit("change"); });
p.on("connection", c => {
c.on("data", d => onData(c, d));
c.on("open", () => { if (conns.size >= 11) { c.send({ t: "full" }); setTimeout(() => c.close(), 300); return; } conns.set(c.peer, c); emit("change"); });
c.on("close", () => { conns.delete(c.peer); players.delete(c.peer); dropAudio(c.peer); emit("change"); broadcast({ t: "gone", id: c.peer }); });
});
p.on("call", answer);
p.on("error", e => {
if (e.type === "unavailable-id") { p.destroy(); client(); }
else if (e.type === "peer-unavailable") { status("Room not found, retrying…"); setTimeout(() => rejoin(), 800 + Math.random() * 1500); }
else status("Network: " + e.type);
});
}
function client() {
if (closing) return;
const p = new Peer({ config: ICE, debug: 0 }); peer = p; host = false;
p.on("open", id => {
me = id; const c = p.connect(PREFIX + room, { serialization: "json", reliable: false }); hostConn = c;
c.on("open", () => { status(`Online · room ${room}`); c.send({ t: "hello", name }); emit("change"); });
c.on("data", d => onData(c, d));
c.on("close", () => { if (!closing) { status("Host left, reconnecting…"); players.clear(); emit("change"); setTimeout(rejoin, Math.random() * 1500); } });
});
p.on("call", answer);
p.on("error", e => { if (e.type === "peer-unavailable") setTimeout(rejoin, 400 + Math.random() * 1200); else status("Network: " + e.type); });
}
function rejoin() { if (closing) return; try { peer && peer.destroy(); } catch { } hostConn = null; conns.clear(); claim(); }
async function leave() {
closing = true; try { peer && peer.destroy(); } catch { } peer = null; hostConn = null; host = false; conns.clear(); players.clear();
for (const id of [...audios.keys()]) dropAudio(id); setMic(false); room = null; emit("change"); status("Offline");
}
function broadcast(m, except) { for (const [id, c] of conns) if (id !== except && c.open) try { c.send(m); } catch { } }
function upsert(id, st) {
let p = players.get(id), fresh = !p; if (!p) { p = { id, name: "Driver", s: [] }; players.set(id, p); }
if (st.n && cleanName(st.n) !== p.name) { p.name = cleanName(st.n); fresh = true; }
Object.assign(p, { model: Number.isInteger(st.m) && st.m >= 0 && st.m < 20 ? st.m : 0, paint: /^#[0-9a-f]{6}$/i.test(st.c) ? st.c : "#888888", race: typeof st.r === "string" ? st.r.slice(0, 20) : 0, rp: num(st.rp, 1e7) ? st.rp : 0, rf: num(st.rf, 1e7) ? st.rf : 0, vc: !!st.vc, wanted: num(st.w, 5) ? st.w | 0 : 0 });
p.s.push({ t: performance.now(), x: st.p[0], y: st.p[1], z: st.p[2], yaw: st.p[3], pitch: st.p[4], roll: st.p[5], v: num(st.v, 2000) ? st.v : 0, air: !!st.a });
if (p.s.length > 12) p.s.shift();
if (fresh) emit("change");
}
function onData(c, d) {
if (!d || typeof d !== "object" || flood(c)) return;
if (d.t === "ev" && !EVENTS.has(d.k)) return;
if (host) {
if (d.t === "hello") { const p = players.get(c.peer) || { id: c.peer, s: [] }; p.name = cleanName(d.name); players.set(c.peer, p); emit("change"); return; }
if (d.t === "s") { if (okState(d)) upsert(c.peer, d); return; }
if (d.t === "ev") { broadcast({ ...d, from: c.peer }, c.peer); emit(d.k, d, c.peer); return; }
} else {
if (d.t === "w") { if (Array.isArray(d.all)) for (const st of d.all.slice(0, 16)) if (st && typeof st.id === "string" && st.id !== me && okState(st)) upsert(st.id, st); return; }
if (d.t === "gone") { players.delete(d.id); dropAudio(d.id); emit("change"); return; }
if (d.t === "full") { status("Room full, trying the next one"); room = room.replace(/(\d+)$/, n => +n + 1); rejoin(); return; }
if (d.t === "ev") { emit(d.k, d, d.from); return; }
}
}
// voice: everyone with their mic on calls everyone; people without a mic still hear
function answer(call) { call.answer(stream || undefined); hook(call); }
function hook(call) {
calls.set(call.peer, call);
call.on("stream", s => { let a = audios.get(call.peer); if (!a) { a = new Audio(); a.autoplay = true; audios.set(call.peer, a); } a.srcObject = s; a.play().catch(() => { }); });
call.on("close", () => { calls.delete(call.peer); });
}
function dropAudio(id) { const a = audios.get(id); if (a) { a.srcObject = null; audios.delete(id); } const c = calls.get(id); if (c) { try { c.close(); } catch { } calls.delete(id); } }
async function setMic(on) {
if (on && !stream) { try { stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); } catch { status("Microphone blocked"); emit("mic", false); return false; } }
micOn = on;
if (stream) for (const t of stream.getAudioTracks()) t.enabled = on;
if (on && peer) for (const p of players.values()) if (!calls.has(p.id)) { try { hook(peer.call(p.id, stream)); } catch { } }
if (!on && !stream) { }
emit("mic", on); return on;
}
// players who went quiet (closed the tab, lost signal) are dropped after 15 s instead of standing still forever
setInterval(() => { const now = performance.now(); let gone = false; for (const p of [...players.values()]) { const last = p.s.length ? p.s[p.s.length - 1].t : p.seen || (p.seen = now); if (now - last > 15000) { players.delete(p.id); dropAudio(p.id); gone = true; } } if (gone) emit("change"); }, 3000);
return {
join, leave, setMic, EMOTES,
on(k, f) { (handlers[k] = handlers[k] || []).push(f); },
online: () => !!(peer && me && (host || (hostConn && hostConn.open))),
get host() { return host; }, get room() { return room; }, get me() { return me; }, get mic() { return micOn; }, get name() { return name; },
players: () => [...players.values()],
nameOf: id => (players.get(id) || {}).name || "Someone",
send(m) { // a one-off event for everyone (race start, emote)
const ev = { ...m, t: "ev", k: m.t };
if (host) broadcast({ ...ev, from: me }); else if (hostConn && hostConn.open) hostConn.send(ev);
},
// ~12 times a second: my car to the host; the host sends everyone to everyone
tick(dt, mine) {
if (!peer || !me) return;
lastState = mine; sendT += dt; if (sendT < 1 / 12) return; sendT = 0;
const st = mine && { t: "s", n: name, ...mine, vc: micOn };
if (host) { const all = [...(st ? [{ id: me, ...st }] : []), ...[...players.values()].filter(p => p.s.length).map(p => { const q = p.s[p.s.length - 1]; return { id: p.id, n: p.name, p: [q.x, q.y, q.z, q.yaw, q.pitch, q.roll], m: p.model, c: p.paint, r: p.race, rp: p.rp, rf: p.rf, vc: p.vc, v: q.v, a: q.air, w: p.wanted }; })]; broadcast({ t: "w", all }); }
else if (st && hostConn && hostConn.open) try { hostConn.send(st); } catch { }
},
// where a player's car should be drawn now: 120 ms behind, between two samples
sample(p) {
const t = performance.now() - 120, s = p.s; if (!s.length) return null;
let a = s[0], b = s[s.length - 1];
for (let k = 0; k < s.length - 1; k++) if (s[k].t <= t && s[k + 1].t >= t) { a = s[k]; b = s[k + 1]; break; }
const u = b.t > a.t ? Math.max(0, Math.min(1.5, (t - a.t) / (b.t - a.t))) : 1, L = (x, y) => x + (y - x) * u;
const ay = (x, y) => { let d = y - x; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return x + d * u; };
return { x: L(a.x, b.x), y: L(a.y, b.y), z: L(a.z, b.z), yaw: ay(a.yaw, b.yaw), pitch: L(a.pitch, b.pitch), roll: L(a.roll, b.roll), v: b.v, air: b.air, stale: performance.now() - s[s.length - 1].t > 4000 };
},
setVolume(id, v) { const a = audios.get(id); if (a) a.volume = Math.max(0, Math.min(1, v)); },
};
}
|