openvibers/breakroomsimulator Commits
Rigid-body cars, physgun feedback + car grabbing, permanent marks, visitor counter
Changes
@@@ -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); }@@@ -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 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">▶ Play now — free, in your browser</a>++ <a class="btn" href="/play">▶ 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 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 & loadout</h3><p>24-slot inventory and hotbar with drag & 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>@@@ -91,60 +91,60 @@ function start(token, user) {const phys = initPhysics(scene); // sandbox physics: dynamic props + physgunlet 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 entrylet 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 menuif (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 velocitybeep(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 holdingfunction 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 animationif (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 rayconst b = held.body;b.wakeUp();@@@ -1128,7 +1224,7 @@ function start(token, user) {// debug/testing handlewindow.__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,};@@@ -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 nowfunction 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 ~12HzsendT += 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; },++ };}@@@ -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>@@@ -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 recentconst 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 streamif (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 openbroadcast({ 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 editorif (!p.admin) return;if (m.op === 'add' && m.prop && typeof m.prop.kind === 'string') {
@@ -137,3 +137,9 @@ body.touch #hotbar { bottom: calc(96px + env(safe-area-inset-bottom)); }
@@ -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 { 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 button:hover { background: rgba(53,224,255,.14); border-color: #35e0ff; }
#spawn-grid .ico { font-size: 1.5rem; display: block; margin-bottom: 4px; }
#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); }
@@ -149,14 +149,16 @@
@@ -149,14 +149,16 @@
<div class="wrap">
<div class="wrap">
<div class="emoji"><span>🏓</span> <span>🦺</span> <span>♟️</span> <span>🚗</span></div>
<div class="emoji"><span>🏓</span> <span>🦺</span> <span>♟️</span> <span>🚗</span></div>
<h1>Amazon Break Room<span class="accent">SIMULATOR</span></h1>
<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 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">
<div class="cta-row">
- <a class="btn" href="/play">▶ Play now — free, in your browser</a>
+ <a class="btn" href="/play">▶ 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>
<a class="btn ghost" href="https://github.com/OpenVibers/BreakRoomSimulator" target="_blank" rel="noopener">⭐ Star on GitHub</a>
</div>
</div>
<p class="note">No download. No install. Works on desktop, phone and tablet. Guest login in one click.</p>
<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 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>
</div>
</div>
@@ -171,7 +173,10 @@
@@ -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>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>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 C market, dining hall, couch corner and the smoke cage.</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 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 & loadout</h3><p>24-slot inventory and hotbar with drag & drop — snacks, hats, vests, and break-room "weapons" like paddles and bananas.</p></div>
<div class="card rv"><div class="ic">🎒</div><h3>Items & loadout</h3><p>24-slot inventory and hotbar with drag & 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>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>
<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 @@
@@ -204,6 +209,9 @@
<tr><td><kbd>Ctrl</kbd></td><td>Crouch / sneak</td></tr>
<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>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>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>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>
<tr><td>📱 Touch</td><td>Virtual joystick + buttons appear automatically on mobile</td></tr>
</table>
</table>
@@ -323,5 +331,13 @@
@@ -323,5 +331,13 @@
})();
})();
</script>
</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>
</body>
</html>
</html>
@@ -91,60 +91,60 @@ function start(token, user) {
@@ -91,60 +91,60 @@ function start(token, user) {
const phys = initPhysics(scene); // sandbox physics: dynamic props + physgun
const phys = initPhysics(scene); // sandbox physics: dynamic props + physgun
let editor = null;
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;
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) {
function enterCar(car) {
if (car.driver && car.driver !== myId) { toast('🚗 Someone is already driving that one.'); return; }
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.driving = car;
- carState.speed = 0;
car.col.off = true;
car.col.off = true;
car.driver = myId;
car.driver = myId;
+ phys.claim(e);
net.send({ t: 'car', id: car.id, op: 'enter' });
net.send({ t: 'car', id: car.id, op: 'enter' });
toast('🚗 W/S throttle · A/D steer · Space handbrake · E to get out');
toast('🚗 W/S throttle · A/D steer · Space handbrake · E to get out');
}
}
function exitCar() {
function exitCar() {
const car = carState.driving;
const car = carState.driving;
if (!car) return;
if (!car) return;
+ const e = phys.cars.get(car.id);
carState.driving = null;
carState.driving = null;
car.col.off = false;
car.col.off = false;
car.driver = null;
car.driver = null;
+ e.owned = true; // keep streaming while it rolls to a stop
// step out beside the driver door
// 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;
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' });
net.send({ t: 'car', id: car.id, op: 'exit' });
sendPos(true);
sendPos(true);
}
}
net.on('car', (m) => {
net.on('car', (m) => {
const car = W.cars?.find(c => c.id === m.id);
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') {
if (m.op === 'enter') {
car.driver = m.driver;
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') {
} else if (m.op === 'exit') {
car.driver = null;
car.driver = null;
car.col.off = false;
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.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 ----------
// ---------- badge gates ----------
@@ -194,6 +194,7 @@ function start(token, user) {
@@ -194,6 +194,7 @@ function start(token, user) {
myAvatar.setHeld(item);
myAvatar.setHeld(item);
net.send({ t: 'held', item });
net.send({ t: 'held', item });
updateViewmodel();
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) => {
onWear: (def) => {
if (def.ap) { me.ap = { ...(me.ap || {}), ...def.ap }; rebuildMyAvatar(); net.send({ t: 'appear', ap: me.ap }); }
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) {
@@ -347,7 +348,8 @@ function start(token, user) {
} else if (e.code === 'KeyV') setFP(!fp);
} else if (e.code === 'KeyV') setFP(!fp);
else if (e.code === 'KeyF') useKey();
else if (e.code === 'KeyF') useKey();
else if (e.code === 'KeyQ') toggleSpawn();
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 ----------
// ---------- chat ----------
@@ -382,7 +384,7 @@ function start(token, user) {
@@ -382,7 +384,7 @@ function start(token, user) {
}
}
addEventListener('keydown', (e) => {
addEventListener('keydown', (e) => {
if (e.code === 'Enter' && !input.chatOpen && !mg.inArcade() && started) { openChat(); e.preventDefault(); }
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) {
else if (e.code === 'Escape' && started) {
// Escape closes the top UI panel (spawn/inventory/settings) like any game menu
// Escape closes the top UI panel (spawn/inventory/settings) like any game menu
if (spawnOpen) toggleSpawn(false);
if (spawnOpen) toggleSpawn(false);
@@ -395,12 +397,13 @@ function start(token, user) {
@@ -395,12 +397,13 @@ function start(token, user) {
// ---------- net handlers ----------
// ---------- net handlers ----------
net.on('init', (m) => {
net.on('init', (m) => {
myId = m.id;
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;
my.x = m.you.x; my.y = m.you.y; my.z = m.you.z; my.ry = m.you.ry;
mg.applyInit(m);
mg.applyInit(m);
for (const [cid, c] of Object.entries(m.cars || {})) {
for (const [cid, c] of Object.entries(m.cars || {})) {
const car = W.cars?.find(k => k.id === cid);
const car = W.cars?.find(k => k.id === cid);
if (!car) continue;
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;
car.driver = c.driver;
if (c.driver) car.col.off = true;
if (c.driver) car.col.off = true;
}
}
@@ -409,6 +412,37 @@ function start(token, user) {
@@ -409,6 +412,37 @@ function start(token, user) {
(m.mapEdits || []).forEach(p => props.add(p));
(m.mapEdits || []).forEach(p => props.add(p));
(m.sleepers || []).forEach(addSleeper);
(m.sleepers || []).forEach(addSleeper);
(m.props || []).forEach(pr => phys.add(pr));
(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 });
if (me.admin && !editor) editor = initEditor({ scene, camera, props, toast });
$('online-count').textContent = m.online;
$('online-count').textContent = m.online;
for (const p of m.players) addOther(p);
for (const p of m.players) addOther(p);
@@ -523,6 +557,57 @@ function start(token, user) {
@@ -523,6 +557,57 @@ function start(token, user) {
net.on('sleep', (m) => addSleeper(m.s));
net.on('sleep', (m) => addSleeper(m.s));
net.on('wake', (m) => removeSleeper(m.key));
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) ----------
// ---------- sandbox: spawn menu (Q) ----------
const spawnEl = $('spawn-menu'), spawnGrid = $('spawn-grid');
const spawnEl = $('spawn-menu'), spawnGrid = $('spawn-grid');
for (const [kind, def] of Object.entries(PHYS_KINDS)) {
for (const [kind, def] of Object.entries(PHYS_KINDS)) {
@@ -544,7 +629,7 @@ function start(token, user) {
@@ -544,7 +629,7 @@ function start(token, user) {
}
}
// ---------- physgun: hold LMB to grab · wheel push/pull · R spin · X delete ----------
// ---------- 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();
const pgRay = new THREE.Raycaster();
function pgEquipped() { return my.held === 'physgun'; }
function pgEquipped() { return my.held === 'physgun'; }
function physgunGrab() {
function physgunGrab() {
@@ -553,11 +638,11 @@ function start(token, user) {
@@ -553,11 +638,11 @@ function start(token, user) {
const hit = phys.raycast(pgRay, 14);
const hit = phys.raycast(pgRay, 14);
if (!hit) return;
if (!hit) return;
pgState.held = hit.e;
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;
hit.e.grabbedBy = myId;
phys.claim(hit.e);
phys.claim(hit.e);
hit.e.body.angularDamping = .92;
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);
beep(880, .06, 'square', .05);
}
}
function physgunRelease(throwIt = true) {
function physgunRelease(throwIt = true) {
@@ -565,16 +650,24 @@ function start(token, user) {
@@ -565,16 +650,24 @@ function start(token, user) {
if (!e) return;
if (!e) return;
pgState.held = null;
pgState.held = null;
e.grabbedBy = 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);
beep(440, .05, 'square', .04);
}
}
addEventListener('mousedown', (e) => {
addEventListener('mousedown', (e) => {
if (e.button !== 0 || !input.locked || input.uiOpen || input.chatOpen) return;
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
// beams: one for me, plus one per prop another player is holding
function glowDot() {
function glowDot() {
@@ -612,26 +705,49 @@ function start(token, user) {
@@ -612,26 +705,49 @@ function start(token, user) {
pos.needsUpdate = true;
pos.needsUpdate = true;
b.dot.position.copy(to);
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() {
function updateBeams() {
const live = new Set();
const live = new Set();
if (pgState.held) {
if (pgState.held) {
live.add('me');
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) {
if (e.grabbedBy && e.grabbedBy !== myId) {
const o = others.get(e.grabbedBy);
const o = others.get(e.grabbedBy);
if (!o) continue;
if (!o) continue;
live.add(e.id);
live.add(e.id);
setBeam(e.id, o.avatar.heldAnchor.getWorldPosition(beamV), e.body.position);
setBeam(e.id, o.avatar.heldAnchor.getWorldPosition(beamV), e.body.position);
+ beamFor(e.id).line.material.opacity = .8;
}
}
}
}
for (const [key, b] of beams) {
for (const [key, b] of beams) {
@@ -908,35 +1024,26 @@ function start(token, user) {
@@ -908,35 +1024,26 @@ function start(token, user) {
}
}
if (carState.driving) {
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 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';
my.anim = 'sit';
carSendT += dt;
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) {
} else if (my.seat) {
if (mvLen > .4) standUp();
if (mvLen > .4) standUp();
my.anim = 'sit';
my.anim = 'sit';
@@ -1069,17 +1176,6 @@ function start(token, user) {
@@ -1069,17 +1176,6 @@ function start(token, user) {
camera.lookAt(my.x, my.y + 1.45, my.z);
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
// badge-gate paddle animation
if (W.gates) for (const g of W.gates) {
if (W.gates) for (const g of W.gates) {
const target = g.open ? 1.15 : 0;
const target = g.open ? 1.15 : 0;
@@ -1090,7 +1186,7 @@ function start(token, user) {
@@ -1090,7 +1186,7 @@ function start(token, user) {
// ---- sandbox physics ----
// ---- sandbox physics ----
if (pgState.held) {
if (pgState.held) {
const held = 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
else { // drag the grabbed prop toward the point pgState.dist along the view ray
const b = held.body;
const b = held.body;
b.wakeUp();
b.wakeUp();
@@ -1128,7 +1224,7 @@ function start(token, user) {
@@ -1128,7 +1224,7 @@ function start(token, user) {
// debug/testing handle
// debug/testing handle
window.__brs = {
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,
action: doAction, nearest: () => nearest?.id || nearestSeat?.id || null,
spawnProp, toggleSpawn, physgunGrab, physgunRelease, pgState, sleepers: sleeperMap, inv: invApi,
spawnProp, toggleSpawn, physgunGrab, physgunRelease, pgState, sleepers: sleeperMap, inv: invApi,
};
};
@@ -127,12 +127,104 @@ export function initPhysics(scene) {
@@ -127,12 +127,104 @@ export function initPhysics(scene) {
b.position.set(c.x0 + hw, 1.3, c.z0 + hd);
b.position.set(c.x0 + hw, 1.3, c.z0 + hd);
world.addBody(b);
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}
const props = new Map(); // id -> {id, kind, body, mesh, owned, netT, grabbedBy}
let sendT = 0;
let sendT = 0;
@@ -162,7 +254,12 @@ export function initPhysics(scene) {
@@ -162,7 +254,12 @@ export function initPhysics(scene) {
scene.remove(e.mesh);
scene.remove(e.mesh);
props.delete(id);
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
// remote authority: someone else is driving this prop now
function applyState(m) {
function applyState(m) {
@@ -220,9 +317,54 @@ export function initPhysics(scene) {
@@ -220,9 +317,54 @@ export function initPhysics(scene) {
}
}
function step(dt, playerPos) {
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);
if (playerPos) playerPush(playerPos.x, playerPos.y, playerPos.z, dt);
world.step(1 / 60, dt, 3);
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()) {
for (const e of props.values()) {
const b = e.body;
const b = e.body;
// safety net: anything that tunnels the floor or escapes the map snaps back
// safety net: anything that tunnels the floor or escapes the map snaps back
@@ -236,29 +378,37 @@ export function initPhysics(scene) {
@@ -236,29 +378,37 @@ export function initPhysics(scene) {
e.mesh.position.copy(b.position);
e.mesh.position.copy(b.position);
e.mesh.quaternion.copy(b.quaternion);
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;
sendT += dt;
if (sendT > .085) {
if (sendT > .085) {
sendT = 0;
sendT = 0;
for (const e of props.values()) {
for (const e of props.values()) {
if (e.owned && e.body.sleepState !== CANNON.Body.SLEEPING) sendState(e);
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) {
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) {
for (const h of hits) {
if (h.distance > maxDist) break;
if (h.distance > maxDist) break;
let o = h.object;
let o = h.object;
- while (o && !meshes.includes(o)) o = o.parent;
+ while (o && !roots.includes(o)) o = o.parent;
if (!o) continue;
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 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; },
+ };
}
}
@@ -38,6 +38,7 @@
@@ -38,6 +38,7 @@
<div id="hud" class="hidden">
<div id="hud" class="hidden">
<div id="chat-log"></div>
<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="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="prompt" class="hidden"><span id="prompt-key">E</span><span id="prompt-text"></span></div>
<div id="crosshair" class="hidden">·</div>
<div id="crosshair" class="hidden">·</div>
@@ -27,6 +27,10 @@ app.get('/play', (req, res) => res.sendFile(path.join(__dirname, '..', 'public',
@@ -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/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/login', (req, res) => res.json(store.login(req.body.name, req.body.pass)));
app.post('/api/guest', (req, res) => res.json(store.guest()));
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)
// CLI: node server/server.js --admin SomeName (grants admin to an account)
const adminIdx = process.argv.indexOf('--admin');
const adminIdx = process.argv.indexOf('--admin');
@@ -92,9 +96,19 @@ try { savedWorld = JSON.parse(fs.readFileSync(WORLD_FILE, 'utf8')); } catch {}
@@ -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 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]}
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 || {})) {
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;
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
function pruneSleepers() { // nappers expire after a week; keep the 60 most recent
const cutoff = Date.now() - 7 * 864e5;
const cutoff = Date.now() - 7 * 864e5;
for (const [k, s] of sleepers) if ((s.ts || 0) < cutoff) sleepers.delete(k);
for (const [k, s] of sleepers) if ((s.ts || 0) < cutoff) sleepers.delete(k);
@@ -108,7 +122,7 @@ let worldDirty = false;
@@ -108,7 +122,7 @@ let worldDirty = false;
function saveWorld() {
function saveWorld() {
worldDirty = false;
worldDirty = false;
const tmp = WORLD_FILE + '.tmp';
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
fs.renameSync(tmp, WORLD_FILE); // atomic: a crash mid-write can't corrupt the state
}
}
setInterval(() => { if (worldDirty) saveWorld(); }, 10000);
setInterval(() => { if (worldDirty) saveWorld(); }, 10000);
@@ -160,10 +174,14 @@ wss.on('connection', (ws, req) => {
@@ -160,10 +174,14 @@ wss.on('connection', (ws, req) => {
worldDirty = true;
worldDirty = true;
}
}
players.set(id, p);
players.set(id, p);
+ stats.joins++;
+ worldDirty = true;
ws.send(JSON.stringify({
ws.send(JSON.stringify({
t: 'init', id, you: pubState(p), guest: user.guest, admin: user.admin,
t: 'init', id, you: pubState(p), guest: user.guest, admin: user.admin,
inv: user.inv, hotbar: user.hotbar,
inv: user.inv, hotbar: user.hotbar,
+ visitorNum: stats.joins, stats: { joins: stats.joins, naps: stats.naps, marks: tags.length },
+ tags,
mapEdits,
mapEdits,
players: [...players.values()].filter(q => q.id !== id).map(pubState),
players: [...players.values()].filter(q => q.id !== id).map(pubState),
c4: { a: c4.a.state(), b: c4.b.state() },
c4: { a: c4.a.state(), b: c4.b.state() },
@@ -190,7 +208,7 @@ wss.on('connection', (ws, req) => {
@@ -190,7 +208,7 @@ wss.on('connection', (ws, req) => {
ws.on('close', () => {
ws.on('close', () => {
players.delete(id);
players.delete(id);
for (const [cid, c] of Object.entries(cars)) {
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() }); }
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() });
chess.leave(p.key); broadcast({ t: 'chess', s: chess.state() });
@@ -206,6 +224,7 @@ wss.on('connection', (ws, req) => {
@@ -206,6 +224,7 @@ wss.on('connection', (ws, req) => {
};
};
sleepers.set(p.key, s);
sleepers.set(p.key, s);
pruneSleepers();
pruneSleepers();
+ stats.naps++;
worldDirty = true;
worldDirty = true;
broadcast({ t: 'sleep', s });
broadcast({ t: 'sleep', s });
sys(`💤 ${p.name} dozed off mid-break`);
sys(`💤 ${p.name} dozed off mid-break`);
@@ -313,7 +332,17 @@ function handle(p, m) {
@@ -313,7 +332,17 @@ function handle(p, m) {
case 'car': {
case 'car': {
const id = String(m.id || '').slice(0, 12);
const id = String(m.id || '').slice(0, 12);
if (!/^(car\d+|lambo)$/.test(id)) return;
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 (m.op === 'enter') {
if (c.driver && c.driver !== p.id) return;
if (c.driver && c.driver !== p.id) return;
c.driver = p.id;
c.driver = p.id;
@@ -322,15 +351,18 @@ function handle(p, m) {
@@ -322,15 +351,18 @@ function handle(p, m) {
} else if (m.op === 'exit') {
} else if (m.op === 'exit') {
if (c.driver !== p.id) return;
if (c.driver !== p.id) return;
c.driver = null;
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 (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;
break;
}
}
@@ -375,6 +407,27 @@ function handle(p, m) {
@@ -375,6 +407,27 @@ function handle(p, m) {
case 'gate': // badge-gate animation relay so everyone sees paddles open
case 'gate': // badge-gate animation relay so everyone sees paddles open
broadcast({ t: 'gate', id: String(m.id || '').slice(0, 20) });
broadcast({ t: 'gate', id: String(m.id || '').slice(0, 20) });
break;
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
case 'edit': { // admin-only persistent level editor
if (!p.admin) return;
if (!p.admin) return;
if (m.op === 'add' && m.prop && typeof m.prop.kind === 'string') {
if (m.op === 'add' && m.prop && typeof m.prop.kind === 'string') {