openvibers/breakroomsimulator Commits

Hall of Records board, health/combat system, gmod physgun grip, faster days, smooth driving

OP OpenVibers committed 29 Jul 2026 · 9401da1341 · 7 files, +276 −67

- Spawn sign rebuilt as the PAE2 HALL OF RECORDS: posts at the edges (no
  pole through the numbers), glowing all-time badge-in odometer, and a
  growing stat grid — naps, marks, props spawned, trees flattened, items
  dropped/burned, joyrides, lambo finds, games won, incidents — plus
  'day N of forever'. Server now tracks all of these lifetime counters.
- Health system: 100hp, fists (F/click with empty hand) + melee damage
  table (wrench 15, broom 12, paddle 10, tube/tapegun 8, fists 6, banana 4),
  server-validated hits with range + cooldown. Death spills the victim's
  whole inventory as world drops where they fell, clears it server-side,
  and respawns them at the walkway with a red flash + kill feed. Sleepers
  can be murdered too (fragile 40hp) — their stuff spills where they slept.
- Physgun gmod grip: held objects rotate with your view yaw and otherwise
  hold their pose (angular velocity pinned unless R-spinning); grab records
  the yaw baseline. Car walking-collider now derives from the body's true
  AABB, fixing the messed-up footprint after physgun stunts (and a crash:
  cannon is updateAABB, not computeAABB).
- Driving smoothness: physics now steps BEFORE the camera/avatar read
  positions (the seat lagged the visual car by a frame = chop), and all
  visual syncing uses cannon's interpolated transforms instead of raw
  fixed-step poses.
- Day/night cycle 24min -> 8min; sandbox hint chat moved from join to
  physgun pickup.

Co-Authored-By: Claude Fable 5 <[email protected]>

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex d0e5119..b4cc301 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -143,3 +143,8 @@ body.touch #hotbar { bottom: calc(96px + env(safe-area-inset-bottom)); }
#tag-form .tag-pen { font-size: 1.2rem; }
#tag-form input { background: transparent; border: none; outline: none; color: #ffd34d; font-family: inherit; font-size: 1rem; width: 340px; font-style: italic; }
#tag-form input::placeholder { color: rgba(255,211,77,.45); }
++
++/* ---------- health ---------- */
++#hp-wrap { position: absolute; bottom: 98px; left: 50%; transform: translateX(-50%); width: 340px; height: 10px; background: rgba(10,14,20,.6); border: 1px solid rgba(255,255,255,.18); border-radius: 6px; overflow: hidden; z-index: 12; }
++#hp-fill { height: 100%; width: 100%; background: #37e06f; transition: width .18s ease, background .3s ease; }
++#dmg-flash { position: absolute; inset: 0; background: radial-gradient(ellipse at center, rgba(226,38,45,0) 42%, rgba(226,38,45,.75) 100%); opacity: 0; pointer-events: none; transition: opacity .35s ease; z-index: 40; }
ddiff --git a/public/js/lighting.js b/public/js/lighting.js
iindex 2ad8d34..a51bb70 100644
---- a/public/js/lighting.js
++++ b/public/js/lighting.js
@@@ -4,7 +4,7 @@
import * as THREE from 'three';
import { W } from './world.js';
--const DAY_LEN = 1440; // seconds per in-game day
++const DAY_LEN = 480; // seconds per in-game day — 8 minutes, brisk enough to watch
export function timeOfDay() { // 0 = midnight, .5 = noon
return (Date.now() / 1000 % DAY_LEN) / DAY_LEN;
ddiff --git a/public/js/main.js b/public/js/main.js
iindex 7ee5b32..73207c5 100644
---- a/public/js/main.js
++++ b/public/js/main.js
@@@ -323,7 +323,7 @@ function start(token, user) {
};
function doSwing() {
-- if (!my.held || ITEMS[my.held]?.type !== 'melee') return;
++ if (my.held && ITEMS[my.held]?.type !== 'melee') return; // empty hand = fists
myAvatar.swing();
vmSwing = 1;
net.send({ t: 'swing' });
@@@ -331,6 +331,29 @@ function start(token, user) {
// send nearby physics props flying
const fx = -Math.sin(my.yaw), fz = -Math.cos(my.yaw);
if (phys.smack(my.x, my.y, my.z, fx, fz)) beep(160, .08, 'square', .09);
++ // did we clock somebody? nearest target in a front cone
++ let victim = null, bestD = 1.9;
++ for (const [id, o] of others) {
++ const g = o.avatar.group.position;
++ const ddx = g.x - my.x, ddz = g.z - my.z;
++ const d2 = Math.hypot(ddx, ddz);
++ if (d2 > bestD || (ddx * fx + ddz * fz) / (d2 || 1) < .5) continue;
++ bestD = d2; victim = id;
++ }
++ if (victim != null) {
++ net.send({ t: 'hit', target: victim, item: my.held });
++ beep(320, .06, 'square', .1);
++ return;
++ }
++ for (const [key, s] of sleeperMap) { // sleepers are fair game
++ const g = s.root.position;
++ const ddx = g.x - my.x, ddz = g.z - my.z;
++ const d2 = Math.hypot(ddx, ddz);
++ if (d2 > 1.9 || (ddx * fx + ddz * fz) / (d2 || 1) < .35) continue;
++ net.send({ t: 'hit', sleeper: key, item: my.held });
++ beep(280, .06, 'square', .09);
++ break;
++ }
}
net.on('swing', (m) => {
if (m.id === myId) return;
@@@ -339,7 +362,7 @@ function start(token, user) {
});
function useKey() {
const r = invApi.useSelected();
-- if (!r) return;
++ if (!r) { doSwing(); return; } // nothing selected: throw hands
if (r.melee === 'physgun') { toast('🧲 Hold left-click to grab · wheel push/pull · R spin · X delete · Q spawns props'); return; }
if (r.melee === 'flashlight') { toast('🔦 Lights wherever you look — best after dark.'); return; }
if (r.melee) { doSwing(); return; }
@@@ -427,38 +450,67 @@ function start(token, user) {
(m.props || []).forEach(pr => phys.add(pr));
(m.tags || []).forEach(addMark);
(m.drops || []).forEach(addWorldDrop);
-- // retro associate counter by the front walkway — proof the place remembers
++ // the HALL OF RECORDS by the front walkway — numbers that only ever grow
if (m.visitorNum) {
-- const pad6 = (v) => String(Math.min(v, 999999)).padStart(6, '0');
-- const signTex = ct(512, 224, (g, w, h) => {
-- g.fillStyle = '#101418'; g.fillRect(0, 0, w, h);
-- g.strokeStyle = '#2c343e'; g.lineWidth = 8; g.strokeRect(4, 4, w - 8, h - 8);
++ const pad6 = (v) => String(Math.min(v ?? 0, 999999)).padStart(6, '0');
++ const st = m.stats || {};
++ const dayN = Math.max(1, Math.floor((Date.now() - (st.since || Date.now())) / 864e5) + 1);
++ const signTex = ct(1024, 512, (g, w, h) => {
++ g.fillStyle = '#0d1116'; g.fillRect(0, 0, w, h);
++ const grd = g.createLinearGradient(0, 0, 0, h);
++ grd.addColorStop(0, 'rgba(255,153,0,.08)'); grd.addColorStop(.4, 'rgba(0,0,0,0)');
++ g.fillStyle = grd; g.fillRect(0, 0, w, h);
++ g.strokeStyle = '#2c343e'; g.lineWidth = 10; g.strokeRect(5, 5, w - 10, h - 10);
g.textAlign = 'center';
-- g.fillStyle = '#8fa0b3'; g.font = '700 26px "Segoe UI", sans-serif';
-- g.fillText(`YOU ARE ASSOCIATE Nº ${m.visitorNum}`, w / 2, 44);
-- g.fillStyle = '#5d6b7a'; g.font = '700 22px "Segoe UI", sans-serif';
-- g.fillText('TOTAL BADGE-INS, ALL TIME', w / 2, 84);
-- g.fillStyle = '#ffb52e'; g.font = '700 76px "Consolas", monospace';
-- g.shadowColor = '#ffb52e'; g.shadowBlur = 18;
-- g.fillText(pad6(m.stats?.joins ?? m.visitorNum), w / 2, 154);
++ g.fillStyle = '#ff9900'; g.font = '800 46px "Segoe UI", sans-serif';
++ g.fillText('PAE2 · HALL OF RECORDS', w / 2, 66);
++ g.fillStyle = '#5d6b7a'; g.font = '600 27px "Segoe UI", sans-serif';
++ g.fillText(`you are associate № ${m.visitorNum} · day ${dayN} of forever`, w / 2, 108);
++ g.fillStyle = '#ffb52e'; g.font = '700 108px Consolas, monospace';
++ g.shadowColor = '#ffb52e'; g.shadowBlur = 24;
++ g.fillText(pad6(st.joins ?? m.visitorNum), w / 2, 222);
g.shadowBlur = 0;
-- g.fillStyle = '#5d6b7a'; g.font = '22px "Consolas", monospace';
-- g.fillText(`naps: ${m.stats?.naps ?? 0} · marks: ${m.stats?.marks ?? 0} · the lights stay on`, w / 2, 196);
++ g.fillStyle = '#8fa0b3'; g.font = '700 26px "Segoe UI", sans-serif';
++ g.fillText('TOTAL BADGE-INS, ALL TIME', w / 2, 262);
++ const rows = [
++ ['💤 naps taken', st.naps], ['🖊️ marks left', st.marks],
++ ['📦 props spawned', st.props], ['🌳 trees flattened', st.knocks],
++ ['🎒 items dropped', st.drops], ['🔥 items burned', st.burns],
++ ['🚗 joyrides', st.joyrides], ['🏎️ lambo found', st.lambo],
++ ['🏆 games won', st.wins], ['💀 incidents', st.kills],
++ ];
++ rows.forEach(([label, v], i) => {
++ const cx = (i % 2) ? w * .73 : w * .27;
++ const cy = 316 + Math.floor(i / 2) * 38;
++ g.fillStyle = '#9fb0c3'; g.font = '600 26px "Segoe UI", sans-serif'; g.textAlign = 'right';
++ g.fillText(label, cx + 30, cy);
++ g.fillStyle = '#ffd34d'; g.font = '700 26px Consolas, monospace'; g.textAlign = 'left';
++ g.fillText(String(v ?? 0), cx + 48, cy);
++ });
++ g.textAlign = 'center';
++ g.fillStyle = '#44515f'; g.font = 'italic 23px "Segoe UI", sans-serif';
++ g.fillText('nothing here resets · the lights stay on', w / 2, 496);
});
const sg = new THREE.Group();
-- const post = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, 2.0, 8), new THREE.MeshStandardMaterial({ color: 0x2a2f36, roughness: .6 }));
-- post.position.y = 1.0;
-- sg.add(post);
-- const panel = new THREE.Mesh(new THREE.PlaneGeometry(2.3, 1.0), new THREE.MeshBasicMaterial({ map: signTex }));
-- panel.position.y = 2.1;
++ const postM = new THREE.MeshStandardMaterial({ color: 0x2a2f36, roughness: .6 });
++ for (const px of [-1.6, 1.6]) { // posts at the EDGES — nothing through the numbers
++ const post = new THREE.Mesh(new THREE.CylinderGeometry(.06, .06, 2.6, 8), postM);
++ post.position.set(px, 1.3, 0);
++ sg.add(post);
++ }
++ const panel = new THREE.Mesh(new THREE.PlaneGeometry(3.7, 1.85), new THREE.MeshBasicMaterial({ map: signTex }));
++ panel.position.y = 2.6;
sg.add(panel);
-- sg.position.set(103, 0, 6.4);
++ const back = new THREE.Mesh(new THREE.PlaneGeometry(3.7, 1.85), new THREE.MeshStandardMaterial({ color: 0x181d23, roughness: .8, side: THREE.BackSide }));
++ back.position.set(0, 2.6, .012);
++ sg.add(back);
++ sg.position.set(103, 0, 6.6);
sg.rotation.y = Math.PI;
scene.add(sg);
-- W.colliders.push({ x0: 102.7, x1: 103.3, z0: 6.1, z1: 6.7 });
++ W.colliders.push({ x0: 101.2, x1: 101.6, z0: 6.4, z1: 6.8 }, { x0: 104.4, x1: 104.8, z0: 6.4, z1: 6.8 });
addChat(`📟 You are associate №${m.visitorNum}. The break room remembers everyone.`, 'sys');
-- addChat('🖊️ G leaves a permanent mark · Q spawns props · H (or right-click a slot) drops items · the 🧲 is out on 172nd St.', 'sys');
}
++ setHp(m.hp ?? 100, false);
if (me.admin && !editor) editor = initEditor({ scene, camera, props, toast });
$('online-count').textContent = m.online;
for (const p of m.players) addOther(p);
@@@ -493,6 +545,34 @@ function start(token, user) {
else others.get(m.id)?.avatar.say(m.text);
});
net.on('sys', (m) => addChat(esc(m.text), 'sys'));
++
++ // ---------- health ----------
++ const hpFill = $('hp-fill'), dmgFlash = $('dmg-flash');
++ function setHp(v, hurt) {
++ hpFill.style.width = Math.max(0, Math.min(100, v)) + '%';
++ hpFill.style.background = v > 60 ? '#37e06f' : v > 30 ? '#f2c521' : '#e2262d';
++ if (hurt) {
++ dmgFlash.style.opacity = .6;
++ setTimeout(() => { dmgFlash.style.opacity = 0; }, 130);
++ beep(170, .1, 'sawtooth', .13);
++ }
++ }
++ net.on('hp', (m) => { if (m.id === myId) setHp(m.hp, m.by != null); });
++ net.on('died', (m) => {
++ setHp(100, false);
++ my.x = m.x; my.z = m.z; my.y = 0; my.vy = 0;
++ my.vel.x = 0; my.vel.z = 0;
++ my.held = null;
++ myAvatar.setHeld(null);
++ net.send({ t: 'held', item: null });
++ invApi.restore(new Array(24).fill(null), new Array(6).fill(null));
++ updateViewmodel();
++ dmgFlash.style.opacity = .9;
++ setTimeout(() => { dmgFlash.style.opacity = 0; }, 700);
++ toast(`💀 ${m.by} got you. Your stuff is scattered where you fell.`, 5600);
++ beep(90, .5, 'sawtooth', .18);
++ sendPos(true);
++ });
net.on('edit', (m) => props.applyServer(m));
net.on('vest', (m) => {
if (m.id === myId) myAvatar.setVest(m.vest);
@@@ -583,6 +663,7 @@ function start(token, user) {
}
function burnItems(id, n = 1) {
toast(`🔥 ${ITEMS[id].icon} ${ITEMS[id].name}${n > 1 ? ' ×' + n : ''} went up in flames`, 3200);
++ net.send({ t: 'burn', n }); // hall-of-records bookkeeping
beep(200, .25, 'sawtooth', .1);
setTimeout(() => beep(140, .3, 'sawtooth', .06), 180);
}
@@@ -749,6 +830,7 @@ function start(token, user) {
const hit = pgPick();
if (!hit) return;
pgState.held = hit.e;
++ pgState.lastYaw = my.yaw; // view-follow rotation baseline
pgState.dist = Math.max(1.6, Math.min(hit.e.isCar ? 16 : 12, hit.distance));
hit.e.grabbedBy = myId;
phys.claim(hit.e);
@@@ -1037,6 +1119,8 @@ function start(token, user) {
if (invApi.add(nearest.data.item)) {
toast(`${ITEMS[nearest.data.item].icon} ${ITEMS[nearest.data.item].name} → inventory`);
beep(600, .06, 'sine', .1);
++ if (nearest.data.item === 'physgun') // the sandbox manual comes with the tool
++ addChat('🧲 Hold left-click to grab (props & empty cars) · wheel push/pull · R spin · X delete · Q spawns props · H drops items · G leaves a permanent mark', 'sys');
}
return;
case 'dropitem':
@@@ -1106,8 +1190,8 @@ function start(token, user) {
const ndc = new THREE.Vector2((sx / innerWidth) * 2 - 1, -(sy / innerHeight) * 2 + 1);
raycaster.setFromCamera(ndc, camera);
if (mg.worldClick(raycaster)) return;
-- // clicking with a melee prop equipped = attack (first OR third person)
-- if (my.held && ITEMS[my.held]?.type === 'melee') doSwing();
++ // clicking with a melee prop (or bare fists) = attack, first OR third person
++ if (!my.held || ITEMS[my.held]?.type === 'melee') doSwing();
};
// ---------- movement & camera ----------
@@@ -1226,6 +1310,41 @@ function start(token, user) {
}
}
++ // ---- sandbox physics: step BEFORE the camera/avatar read positions, so
++ // the car you're riding and the mesh you see are the same frame (chop fix)
++ if (pgState.held) {
++ const held = pgState.held;
++ if (!pgEquipped() || !(held.isCar ? phys.cars : phys.props).has(held.id)) physgunRelease(false);
++ else { // drag the grabbed body toward the point pgState.dist along the view ray
++ const b = held.body;
++ b.wakeUp();
++ const dir2 = camera.getWorldDirection(beamV);
++ const tx3 = camera.position.x + dir2.x * pgState.dist;
++ const ty3 = Math.max(.3, camera.position.y + dir2.y * pgState.dist);
++ const tz3 = camera.position.z + dir2.z * pgState.dist;
++ let vx3 = (tx3 - b.position.x) * 10, vy3 = (ty3 - b.position.y) * 10, vz3 = (tz3 - b.position.z) * 10;
++ const vl = Math.hypot(vx3, vy3, vz3);
++ if (vl > 22) { vx3 *= 22 / vl; vy3 *= 22 / vl; vz3 *= 22 / vl; }
++ b.velocity.set(vx3, vy3, vz3);
++ // gmod grip: the object turns with your view and otherwise holds its pose
++ const dyaw = my.yaw - (pgState.lastYaw ?? my.yaw);
++ pgState.lastYaw = my.yaw;
++ if (input.keys.KeyR) b.angularVelocity.set(0, 2.8, 0);
++ else {
++ b.angularVelocity.set(0, 0, 0);
++ if (Math.abs(dyaw) > 1e-4) phys.yawBody(held, dyaw);
++ }
++ }
++ }
++ phys.step(dt, my);
++ if (carState.driving) { // re-sync the seat to the freshly stepped chassis
++ const car = carState.driving;
++ const ce2 = phys.cars.get(car.id);
++ my.x = car.x; my.z = car.z;
++ my.y = Math.max(0, ce2.body.position.y - .15);
++ my.ry = car.ry;
++ }
++
// avatar
myAvatar.group.position.set(my.x, my.y + (myAvatar.bobY || 0), my.z);
myAvatar.group.rotation.y = my.ry;
@@@ -1306,25 +1425,6 @@ function start(token, user) {
g.pr.rotation.y += (target - g.pr.rotation.y) * Math.min(1, dt * 8);
}
-- // ---- sandbox physics ----
-- if (pgState.held) {
-- const held = pgState.held;
-- if (!pgEquipped() || !(held.isCar ? phys.cars : phys.props).has(held.id)) physgunRelease(false);
-- else { // drag the grabbed prop toward the point pgState.dist along the view ray
-- const b = held.body;
-- b.wakeUp();
-- const dir2 = camera.getWorldDirection(beamV);
-- const tx3 = camera.position.x + dir2.x * pgState.dist;
-- const ty3 = Math.max(.3, camera.position.y + dir2.y * pgState.dist);
-- const tz3 = camera.position.z + dir2.z * pgState.dist;
-- let vx3 = (tx3 - b.position.x) * 10, vy3 = (ty3 - b.position.y) * 10, vz3 = (tz3 - b.position.z) * 10;
-- const vl = Math.hypot(vx3, vy3, vz3);
-- if (vl > 22) { vx3 *= 22 / vl; vy3 *= 22 / vl; vz3 *= 22 / vl; }
-- b.velocity.set(vx3, vy3, vz3);
-- if (input.keys.KeyR) b.angularVelocity.set(0, 2.8, 0);
-- }
-- }
-- phys.step(dt, my);
updateBeams();
// sleepers dream
ddiff --git a/public/js/physics.js b/public/js/physics.js
iindex 123cab4..259f995 100644
---- a/public/js/physics.js
++++ b/public/js/physics.js
@@@ -368,18 +368,21 @@ export function initPhysics(scene) {
b.velocity.set(0, 0, 0);
b.angularVelocity.set(0, 0, 0);
}
-- const off = b.quaternion.vmult(V(0, CAR_HH, 0));
-- car.group.position.set(b.position.x - off.x, b.position.y - off.y, b.position.z - off.z);
-- car.group.quaternion.copy(b.quaternion);
-- const f = b.quaternion.vmult(V(0, 0, 1));
++ // visuals from the interpolated pose (smooth); logic from it too so the
++ // camera riding car.x never disagrees with the mesh
++ const ip = b.interpolatedPosition, iq = b.interpolatedQuaternion;
++ const off = iq.vmult(V(0, CAR_HH, 0));
++ car.group.position.set(ip.x - off.x, ip.y - off.y, ip.z - off.z);
++ car.group.quaternion.copy(iq);
++ const f = iq.vmult(V(0, 0, 1));
car.ry = Math.atan2(f.x, f.z);
-- car.x = b.position.x;
-- car.z = b.position.z;
-- const s = Math.abs(Math.sin(car.ry)), c = Math.abs(Math.cos(car.ry));
-- const hl = car.hl || 2.15, hw = car.hw || 1.0;
-- const ex = hl * s + hw * c, ez = hl * c + hw * s;
-- car.col.x0 = car.x - ex; car.col.x1 = car.x + ex;
-- car.col.z0 = car.z - ez; car.col.z1 = car.z + ez;
++ car.x = ip.x;
++ car.z = ip.z;
++ // walking collider from the body's true AABB — correct even when the
++ // car ends up tilted or on its roof (physgun aftermath)
++ b.updateAABB();
++ car.col.x0 = b.aabb.lowerBound.x; car.col.x1 = b.aabb.upperBound.x;
++ car.col.z0 = b.aabb.lowerBound.z; car.col.z1 = b.aabb.upperBound.z;
if (car.inter) { car.inter.x = car.x; car.inter.z = car.z; }
}
for (const e of props.values()) {
@@@ -392,8 +395,10 @@ export function initPhysics(scene) {
b.velocity.set(0, 0, 0);
b.angularVelocity.set(0, 0, 0);
}
-- e.mesh.position.copy(b.position);
-- e.mesh.quaternion.copy(b.quaternion);
++ // interpolated pose: fixed-step positions quantize to 60Hz and read as
++ // chop against an unaligned rAF — cannon keeps smooth in-between values
++ e.mesh.position.copy(b.interpolatedPosition);
++ e.mesh.quaternion.copy(b.interpolatedQuaternion);
}
// stream my awake props + driverless cars I've been shoving, at ~12Hz
sendT += dt;
@@@ -423,9 +428,16 @@ export function initPhysics(scene) {
return null;
}
++ // gmod-style: rotate a held body's yaw in place (view-follow / R-spin)
++ const yawQ = new CANNON.Quaternion();
++ function yawBody(e, dyaw) {
++ yawQ.setFromAxisAngle(V(0, 1, 0), dyaw);
++ yawQ.mult(e.body.quaternion, e.body.quaternion);
++ }
++
return {
world, props, cars, add, remove, claim, applyState, applyCarState, sendState, sendCarState,
-- drive, step, smack, raycast, PHYS_KINDS,
++ drive, step, smack, raycast, yawBody, PHYS_KINDS,
setMyId(id) { myId = id; },
};
}
ddiff --git a/public/play.html b/public/play.html
iindex 6448346..82f3882 100644
---- a/public/play.html
++++ b/public/play.html
@@@ -62,6 +62,10 @@
<button id="btn-chat" class="mbtn">💬</button>
</div>
++ <!-- health -->
++ <div id="hp-wrap"><div id="hp-fill"></div></div>
++ <div id="dmg-flash"></div>
++
<!-- hotbar -->
<div id="hotbar"></div>
ddiff --git a/server/server.js b/server/server.js
iindex 0782137..34ad488 100644
---- a/server/server.js
++++ b/server/server.js
@@@ -72,7 +72,7 @@ const pongEvents = (ev, data) => {
if (ev === 'gameover') {
const table = pong[data.id];
const w = table?.seats?.[data.winner];
-- if (w) store.addWin(w.key, 'pongWins');
++ if (w) { store.addWin(w.key, 'pongWins'); stats.wins++; worldDirty = true; }
sys(`🏓 ${data.winnerName} wins ping pong ${data.score[data.winner]}-${data.score[1 - data.winner]}!`);
}
};
@@@ -113,8 +113,11 @@ let nextDropId = [...drops.keys()].reduce((m, id) => Math.max(m, Number(String(i
// permanent marker tags — the "I was here" layer. They outlive everyone.
const tags = Array.isArray(savedWorld.tags) ? savedWorld.tags : [];
let nextTagId = tags.reduce((m, t) => Math.max(m, Number(String(t.id).slice(1)) || 0), 0) + 1;
--// lifetime counters for the retro hit-counter energy
--const stats = Object.assign({ joins: 0, naps: 0 }, savedWorld.stats || {});
++// lifetime counters for the hall-of-records board — numbers that only grow
++const stats = Object.assign(
++ { joins: 0, naps: 0, props: 0, knocks: 0, drops: 0, joyrides: 0, lambo: 0, burns: 0, kills: 0, wins: 0, since: Date.now() },
++ savedWorld.stats || {});
++stats.since ||= Date.now();
function pruneSleepers() { // nappers expire after a week; keep the 60 most recent
const cutoff = Date.now() - 7 * 864e5;
for (const [k, s] of sleepers) if ((s.ts || 0) < cutoff) sleepers.delete(k);
@@@ -151,6 +154,23 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
});
}
++// a victim's whole inventory scatters on the floor around where they fell
++function spillInventory(key, x, z) {
++ const u = store.getInventory(key);
++ if (!u) return;
++ for (const s of [...u.inv, ...u.hotbar]) {
++ if (!s || drops.size >= 200) continue;
++ const d = {
++ id: 'd' + nextDropId++, item: s.id, n: s.n || 1,
++ x: clampX(+(x + (Math.random() - .5) * 2.2).toFixed(2)), y: .35, z: clampZ(+(z + (Math.random() - .5) * 2.2).toFixed(2)),
++ };
++ drops.set(d.id, d);
++ broadcast({ t: 'drop', op: 'add', d });
++ }
++ store.clearInventory(key);
++ worldDirty = true;
++}
++
function pubState(p) {
return { id: p.id, name: p.name, vest: p.vest, ap: p.ap || null, x: p.x, y: p.y, z: p.z, ry: p.ry, anim: p.anim, seat: p.seat, held: p.held };
}
@@@ -170,7 +190,7 @@ wss.on('connection', (ws, req) => {
const p = {
id, key: user.key, name: user.name, vest: user.vest, admin: user.admin, ap: user.ap, guest: !!user.guest, ws,
x: 105 + Math.random() * 3, y: 0, z: -1 + Math.random() * 2, ry: -Math.PI / 2,
-- anim: 'idle', seat: null, held: null, lastChat: 0,
++ anim: 'idle', seat: null, held: null, lastChat: 0, hp: 100,
};
// returning player: wake their napper and resume exactly where they dozed off
const slept = sleepers.get(user.key);
@@@ -186,7 +206,8 @@ wss.on('connection', (ws, req) => {
ws.send(JSON.stringify({
t: 'init', id, you: pubState(p), guest: user.guest, admin: user.admin,
inv: user.inv, hotbar: user.hotbar,
-- visitorNum: stats.joins, stats: { joins: stats.joins, naps: stats.naps, marks: tags.length },
++ visitorNum: stats.joins, stats: { ...stats, marks: tags.length },
++ hp: p.hp,
tags,
drops: [...drops.values()],
mapEdits,
@@@ -299,7 +320,7 @@ function handle(p, m) {
if (r.error) return;
if (g.winner === 0 || g.winner === 1) {
const w = g.seats[g.winner];
-- if (w) { store.addWin(w.key, 'c4Wins'); sys(`🎉 ${w.name} wins giant Connect 4!`); }
++ if (w) { store.addWin(w.key, 'c4Wins'); stats.wins++; worldDirty = true; sys(`🎉 ${w.name} wins giant Connect 4!`); }
}
}
else if (m.op === 'reset') { if (g.seats.some(s => s?.key === p.key)) g.reset(); }
@@@ -314,7 +335,7 @@ function handle(p, m) {
if (r.error) return;
if (r.winnerSeat !== null && r.winnerSeat !== undefined) {
const w = chess.seats[r.winnerSeat];
-- if (w) { store.addWin(w.key, 'chessWins'); sys(`♛ ${w.name} captured the king and wins at chess!`); }
++ if (w) { store.addWin(w.key, 'chessWins'); stats.wins++; worldDirty = true; sys(`♛ ${w.name} captured the king and wins at chess!`); }
}
}
else if (m.op === 'reset') { if (chess.seats.some(s => s?.key === p.key)) chess.reset(); }
@@@ -353,6 +374,9 @@ function handle(p, m) {
if (m.op === 'enter') {
if (c.driver && c.driver !== p.id) return;
c.driver = p.id;
++ stats.joyrides++;
++ if (id === 'lambo') stats.lambo++;
++ worldDirty = true;
broadcast({ t: 'car', id, op: 'enter', driver: p.id });
sys(id === 'lambo' ? `🏎️ ${p.name} found THE LAMBO` : `🚗 ${p.name} got into a car`);
} else if (m.op === 'exit') {
@@@ -385,6 +409,7 @@ function handle(p, m) {
if (pos.length !== 3 || !pos.every(Number.isFinite)) return;
const prop = { id: 'fp' + nextPhysId++, kind: m.kind, p: [clampX(pos[0]), Math.max(0, Math.min(30, pos[1])), clampZ(pos[2])], q: [0, 0, 0, 1] };
props.set(prop.id, prop);
++ stats.props++;
worldDirty = true;
broadcast({ t: 'prop', op: 'add', prop, owner: p.id });
} else if (m.op === 'state') {
@@@ -427,6 +452,7 @@ function handle(p, m) {
x: clampX(+(+m.x || 0).toFixed(2)), y: Math.max(.05, Math.min(8, +(+m.y || 0).toFixed(2))), z: clampZ(+(+m.z || 0).toFixed(2)),
};
drops.set(d.id, d);
++ stats.drops++;
worldDirty = true;
broadcast({ t: 'drop', op: 'add', d });
} else if (m.op === 'take') {
@@@ -439,9 +465,59 @@ function handle(p, m) {
}
break;
}
++ case 'burn': { // hall-of-records bookkeeping for the barrel
++ stats.burns += Math.max(1, Math.min(99, Math.floor(Number(m.n) || 1)));
++ worldDirty = true;
++ break;
++ }
++ case 'hit': { // melee combat — fists and break-room weaponry
++ const now = Date.now();
++ if (now - (p.lastHitAt || 0) < 350) return;
++ p.lastHitAt = now;
++ const DMG = { fists: 6, paddle: 10, broom: 12, tapegun: 8, tube: 8, wrench: 15, banana: 4 };
++ const weapon = m.item == null ? 'fists' : String(m.item);
++ const dmg = DMG[weapon];
++ if (!dmg) return;
++ const label = weapon === 'fists' ? 'their fists' : `a ${weapon}`;
++ if (m.sleeper) { // sleepers are fair game — brutal, but this place remembers
++ const key = String(m.sleeper);
++ const s = sleepers.get(key);
++ if (!s) return;
++ if (Math.hypot(p.x - s.x, p.z - s.z) > 3.4) return;
++ s.hp = (s.hp ?? 40) - dmg; // sleepers are fragile
++ if (s.hp > 0) { worldDirty = true; return; }
++ sleepers.delete(key);
++ spillInventory(key, s.x, s.z);
++ stats.kills++;
++ worldDirty = true;
++ broadcast({ t: 'wake', key });
++ sys(`💀 ${p.name} took out ${s.name} in their sleep with ${label}`);
++ return;
++ }
++ const t = players.get(Number(m.target));
++ if (!t || t.id === p.id) return;
++ if (Math.hypot(p.x - t.x, p.z - t.z) > 3.4) return;
++ t.hp = (t.hp ?? 100) - dmg;
++ if (t.hp > 0) {
++ broadcast({ t: 'hp', id: t.id, hp: t.hp, by: p.id });
++ return;
++ }
++ // death: everything they carried spills where they fell, then respawn
++ stats.kills++;
++ worldDirty = true;
++ spillInventory(t.key, t.x, t.z);
++ t.hp = 100;
++ t.x = 105 + Math.random() * 3; t.y = 0; t.z = -1 + Math.random() * 2;
++ if (t.ws.readyState === 1) t.ws.send(JSON.stringify({ t: 'died', by: p.name, x: t.x, z: t.z }));
++ broadcast({ t: 'hp', id: t.id, hp: 100 });
++ sys(`💀 ${p.name} clocked ${t.name} with ${label} — their stuff hit the floor`);
++ break;
++ }
case 'knock': { // decorative crash relay — no state, knockables respawn
const kid = String(m.id || '').slice(0, 8);
if (!/^k\d+$/.test(kid) || ![m.dx, m.dz].every(Number.isFinite)) return;
++ stats.knocks++;
++ worldDirty = true;
broadcast({ t: 'knock', id: kid, dx: +m.dx.toFixed(2), dz: +m.dz.toFixed(2) }, p.id);
break;
}
ddiff --git a/server/store.js b/server/store.js
iindex 44044f0..1207cd5 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -159,6 +159,18 @@ export function addWin(key, stat) {
saveSoon();
}
++export function getInventory(key) {
++ const u = users[key] || guests.get(key);
++ return u ? { inv: u.inv || [], hotbar: u.hotbar || [] } : null;
++}
++export function clearInventory(key) {
++ const u = users[key] || guests.get(key);
++ if (!u) return;
++ u.inv = [];
++ u.hotbar = [];
++ saveSoon();
++}
++
export function getHighscores() { return highscores; }
export function submitScore(name, score) {