openvibers/breakroomsimulator Commits

Rigid-body cars, physgun feedback + car grabbing, permanent marks, visitor counter

OP OpenVibers committed 28 Jul 2026 · c349f44408 · 6 files, +439 −117

- Cars are cannon-es rigid bodies: they roll, flip, and can be physgunned
  around while empty. Driving is velocity-space arcade control on the chassis
  (the friction solver zeroes tangential velocity of heavy resting boxes under
  any nonzero mu, so chassis-ground friction is exactly 0 and grip/drag live
  in drive()). Full pose (position+quaternion+velocity) streams over the car
  protocol; remote cars follow kinematically and settle dynamic. Old
  {x,z,ry} world-state entries migrate automatically.
- Physgun: gmod-style beam fires on click even with nothing grabbed, dry-fire
  click sound, equip hint toast, and fixed a check that instantly dropped
  car grabs (phys.props.has on a car id).
- Lost-corner-of-the-internet: G writes a permanent marker tag on the floor
  (persisted forever in world-state, 20s cooldown, 600 cap), retro visitor
  sign by the walkway (associate number + nap/mark counters), /api/stats,
  hit counter + copy pass on the landing page.

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

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex 9e8409b..d0e5119 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -137,3 +137,9 @@ body.touch #hotbar { bottom: calc(96px + env(safe-area-inset-bottom)); }
#spawn-grid button { background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.12); border-radius: 10px; color: #dfe7f0; padding: 10px 6px; cursor: pointer; font-size: .78rem; font-family: inherit; }
#spawn-grid button:hover { background: rgba(53,224,255,.14); border-color: #35e0ff; }
#spawn-grid .ico { font-size: 1.5rem; display: block; margin-bottom: 4px; }
++
++/* ---------- permanent marker form (G) ---------- */
++#tag-form { position: absolute; bottom: 140px; left: 50%; transform: translateX(-50%); display: flex; gap: 10px; align-items: center; background: rgba(12,16,22,.92); border: 1px solid rgba(255,211,77,.35); border-radius: 12px; padding: 10px 16px; z-index: 25; box-shadow: 0 8px 30px rgba(0,0,0,.45); }
++#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); }
ddiff --git a/public/index.html b/public/index.html
iindex b8b612b..014ecfb 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -149,14 +149,16 @@
<div class="wrap">
<div class="emoji"><span>🏓</span> <span>🦺</span> <span>♟️</span> <span>🚗</span></div>
<h1>Amazon Break Room<span class="accent">SIMULATOR</span></h1>
-- <p class="sub">Your 15 minutes start now. A free, open-source multiplayer 3D world rebuilt from a
-- real fulfillment center — badge through security, hit the lockers, and meet everyone in the
-- break room for ping pong, giant Connect&nbsp;4, chess and arcade highscores.</p>
++ <p class="sub">A lost corner of the internet shaped like a fulfillment-center break room.
++ The fluorescent lights never turn off, the break never ends, and <em>nothing here resets</em> —
++ leave the tab and your associate curls up for a nap right where you stood. Marker on the floor,
++ boxes in the parking lot, a car on its roof: it's all still there tomorrow.</p>
<div class="cta-row">
-- <a class="btn" href="/play">▶ &nbsp;Play now — free, in your browser</a>
++ <a class="btn" href="/play">▶ &nbsp;Clock in — free, in your browser</a>
<a class="btn ghost" href="https://github.com/OpenVibers/BreakRoomSimulator" target="_blank" rel="noopener">⭐ Star on GitHub</a>
</div>
<p class="note">No download. No install. Works on desktop, phone and tablet. Guest login in one click.</p>
++ <p class="note" id="hit-counter" style="font-family: ui-monospace, monospace; letter-spacing: .12em; color: #ffb52e;"></p>
<div class="shot" id="heroShot"><img src="/img/lp-security.png" alt="The PAE2 security lobby with the front desk island and metal detectors"></div>
</div>
</div>
@@@ -171,7 +173,10 @@
<div class="card rv"><div class="ic">🌐</div><h3>Realtime multiplayer</h3><p>Everyone shares one world over websockets — chat, emotes, and other players walking, driving and playing live around you.</p></div>
<div class="card rv"><div class="ic">🏓</div><h3>Playable minigames</h3><p>Ping pong duels, giant Connect 4, full chess, and the PRIME BREAKER arcade cabinet with global highscores.</p></div>
<div class="card rv"><div class="ic">🗺️</div><h3>True-to-life map</h3><p>Parking lot, badge gates, PAE2 security desk, locker hallway, avenue&nbsp;C market, dining hall, couch corner and the smoke cage.</p></div>
-- <div class="card rv"><div class="ic">🚗</div><h3>Drivable cars</h3><p>Every car in the parking lot starts. Arcade handling, handbrake slides, synced across players.</p></div>
++ <div class="card rv"><div class="ic">🚗</div><h3>Real physics cars</h3><p>Every car is a rigid body — handbrake slides, rolls, full flips. Park it on its roof and it stays that way for the next visitor.</p></div>
++ <div class="card rv"><div class="ic">🧲</div><h3>Physgun sandbox</h3><p>Find the gravity tool abandoned on 172nd St, spawn boxes and watermelons, and rearrange the world gmod-style. Everything you move stays moved.</p></div>
++ <div class="card rv"><div class="ic">💤</div><h3>Nobody ever leaves</h3><p>Log off and your associate naps on the floor exactly where you stood — until you come back and pick up mid-break. The world remembers.</p></div>
++ <div class="card rv"><div class="ic">🖊️</div><h3>Leave your mark</h3><p>Press G and write on the floor in permanent marker. Your words (and your name) stay in the break room forever.</p></div>
<div class="card rv"><div class="ic">🎒</div><h3>Items &amp; loadout</h3><p>24-slot inventory and hotbar with drag &amp; drop — snacks, hats, vests, and break-room "weapons" like paddles and bananas.</p></div>
<div class="card rv"><div class="ic">🕹️</div><h3>Source-style movement</h3><p>First or third person, air-strafing, bunny hops, crouch-sneaking — movement that feels like the classics.</p></div>
<div class="card rv"><div class="ic">🧍</div><h3>Character customization</h3><p>Skin, shirt, hair and hats — plus the mandatory hi-vis vest if you want to look like you belong.</p></div>
@@@ -204,6 +209,9 @@
<tr><td><kbd>Ctrl</kbd></td><td>Crouch / sneak</td></tr>
<tr><td><kbd>1</kbd>–<kbd>6</kbd> / scroll</td><td>Hotbar — equip paddles, snacks and other items</td></tr>
<tr><td><kbd>F</kbd> / click</td><td>Use item / swing</td></tr>
++ <tr><td><kbd>Q</kbd></td><td>Spawn menu — boxes, crates, kickballs, watermelons</td></tr>
++ <tr><td><kbd>G</kbd></td><td>Permanent marker — write on the floor, forever</td></tr>
++ <tr><td>Hold click 🧲</td><td>Physgun (find it on 172nd St) — grab props and empty cars, scroll to push/pull, <kbd>X</kbd> deletes</td></tr>
<tr><td>Scroll in</td><td>First-person mode (scroll out or 📷 to leave)</td></tr>
<tr><td>📱 Touch</td><td>Virtual joystick + buttons appear automatically on mobile</td></tr>
</table>
@@@ -323,5 +331,13 @@
})();
</script>
++<script>
++// retro hit counter — the front porch of a lost corner of the internet
++fetch('/api/stats').then(r => r.json()).then(s => {
++ const pad = n => String(n).padStart(6, '0');
++ document.getElementById('hit-counter').textContent =
++ `▓ visitor ${pad(s.joins + 1)} ▓ ${s.online} inside now · ${s.sleepers} napping · ${s.marks} marks on the floor`;
++}).catch(() => {});
++</script>
</body>
</html>
ddiff --git a/public/js/main.js b/public/js/main.js
iindex 5212d6c..306b836 100644
---- a/public/js/main.js
++++ b/public/js/main.js
@@@ -91,60 +91,60 @@ function start(token, user) {
const phys = initPhysics(scene); // sandbox physics: dynamic props + physgun
let editor = null;
-- // ---------- drivable cars (arcade vehicle physics) ----------
-- const carState = { driving: null, speed: 0 }; // driving = W.cars entry
++ // ---------- drivable cars (rigid-body vehicles — they roll and flip) ----------
++ const carState = { driving: null }; // driving = W.cars entry
let carSendT = 0;
-- // keep a car's visuals, heading-aware collider and E-interact spot in sync
-- // with wherever it actually is (they used to stay at the parking spot)
-- function syncCar(car) {
-- car.group.position.set(car.x, 0, car.z);
-- car.group.rotation.y = car.ry;
-- 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;
-- if (car.inter) { car.inter.x = car.x; car.inter.z = car.z; }
-- }
function enterCar(car) {
if (car.driver && car.driver !== myId) { toast('🚗 Someone is already driving that one.'); return; }
++ const e = phys.cars.get(car.id);
++ if (pgState.held === e) physgunRelease(false);
carState.driving = car;
-- carState.speed = 0;
car.col.off = true;
car.driver = myId;
++ phys.claim(e);
net.send({ t: 'car', id: car.id, op: 'enter' });
toast('🚗 W/S throttle · A/D steer · Space handbrake · E to get out');
}
function exitCar() {
const car = carState.driving;
if (!car) return;
++ const e = phys.cars.get(car.id);
carState.driving = null;
car.col.off = false;
car.driver = null;
++ e.owned = true; // keep streaming while it rolls to a stop
// step out beside the driver door
-- my.x = car.x + Math.cos(car.ry) * 1.6;
-- my.z = car.z - Math.sin(car.ry) * 1.6;
++ my.x = car.x + Math.cos(car.ry) * 1.7;
++ my.z = car.z - Math.sin(car.ry) * 1.7;
my.vel.x = 0; my.vel.z = 0;
-- syncCar(car);
++ my.y = 0; my.vy = 0;
net.send({ t: 'car', id: car.id, op: 'exit' });
sendPos(true);
}
net.on('car', (m) => {
const car = W.cars?.find(c => c.id === m.id);
-- if (!car) return;
++ const e = phys.cars.get(m.id);
++ if (!car || !e) return;
if (m.op === 'enter') {
car.driver = m.driver;
-- if (m.driver !== myId) car.col.off = true;
++ if (m.driver !== myId) { car.col.off = true; e.owned = false; }
++ e.grabbedBy = null;
++ if (pgState.held === e) physgunRelease(false);
} else if (m.op === 'exit') {
car.driver = null;
car.col.off = false;
-- if (Number.isFinite(m.x)) { car.x = m.x; car.z = m.z; car.ry = m.ry; }
-- syncCar(car);
-- if (Number.isFinite(m.x)) { car.x = m.x; car.z = m.z; car.ry = m.ry; }
-- } else if (m.op === 'state' && m.driver !== myId) {
++ if (Array.isArray(m.p)) phys.applyCarState({ id: m.id, p: m.p, q: m.q, v: null }, true);
++ } else if (m.op === 'state') {
++ if (m.driver === myId) return;
car.driver = m.driver;
-- car.tx = m.x; car.tz = m.z; car.try = m.ry; // lerp targets
-- }
++ phys.applyCarState(m);
++ } else if (m.op === 'phys') {
++ if (m.owner === myId) return;
++ phys.applyCarState(m);
++ } else if (m.op === 'grab') {
++ e.grabbedBy = m.by;
++ e.owned = false;
++ } else if (m.op === 'drop') e.grabbedBy = null;
});
// ---------- badge gates ----------
@@@ -194,6 +194,7 @@ function start(token, user) {
myAvatar.setHeld(item);
net.send({ t: 'held', item });
updateViewmodel();
++ if (item === 'physgun') toast('🧲 Hold left-click on a prop or empty car to grab · wheel push/pull · R spin · X delete · Q spawns props', 4600);
},
onWear: (def) => {
if (def.ap) { me.ap = { ...(me.ap || {}), ...def.ap }; rebuildMyAvatar(); net.send({ t: 'appear', ap: me.ap }); }
@@@ -347,7 +348,8 @@ function start(token, user) {
} else if (e.code === 'KeyV') setFP(!fp);
else if (e.code === 'KeyF') useKey();
else if (e.code === 'KeyQ') toggleSpawn();
-- else if (e.code === 'KeyX' && pgState.held) net.send({ t: 'prop', op: 'del', id: pgState.held.id });
++ else if (e.code === 'KeyG') { e.preventDefault(); openTag(); }
++ else if (e.code === 'KeyX' && pgState.held && !pgState.held.isCar) net.send({ t: 'prop', op: 'del', id: pgState.held.id });
});
// ---------- chat ----------
@@@ -382,7 +384,7 @@ function start(token, user) {
}
addEventListener('keydown', (e) => {
if (e.code === 'Enter' && !input.chatOpen && !mg.inArcade() && started) { openChat(); e.preventDefault(); }
-- else if (e.code === 'Escape' && input.chatOpen) closeChat();
++ else if (e.code === 'Escape' && input.chatOpen) { if (tagOpen) closeTag(); else closeChat(); }
else if (e.code === 'Escape' && started) {
// Escape closes the top UI panel (spawn/inventory/settings) like any game menu
if (spawnOpen) toggleSpawn(false);
@@@ -395,12 +397,13 @@ function start(token, user) {
// ---------- net handlers ----------
net.on('init', (m) => {
myId = m.id;
++ phys.setMyId(m.id);
my.x = m.you.x; my.y = m.you.y; my.z = m.you.z; my.ry = m.you.ry;
mg.applyInit(m);
for (const [cid, c] of Object.entries(m.cars || {})) {
const car = W.cars?.find(k => k.id === cid);
if (!car) continue;
-- if (Number.isFinite(c.x)) { car.x = c.x; car.z = c.z; car.ry = c.ry; syncCar(car); }
++ if (Array.isArray(c.p)) phys.applyCarState({ id: cid, p: c.p, q: c.q || [0, 0, 0, 1], v: null }, true);
car.driver = c.driver;
if (c.driver) car.col.off = true;
}
@@@ -409,6 +412,37 @@ function start(token, user) {
(m.mapEdits || []).forEach(p => props.add(p));
(m.sleepers || []).forEach(addSleeper);
(m.props || []).forEach(pr => phys.add(pr));
++ (m.tags || []).forEach(addMark);
++ // retro associate counter by the front walkway — proof the place remembers
++ 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);
++ g.textAlign = 'center';
++ g.fillStyle = '#8fa0b3'; g.font = '700 30px "Segoe UI", sans-serif';
++ g.fillText('YOU ARE ASSOCIATE', w / 2, 52);
++ g.fillStyle = '#ffb52e'; g.font = '700 84px "Consolas", monospace';
++ g.shadowColor = '#ffb52e'; g.shadowBlur = 18;
++ g.fillText('Nº ' + pad6(m.visitorNum), w / 2, 138);
++ g.shadowBlur = 0;
++ g.fillStyle = '#5d6b7a'; g.font = '24px "Consolas", monospace';
++ g.fillText(`naps: ${m.stats?.naps ?? 0} · marks: ${m.stats?.marks ?? 0} · the lights stay on`, w / 2, 190);
++ });
++ 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;
++ sg.add(panel);
++ sg.position.set(103, 0, 6.4);
++ sg.rotation.y = Math.PI;
++ scene.add(sg);
++ W.colliders.push({ x0: 102.7, x1: 103.3, z0: 6.1, z1: 6.7 });
++ addChat(`📟 You are associate №${m.visitorNum}. The break room remembers everyone.`, 'sys');
++ addChat('🖊️ Press G to leave a permanent mark · Q to spawn props · the 🧲 is out on 172nd St.', 'sys');
++ }
if (me.admin && !editor) editor = initEditor({ scene, camera, props, toast });
$('online-count').textContent = m.online;
for (const p of m.players) addOther(p);
@@@ -523,6 +557,57 @@ function start(token, user) {
net.on('sleep', (m) => addSleeper(m.s));
net.on('wake', (m) => removeSleeper(m.key));
++ // ---------- permanent marks (G): sharpie on the floor, forever ----------
++ const tagGroup = new THREE.Group();
++ scene.add(tagGroup);
++ function addMark(tg) {
++ const n = parseInt(String(tg.id).slice(1), 10) || 0;
++ const tex = ct(256, 96, (g, w) => {
++ let f = 30;
++ const setF = () => { g.font = `italic 700 ${f}px "Segoe Print", "Comic Sans MS", cursive`; };
++ setF();
++ while (g.measureText(tg.text).width > w - 14 && f > 13) { f -= 2; setF(); }
++ g.fillStyle = `hsla(${tg.hue}, 70%, 30%, .95)`; // dark enough to read on pale floors
++ g.textAlign = 'center';
++ g.fillText(tg.text, w / 2, 46);
++ g.font = 'italic 15px "Segoe UI", sans-serif';
++ g.fillStyle = 'rgba(20,26,34,.6)';
++ g.fillText('— ' + tg.name, w / 2, 76);
++ });
++ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.7, .64), new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false }));
++ mesh.rotation.x = -Math.PI / 2;
++ // readable from where the writer stood, with a little hand-drawn wobble
++ mesh.rotation.z = (tg.ry || 0) + Math.PI + ((n % 5) - 2) * .12;
++ mesh.position.set(tg.x, .022 + (n % 7) * .0014, tg.z); // stacked ink never z-fights
++ mesh.renderOrder = 2;
++ tagGroup.add(mesh);
++ }
++ net.on('tag', (m) => { if (m.op === 'add') addMark(m.tag); });
++
++ const tagForm = $('tag-form'), tagInput = $('tag-input');
++ let tagOpen = false;
++ function openTag() {
++ if (tagOpen || carState.driving) return;
++ tagOpen = true;
++ input.chatOpen = true; // reuse the "typing" suppression of game keys
++ tagForm.classList.remove('hidden');
++ tagInput.focus();
++ }
++ function closeTag() {
++ tagOpen = false;
++ input.chatOpen = false;
++ tagForm.classList.add('hidden');
++ tagInput.blur();
++ canvas.focus?.();
++ }
++ tagForm.addEventListener('submit', (e) => {
++ e.preventDefault();
++ const text = tagInput.value.trim();
++ if (text) net.send({ t: 'tag', text, x: my.x, z: my.z, ry: my.ry });
++ tagInput.value = '';
++ closeTag();
++ });
++
// ---------- sandbox: spawn menu (Q) ----------
const spawnEl = $('spawn-menu'), spawnGrid = $('spawn-grid');
for (const [kind, def] of Object.entries(PHYS_KINDS)) {
@@@ -544,7 +629,7 @@ function start(token, user) {
}
// ---------- physgun: hold LMB to grab · wheel push/pull · R spin · X delete ----------
-- const pgState = { held: null, dist: 0 };
++ const pgState = { held: null, dist: 0, lmb: false, missT: 0 };
const pgRay = new THREE.Raycaster();
function pgEquipped() { return my.held === 'physgun'; }
function physgunGrab() {
@@@ -553,11 +638,11 @@ function start(token, user) {
const hit = phys.raycast(pgRay, 14);
if (!hit) return;
pgState.held = hit.e;
-- pgState.dist = Math.max(1.6, Math.min(10, hit.distance));
++ pgState.dist = Math.max(1.6, Math.min(hit.e.isCar ? 12 : 10, hit.distance));
hit.e.grabbedBy = myId;
phys.claim(hit.e);
hit.e.body.angularDamping = .92;
-- net.send({ t: 'prop', op: 'grab', id: hit.e.id });
++ net.send({ t: hit.e.isCar ? 'car' : 'prop', op: 'grab', id: hit.e.id });
beep(880, .06, 'square', .05);
}
function physgunRelease(throwIt = true) {
@@@ -565,16 +650,24 @@ function start(token, user) {
if (!e) return;
pgState.held = null;
e.grabbedBy = null;
-- e.body.angularDamping = .35;
-- net.send({ t: 'prop', op: 'drop', id: e.id });
-- if (throwIt) phys.sendState(e); // carries the fling velocity
++ e.body.angularDamping = e.isCar ? .6 : .35;
++ net.send({ t: e.isCar ? 'car' : 'prop', op: 'drop', id: e.id });
++ if (throwIt) (e.isCar ? phys.sendCarState : phys.sendState)(e); // carries the fling velocity
beep(440, .05, 'square', .04);
}
addEventListener('mousedown', (e) => {
if (e.button !== 0 || !input.locked || input.uiOpen || input.chatOpen) return;
-- if (pgEquipped()) physgunGrab();
++ if (pgEquipped()) {
++ pgState.lmb = true;
++ physgunGrab();
++ if (!pgState.held) beep(220, .05, 'square', .04); // dry-fire click
++ }
++ });
++ addEventListener('mouseup', (e) => {
++ if (e.button !== 0) return;
++ pgState.lmb = false;
++ if (pgState.held) physgunRelease();
});
-- addEventListener('mouseup', (e) => { if (e.button === 0 && pgState.held) physgunRelease(); });
// beams: one for me, plus one per prop another player is holding
function glowDot() {
@@@ -612,26 +705,49 @@ function start(token, user) {
pos.needsUpdate = true;
b.dot.position.copy(to);
}
++ function myBeamFrom() {
++ if (fp) {
++ const dir = camera.getWorldDirection(beamV.set(0, 0, 0));
++ const from = camera.position.clone().addScaledVector(dir, .5);
++ from.y -= .18;
++ return from;
++ }
++ return myAvatar.heldAnchor.getWorldPosition(new THREE.Vector3());
++ }
function updateBeams() {
const live = new Set();
if (pgState.held) {
live.add('me');
-- let from;
-- if (fp) {
-- const dir = camera.getWorldDirection(beamV.set(0, 0, 0));
-- from = camera.position.clone().addScaledVector(dir, .5);
-- from.y -= .18;
-- } else {
-- from = myAvatar.heldAnchor.getWorldPosition(new THREE.Vector3());
++ setBeam('me', myBeamFrom(), pgState.held.body.position);
++ beamFor('me').line.material.opacity = .8;
++ } else if (pgState.lmb && pgEquipped()) {
++ // gmod-style: the beam fires even with nothing grabbed, ending on
++ // whatever the crosshair touches (dimmer — it isn't holding anything)
++ live.add('me');
++ pgRay.setFromCamera(new THREE.Vector2(0, 0), camera);
++ pgRay.far = 20;
++ let end = null;
++ const hit = phys.raycast(pgRay, 20);
++ if (hit) end = hit.point;
++ else {
++ const wallHits = pgRay.intersectObjects(W.camBlockers, false);
++ if (wallHits.length) end = wallHits[0].point;
++ else if (pgRay.ray.direction.y < -.001) { // floor
++ const t = -pgRay.ray.origin.y / pgRay.ray.direction.y;
++ if (t > 0 && t < 20) end = pgRay.ray.origin.clone().addScaledVector(pgRay.ray.direction, t);
++ }
++ if (!end) end = pgRay.ray.origin.clone().addScaledVector(pgRay.ray.direction, 20);
}
-- setBeam('me', from, pgState.held.body.position);
++ setBeam('me', myBeamFrom(), end);
++ beamFor('me').line.material.opacity = .3;
}
-- for (const e of phys.props.values()) {
++ for (const e of [...phys.props.values(), ...phys.cars.values()]) {
if (e.grabbedBy && e.grabbedBy !== myId) {
const o = others.get(e.grabbedBy);
if (!o) continue;
live.add(e.id);
setBeam(e.id, o.avatar.heldAnchor.getWorldPosition(beamV), e.body.position);
++ beamFor(e.id).line.material.opacity = .8;
}
}
for (const [key, b] of beams) {
@@@ -908,35 +1024,26 @@ function start(token, user) {
}
if (carState.driving) {
-- // ---- arcade car physics: throttle, speed-scaled steering, drift, crash ----
++ // ---- rigid-body driving: forces on the chassis, physics handles the rest ----
const car = carState.driving;
-- const throttle = -kv.y; // W forward, S reverse/brake
-- const steer = -kv.x;
-- const handbrake = input.keys.Space;
-- const maxF = car.top || 17, maxR = 6;
-- if (throttle > 0) carState.speed += (car.acc || 9) * throttle * dt;
-- else if (throttle < 0) carState.speed += (carState.speed > 0 ? 14 : 5) * throttle * dt;
-- carState.speed -= carState.speed * (handbrake ? 2.4 : .5) * dt; // drag
-- carState.speed = Math.max(-maxR, Math.min(maxF, carState.speed));
-- if (Math.abs(carState.speed) > .2) {
-- const grip = handbrake ? 1.7 : 1.0;
-- car.ry += steer * grip * Math.min(Math.abs(carState.speed) / 7, 1) * 1.9 * dt * Math.sign(carState.speed);
-- }
-- let cx2 = car.x + Math.sin(car.ry) * carState.speed * dt;
-- let cz2 = car.z + Math.cos(car.ry) * carState.speed * dt;
-- const [rx, rz] = resolveCollisions(cx2, cz2, 1.25);
-- if (Math.hypot(rx - cx2, rz - cz2) > .01) { // crash
-- if (Math.abs(carState.speed) > 5) beep(90, .2, 'sawtooth', .2);
-- carState.speed *= .35;
-- }
-- car.x = rx; car.z = rz;
-- syncCar(car);
-- // player rides in the seat; engine hum
-- my.x = car.x; my.z = car.z; my.y = .45; my.ry = car.ry;
++ const e = phys.cars.get(car.id);
++ phys.drive(e, -kv.y, -kv.x, !!input.keys.Space, dt);
++ // ride the chassis (car.x/z/ry are synced from the body in phys.step)
++ my.x = car.x; my.z = car.z;
++ my.y = Math.max(0, e.body.position.y - .15);
++ my.ry = car.ry;
my.anim = 'sit';
carSendT += dt;
-- if (carSendT > .1) { carSendT = 0; net.send({ t: 'car', id: car.id, op: 'state', x: +car.x.toFixed(2), z: +car.z.toFixed(2), ry: +car.ry.toFixed(3) }); }
-- if (input.actionQueued2) {} // noop
++ if (carSendT > .1) {
++ carSendT = 0;
++ const b = e.body;
++ net.send({
++ t: 'car', id: car.id, op: 'state',
++ p: [+b.position.x.toFixed(2), +b.position.y.toFixed(2), +b.position.z.toFixed(2)],
++ q: [+b.quaternion.x.toFixed(3), +b.quaternion.y.toFixed(3), +b.quaternion.z.toFixed(3), +b.quaternion.w.toFixed(3)],
++ v: [+b.velocity.x.toFixed(2), +b.velocity.y.toFixed(2), +b.velocity.z.toFixed(2)],
++ });
++ }
} else if (my.seat) {
if (mvLen > .4) standUp();
my.anim = 'sit';
@@@ -1069,17 +1176,6 @@ function start(token, user) {
camera.lookAt(my.x, my.y + 1.45, my.z);
}
-- // remote-driven cars interpolate toward their latest state
-- if (W.cars) for (const car of W.cars) {
-- if (car.driver && car.driver !== myId && car.tx !== undefined) {
-- car.x += (car.tx - car.x) * Math.min(1, dt * 10);
-- car.z += (car.tz - car.z) * Math.min(1, dt * 10);
-- let cdr = (car.try - car.ry + Math.PI * 3) % (Math.PI * 2) - Math.PI;
-- car.ry += cdr * Math.min(1, dt * 10);
-- syncCar(car);
-- }
-- }
--
// badge-gate paddle animation
if (W.gates) for (const g of W.gates) {
const target = g.open ? 1.15 : 0;
@@@ -1090,7 +1186,7 @@ function start(token, user) {
// ---- sandbox physics ----
if (pgState.held) {
const held = pgState.held;
-- if (!pgEquipped() || !phys.props.has(held.id)) physgunRelease(false);
++ 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();
@@@ -1128,7 +1224,7 @@ function start(token, user) {
// debug/testing handle
window.__brs = {
-- my, mg, scene, W, phys, teleport: (x, z, yaw) => { my.x = x; my.z = z; if (yaw !== undefined) my.yaw = yaw; sendPos(true); },
++ my, mg, scene, W, phys, input, teleport: (x, z, yaw) => { my.x = x; my.z = z; if (yaw !== undefined) my.yaw = yaw; sendPos(true); },
action: doAction, nearest: () => nearest?.id || nearestSeat?.id || null,
spawnProp, toggleSpawn, physgunGrab, physgunRelease, pgState, sleepers: sleeperMap, inv: invApi,
};
ddiff --git a/public/js/physics.js b/public/js/physics.js
iindex b84e123..8a21fad 100644
---- a/public/js/physics.js
++++ b/public/js/physics.js
@@@ -127,12 +127,104 @@ export function initPhysics(scene) {
b.position.set(c.x0 + hw, 1.3, c.z0 + hd);
world.addBody(b);
}
-- const carBodies = (W.cars || []).map(car => {
-- const b = new CANNON.Body({ type: CANNON.Body.KINEMATIC, material: groundMat, shape: new CANNON.Box(new CANNON.Vec3(car.hl || 2.15, .6, car.hw || 1.0)) });
-- b.position.set(car.x, .6, car.z);
-- world.addBody(b);
-- return { car, b };
-- });
++ // cars are full rigid bodies: they roll, flip, get shoved, and can be
++ // physgunned around while nobody's driving — proper gmod energy
++ const CAR_HH = .5; // chassis half-height; visual group origin sits at the wheels
++ const carMat = new CANNON.Material('car');
++ // EXACTLY zero chassis-ground friction: the friction solver zeroes the
++ // tangential velocity of a heavy resting box under ANY nonzero μ (verified
++ // empirically — .04 behaved identically to .3). Cars roll on wheels anyway:
++ // grip and drag live in drive(), sliding decay in linearDamping.
++ world.addContactMaterial(new CANNON.ContactMaterial(groundMat, carMat, { friction: 0, restitution: .1 }));
++ world.addContactMaterial(new CANNON.ContactMaterial(propMat, carMat, { friction: .35, restitution: .3 }));
++ let myId = null;
++ const cars = new Map(); // id -> {id, car, body, isCar, owned, grabbedBy, remote, rp, rq, rv, remoteT}
++ for (const car of W.cars || []) {
++ const body = new CANNON.Body({
++ mass: 180, material: carMat,
++ shape: new CANNON.Box(new CANNON.Vec3(car.hl || 2.15, CAR_HH, car.hw || 1.0)),
++ angularDamping: .6, linearDamping: .12,
++ });
++ body.position.set(car.x, CAR_HH + .05, car.z);
++ body.quaternion.setFromEuler(0, car.ry, 0);
++ body.sleepSpeedLimit = .35;
++ body.sleepTimeLimit = .6;
++ body.sleep();
++ world.addBody(body);
++ cars.set(car.id, { id: car.id, car, body, isCar: true, owned: false, grabbedBy: null, remote: false });
++ }
++ const V = (x, y, z) => new CANNON.Vec3(x, y, z);
++
++ // arcade forces on a real chassis: engine + brake along the body's forward
++ // axis, hard-set yaw rate for snappy steering, lateral-grip kill, and a
++ // gentle self-righting torque so a flipped car can be driven back onto
++ // its wheels
++ function drive(e, throttle, steer, handbrake, dt) {
++ const b = e.body;
++ b.wakeUp();
++ const fwd = b.quaternion.vmult(V(0, 0, 1));
++ const right = b.quaternion.vmult(V(1, 0, 0));
++ const up = b.quaternion.vmult(V(0, 1, 0));
++ const grounded = up.y > .5 && b.position.y < CAR_HH + 1.1;
++ if (grounded) {
++ // velocity-space driving: the ground-contact friction solver eats applied
++ // forces on resting bodies, so we steer the velocity directly — the
++ // solver still owns collisions, gravity and rollovers
++ let vFwd = b.velocity.dot(fwd);
++ let vLat = b.velocity.dot(right);
++ const top = e.car.top || 17;
++ if (throttle > 0) vFwd += (e.car.acc || 9) * throttle * dt;
++ else if (throttle < 0) vFwd += (vFwd > .3 ? 14 : 5) * throttle * dt;
++ vFwd -= vFwd * (handbrake ? 2.2 : .45) * dt; // drag
++ vFwd = Math.max(-6, Math.min(top, vFwd));
++ vLat *= Math.pow(handbrake ? .6 : .02, dt); // grip (handbrake = drift)
++ b.velocity.set(
++ fwd.x * vFwd + right.x * vLat,
++ b.velocity.y,
++ fwd.z * vFwd + right.z * vLat,
++ );
++ const sf = Math.min(Math.abs(vFwd) / 7, 1) * 1.9 * (vFwd < -.3 ? -1 : 1);
++ b.angularVelocity.y = steer * (handbrake ? 1.7 : 1) * sf;
++ }
++ if (up.y < .95) { // self-righting: torque toward world-up
++ const axis = up.cross(V(0, 1, 0));
++ b.angularVelocity.x += axis.x * 5 * dt;
++ b.angularVelocity.z += axis.z * 5 * dt;
++ if (up.y < -.9) b.angularVelocity.x += 3 * dt; // break the upside-down equilibrium
++ }
++ }
++
++ // remote authority for a car (driver stream or driverless nudge)
++ function applyCarState(m, snap = false) {
++ const e = cars.get(m.id);
++ if (!e) return;
++ e.owned = false;
++ e.rp = e.rp || new CANNON.Vec3();
++ e.rp.set(m.p[0], m.p[1], m.p[2]);
++ e.rq = e.rq || new CANNON.Quaternion();
++ e.rq.set(m.q[0], m.q[1], m.q[2], m.q[3]);
++ e.rv = Array.isArray(m.v) ? m.v : null;
++ e.remote = true;
++ e.remoteT = performance.now();
++ if (snap) {
++ e.body.position.copy(e.rp);
++ e.body.quaternion.copy(e.rq);
++ e.body.velocity.setZero();
++ e.body.angularVelocity.setZero();
++ e.remote = false;
++ }
++ e.body.wakeUp();
++ }
++
++ function sendCarState(e) {
++ const b = e.body;
++ net.send({
++ t: 'car', id: e.id, op: 'phys',
++ p: [+b.position.x.toFixed(2), +b.position.y.toFixed(2), +b.position.z.toFixed(2)],
++ q: [+b.quaternion.x.toFixed(3), +b.quaternion.y.toFixed(3), +b.quaternion.z.toFixed(3), +b.quaternion.w.toFixed(3)],
++ v: [+b.velocity.x.toFixed(2), +b.velocity.y.toFixed(2), +b.velocity.z.toFixed(2)],
++ });
++ }
const props = new Map(); // id -> {id, kind, body, mesh, owned, netT, grabbedBy}
let sendT = 0;
@@@ -162,7 +254,12 @@ export function initPhysics(scene) {
scene.remove(e.mesh);
props.delete(id);
}
-- function claim(e) { e.owned = true; e.body.wakeUp(); }
++ function claim(e) {
++ e.owned = true;
++ e.remote = false;
++ if (e.body.type === CANNON.Body.KINEMATIC) e.body.type = CANNON.Body.DYNAMIC;
++ e.body.wakeUp();
++ }
// remote authority: someone else is driving this prop now
function applyState(m) {
@@@ -220,9 +317,54 @@ export function initPhysics(scene) {
}
function step(dt, playerPos) {
-- for (const { car, b } of carBodies) { b.position.set(car.x, .6, car.z); b.quaternion.setFromEuler(0, car.ry, 0); }
++ // remote-controlled cars follow their stream kinematically; when the
++ // stream dries up (driver left / nudger settled) they go dynamic again
++ for (const e of cars.values()) {
++ const drivenByMe = e.car.driver != null && e.car.driver === myId;
++ const remoteActive = e.remote && performance.now() - e.remoteT < 800;
++ if (!drivenByMe && e.rp && (remoteActive || (e.car.driver != null && e.remote))) {
++ if (e.body.type !== CANNON.Body.KINEMATIC) {
++ e.body.type = CANNON.Body.KINEMATIC;
++ e.body.velocity.setZero();
++ e.body.angularVelocity.setZero();
++ }
++ const k = Math.min(1, dt * 12);
++ e.body.position.lerp(e.rp, k, e.body.position);
++ e.body.quaternion.slerp(e.rq, k, e.body.quaternion);
++ e.body.wakeUp();
++ } else if (e.body.type === CANNON.Body.KINEMATIC) {
++ e.body.type = CANNON.Body.DYNAMIC;
++ if (e.rv) e.body.velocity.set(e.rv[0], e.rv[1], e.rv[2]);
++ e.remote = false;
++ e.body.wakeUp();
++ }
++ }
if (playerPos) playerPush(playerPos.x, playerPos.y, playerPos.z, dt);
world.step(1 / 60, dt, 3);
++ // cars: visual group, walking collider, and E-prompt follow the body
++ for (const e of cars.values()) {
++ const b = e.body, car = e.car;
++ if (b.position.y < -8 || b.position.x < -80 || b.position.x > 150 || b.position.z < -60 || b.position.z > 188) {
++ b.position.x = Math.max(-78, Math.min(148, b.position.x));
++ b.position.z = Math.max(-58, Math.min(186, b.position.z));
++ b.position.y = 1.2;
++ 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));
++ 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;
++ if (car.inter) { car.inter.x = car.x; car.inter.z = car.z; }
++ }
for (const e of props.values()) {
const b = e.body;
// safety net: anything that tunnels the floor or escapes the map snaps back
@@@ -236,29 +378,37 @@ export function initPhysics(scene) {
e.mesh.position.copy(b.position);
e.mesh.quaternion.copy(b.quaternion);
}
-- // stream my awake props at ~12Hz
++ // stream my awake props + driverless cars I've been shoving, at ~12Hz
sendT += dt;
if (sendT > .085) {
sendT = 0;
for (const e of props.values()) {
if (e.owned && e.body.sleepState !== CANNON.Body.SLEEPING) sendState(e);
}
++ for (const e of cars.values()) {
++ if (e.owned && !e.car.driver && e.body.type === CANNON.Body.DYNAMIC && e.body.sleepState !== CANNON.Body.SLEEPING) sendCarState(e);
++ }
}
}
function raycast(raycaster, maxDist = 14) {
-- const meshes = [...props.values()].map(e => e.mesh);
-- const hits = raycaster.intersectObjects(meshes, true);
++ const roots = [], entries = [];
++ for (const e of props.values()) { roots.push(e.mesh); entries.push(e); }
++ for (const e of cars.values()) if (!e.car.driver) { roots.push(e.car.group); entries.push(e); }
++ const hits = raycaster.intersectObjects(roots, true);
for (const h of hits) {
if (h.distance > maxDist) break;
let o = h.object;
-- while (o && !meshes.includes(o)) o = o.parent;
++ while (o && !roots.includes(o)) o = o.parent;
if (!o) continue;
-- const e = [...props.values()].find(x => x.mesh === o);
-- if (e) return { e, point: h.point, distance: h.distance };
++ return { e: entries[roots.indexOf(o)], point: h.point, distance: h.distance };
}
return null;
}
-- return { world, props, add, remove, claim, applyState, sendState, step, smack, raycast, PHYS_KINDS };
++ return {
++ world, props, cars, add, remove, claim, applyState, applyCarState, sendState, sendCarState,
++ drive, step, smack, raycast, PHYS_KINDS,
++ setMyId(id) { myId = id; },
++ };
}
ddiff --git a/public/play.html b/public/play.html
iindex d0a228a..c5ef8b7 100644
---- a/public/play.html
++++ b/public/play.html
@@@ -38,6 +38,7 @@
<div id="hud" class="hidden">
<div id="chat-log"></div>
<form id="chat-form" class="hidden"><input id="chat-input" type="text" maxlength="160" placeholder="Say something… (Enter to send, Esc to close)"></form>
++ <form id="tag-form" class="hidden"><span class="tag-pen">🖊️</span><input id="tag-input" type="text" maxlength="48" placeholder="Leave your mark on the floor — it stays forever…"></form>
<div id="prompt" class="hidden"><span id="prompt-key">E</span><span id="prompt-text"></span></div>
<div id="crosshair" class="hidden">·</div>
ddiff --git a/server/server.js b/server/server.js
iindex 2852948..12264df 100644
---- a/server/server.js
++++ b/server/server.js
@@@ -27,6 +27,10 @@ app.get('/play', (req, res) => res.sendFile(path.join(__dirname, '..', 'public',
app.post('/api/register', (req, res) => res.json(store.register(req.body.name, req.body.pass)));
app.post('/api/login', (req, res) => res.json(store.login(req.body.name, req.body.pass)));
app.post('/api/guest', (req, res) => res.json(store.guest()));
++app.get('/api/stats', (req, res) => res.json({
++ joins: stats.joins, naps: stats.naps, marks: tags.length,
++ sleepers: sleepers.size, props: props.size, online: players.size,
++}));
// CLI: node server/server.js --admin SomeName (grants admin to an account)
const adminIdx = process.argv.indexOf('--admin');
@@@ -92,9 +96,19 @@ try { savedWorld = JSON.parse(fs.readFileSync(WORLD_FILE, 'utf8')); } catch {}
const sleepers = new Map(Object.entries(savedWorld.sleepers || {})); // key -> {key,name,vest,ap,guest,x,y,z,ry,ts}
const props = new Map(Object.entries(savedWorld.props || {})); // id -> {id,kind,p:[x,y,z],q:[x,y,z,w]}
for (const [cid, c] of Object.entries(savedWorld.cars || {})) {
-- if (/^(car\d+|lambo)$/.test(cid) && Number.isFinite(c.x)) cars[cid] = { x: c.x, z: c.z, ry: c.ry || 0, driver: null };
++ if (!/^(car\d+|lambo)$/.test(cid)) continue;
++ if (Array.isArray(c.p)) cars[cid] = { p: c.p, q: Array.isArray(c.q) ? c.q : [0, 0, 0, 1], driver: null };
++ else if (Number.isFinite(c.x)) { // migrate pre-rigid-body saves {x,z,ry}
++ const h = (c.ry || 0) / 2;
++ cars[cid] = { p: [c.x, .55, c.z], q: [0, +Math.sin(h).toFixed(3), 0, +Math.cos(h).toFixed(3)], driver: null };
++ }
}
let nextPhysId = [...props.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(2)) || 0), 0) + 1;
++// 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 || {});
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);
@@@ -108,7 +122,7 @@ let worldDirty = false;
function saveWorld() {
worldDirty = false;
const tmp = WORLD_FILE + '.tmp';
-- fs.writeFileSync(tmp, JSON.stringify({ sleepers: Object.fromEntries(sleepers), cars, props: Object.fromEntries(props) }));
++ fs.writeFileSync(tmp, JSON.stringify({ sleepers: Object.fromEntries(sleepers), cars, props: Object.fromEntries(props), tags, stats }));
fs.renameSync(tmp, WORLD_FILE); // atomic: a crash mid-write can't corrupt the state
}
setInterval(() => { if (worldDirty) saveWorld(); }, 10000);
@@@ -160,10 +174,14 @@ wss.on('connection', (ws, req) => {
worldDirty = true;
}
players.set(id, p);
++ stats.joins++;
++ worldDirty = true;
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 },
++ tags,
mapEdits,
players: [...players.values()].filter(q => q.id !== id).map(pubState),
c4: { a: c4.a.state(), b: c4.b.state() },
@@@ -190,7 +208,7 @@ wss.on('connection', (ws, req) => {
ws.on('close', () => {
players.delete(id);
for (const [cid, c] of Object.entries(cars)) {
-- if (c.driver === id) { c.driver = null; broadcast({ t: 'car', id: cid, op: 'exit', x: c.x, z: c.z, ry: c.ry }); }
++ if (c.driver === id) { c.driver = null; broadcast({ t: 'car', id: cid, op: 'exit', p: c.p, q: c.q }); }
}
for (const g of [c4.a, c4.b]) { g.leave(p.key); broadcast({ t: 'c4', s: g.state() }); }
chess.leave(p.key); broadcast({ t: 'chess', s: chess.state() });
@@@ -206,6 +224,7 @@ wss.on('connection', (ws, req) => {
};
sleepers.set(p.key, s);
pruneSleepers();
++ stats.naps++;
worldDirty = true;
broadcast({ t: 'sleep', s });
sys(`💤 ${p.name} dozed off mid-break`);
@@@ -313,7 +332,17 @@ function handle(p, m) {
case 'car': {
const id = String(m.id || '').slice(0, 12);
if (!/^(car\d+|lambo)$/.test(id)) return;
-- const c = cars[id] || (cars[id] = { x: null, z: null, ry: 0, driver: null });
++ const c = cars[id] || (cars[id] = { p: null, q: [0, 0, 0, 1], driver: null });
++ const readPose = () => { // full rigid-body pose: position + quaternion (+velocity)
++ const pos = Array.isArray(m.p) ? m.p.map(Number) : null;
++ const q = Array.isArray(m.q) ? m.q.map(Number) : null;
++ if (!pos || pos.length !== 3 || !pos.every(Number.isFinite) || !q || q.length !== 4 || !q.every(Number.isFinite)) return false;
++ c.p = [clampX(pos[0]), Math.max(0, Math.min(40, pos[1])), clampZ(pos[2])];
++ c.q = q.map(n => +n.toFixed(3));
++ worldDirty = true;
++ return true;
++ };
++ const vel = () => Array.isArray(m.v) && m.v.length === 3 && m.v.every(Number.isFinite) ? m.v : null;
if (m.op === 'enter') {
if (c.driver && c.driver !== p.id) return;
c.driver = p.id;
@@@ -322,15 +351,18 @@ function handle(p, m) {
} else if (m.op === 'exit') {
if (c.driver !== p.id) return;
c.driver = null;
-- broadcast({ t: 'car', id, op: 'exit', x: c.x, z: c.z, ry: c.ry });
-- } else if (m.op === 'state') {
++ broadcast({ t: 'car', id, op: 'exit', p: c.p, q: c.q });
++ } else if (m.op === 'state') { // driver's stream
if (c.driver !== p.id) return;
-- if (![m.x, m.z, m.ry].every(Number.isFinite)) return;
-- c.x = Math.max(-78, Math.min(148, m.x));
-- c.z = Math.max(-58, Math.min(186, m.z));
-- c.ry = m.ry;
-- worldDirty = true;
-- broadcast({ t: 'car', id, op: 'state', x: c.x, z: c.z, ry: c.ry, driver: p.id }, p.id);
++ if (!readPose()) return;
++ broadcast({ t: 'car', id, op: 'state', p: c.p, q: c.q, v: vel(), driver: p.id }, p.id);
++ } else if (m.op === 'phys') { // driverless nudge: physgun, shoves, rolling to a stop
++ if (c.driver) return;
++ if (!readPose()) return;
++ broadcast({ t: 'car', id, op: 'phys', p: c.p, q: c.q, v: vel(), owner: p.id }, p.id);
++ } else if (m.op === 'grab' || m.op === 'drop') { // physgun beam relay
++ if (m.op === 'grab' && c.driver) return;
++ broadcast({ t: 'car', id, op: m.op, by: p.id }, p.id);
}
break;
}
@@@ -375,6 +407,27 @@ function handle(p, m) {
case 'gate': // badge-gate animation relay so everyone sees paddles open
broadcast({ t: 'gate', id: String(m.id || '').slice(0, 20) });
break;
++ case 'tag': { // permanent marker on the floor — your mark stays forever
++ const now = Date.now();
++ if (now - (p.lastTag || 0) < 20000) {
++ if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🖊️ The marker needs a minute to dry — try again shortly.' }));
++ return;
++ }
++ const text = String(m.text || '').replace(/\s+/g, ' ').trim().slice(0, 48);
++ if (!text) return;
++ p.lastTag = now;
++ const tag = {
++ id: 't' + nextTagId++, name: p.name, text,
++ x: clampX(+(+m.x || 0).toFixed(1)), z: clampZ(+(+m.z || 0).toFixed(1)),
++ ry: +(+m.ry || 0).toFixed(2), hue: Math.floor(Math.random() * 360), ts: now,
++ };
++ tags.push(tag);
++ if (tags.length > 600) tags.shift(); // eventually the oldest ink fades
++ worldDirty = true;
++ broadcast({ t: 'tag', op: 'add', tag });
++ sys(`🖊️ ${p.name} left a mark`);
++ break;
++ }
case 'edit': { // admin-only persistent level editor
if (!p.admin) return;
if (m.op === 'add' && m.prop && typeof m.prop.kind === 'string') {