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