openvibers/breakroomsimulator Commits

Fix physgun freeze (lost function), shadow wedges, zombie behavior, sleeper combat parity

OP OpenVibers committed 30 Jul 2026 · c3b183df38 · 4 files, +78 −8

- Physgun 'freeze while holding click': the last deploy lost the
  myBeamFrom() definition during file surgery — every held/idle-beam frame
  threw a ReferenceError BEFORE renderer.render, so the screen stopped
  updating until mouseup stopped the exception. Restored (beam origin at
  the viewmodel gun tip in FP / held gun in 3P).
- Bug50/51 giant moving shadow: it was the shadow-camera coverage boundary
  painted indoors by direct sun (sun 'through the roof' where the 96m
  shadow box ended, dark where covered). Direct sun now fades to ~0 while
  indoors (fluorescents light interiors anyway) and the shadow follow is
  quantized to a 4m grid to stop boundary swim.
- Zombies: refuse to step inside the lit facility (server-side rect check),
  carry a lit torch (emissive flame + glow sprite) and have ember eyes so
  you see them coming at night, and visibly burn up in a flame puff at
  sunrise (they already despawned; now it reads).
- Sleeper combat parity: partial hits on nappers broadcast sleephurt —
  the napper flashes red for everyone and the attacker gets a hitmarker.
- Sleeper deaths now spill EVERYTHING: the debounced inventory save could
  be lost when the tab closed, so the server only knew a stale inventory.
  The pending save now flushes on pagehide.

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

Changes

ddiff --git a/public/js/inventory.js b/public/js/inventory.js
iindex bdd23bc..df82641 100644
---- a/public/js/inventory.js
++++ b/public/js/inventory.js
@@@ -64,6 +64,10 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
clearTimeout(saveT);
saveT = setTimeout(() => net.send({ t: 'inv', inv: inv.slots, hotbar: inv.hotbar }), 400);
};
++ const flush = () => { // fire the pending save NOW (tab closing)
++ clearTimeout(saveT);
++ net.send({ t: 'inv', inv: inv.slots, hotbar: inv.hotbar });
++ };
// ---------- DOM ----------
const hotbarEl = document.getElementById('hotbar');
@@@ -288,5 +292,5 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
if (el?.dataset?.bar !== undefined) select(+el.dataset.bar);
});
-- return { add, select, useSelected, dropSelected, count, consume, toggle, selectedItem, restore, state: inv };
++ return { add, select, useSelected, dropSelected, count, consume, toggle, selectedItem, restore, flush, state: inv };
}
ddiff --git a/public/js/lighting.js b/public/js/lighting.js
iindex 239662b..f94ceb8 100644
---- a/public/js/lighting.js
++++ b/public/js/lighting.js
@@@ -119,10 +119,13 @@ export function initLighting(scene, renderer, shadows = true) {
scene.background.copy(sky);
scene.fog.color.copy(sky);
-- sun.intensity = 1.4 * d;
++ // no direct sun indoors: interiors are lit by the fluorescents, and sun
++ // spilling "through the roof" painted giant moving shadow-boundary wedges
++ sun.intensity = 1.4 * d * (1 - indoorK * .92);
sun.color.copy(sunWarm).lerp(sunNoon, smooth(.05, .45, elev));
-- sun.position.set(px + Math.sin(az) * 80, Math.max(4, elev * 90), pz + Math.cos(az) * 40);
-- sun.target.position.set(px, 0, pz);
++ const qx = Math.round(px / 4) * 4, qz = Math.round(pz / 4) * 4; // quantized follow = less boundary swim
++ sun.position.set(qx + Math.sin(az) * 80, Math.max(4, elev * 90), qz + Math.cos(az) * 40);
++ sun.target.position.set(qx, 0, qz);
moon.intensity = .22 * (1 - d);
moon.position.set(px - Math.sin(az) * 70, Math.max(10, -elev * 80), pz - Math.cos(az) * 35);
ddiff --git a/public/js/main.js b/public/js/main.js
iindex 4adbc48..b068463 100644
---- a/public/js/main.js
++++ b/public/js/main.js
@@@ -757,7 +757,7 @@ function start(token, user) {
root.add(zz);
enableShadows(root);
scene.add(root);
-- sleeperMap.set(s.key, { root, zz, t: Math.random() * 6 });
++ sleeperMap.set(s.key, { root, zz, av, t: Math.random() * 6 });
}
function removeSleeper(key) {
const e = sleeperMap.get(key);
@@@ -767,6 +767,10 @@ function start(token, user) {
}
net.on('sleep', (m) => addSleeper(m.s));
net.on('wake', (m) => removeSleeper(m.key));
++ net.on('sleephurt', (m) => {
++ sleeperMap.get(m.key)?.av?.flash?.();
++ if (m.by === myId) hitmarker(false);
++ });
// ---------- dropped items on the floor (minecraft style) ----------
const worldDrops = new Map(); // id -> {group, mesh, inter, t}
@@@ -900,7 +904,29 @@ function start(token, user) {
const skin = M2(0x6a9c4e, .9), shirt = M2(0x3a3f46, .95);
g.add(bx(.44, .62, .26, shirt, 0, 1.12, 0));
head = bx(.3, .3, .3, skin, 0, 1.62, 0);
++ // ember eyes so they read at night
++ const eyeM = new THREE.MeshStandardMaterial({ color: 0xff7a2a, emissive: 0xff7a2a, emissiveIntensity: 2 });
++ head.add(bx(.05, .04, .02, eyeM, -.07, .03, .16));
++ head.add(bx(.05, .04, .02, eyeM, .07, .03, .16));
g.add(head);
++ // a lit torch in hand — see them coming
++ const torchG = new THREE.Group();
++ torchG.add(bx(.05, .4, .05, M2(0x5a4028, .9), 0, 0, 0));
++ const flameM = new THREE.MeshStandardMaterial({ color: 0xffb42e, emissive: 0xff8a1c, emissiveIntensity: 2.6 });
++ torchG.add(bx(.1, .16, .1, flameM, 0, .27, 0));
++ const glow = new THREE.Sprite(new THREE.SpriteMaterial({
++ map: ct(64, 64, (gg, w2, h2) => {
++ const gr = gg.createRadialGradient(w2 / 2, h2 / 2, 3, w2 / 2, h2 / 2, 30);
++ gr.addColorStop(0, 'rgba(255,190,90,.9)'); gr.addColorStop(1, 'rgba(255,140,30,0)');
++ gg.fillStyle = gr; gg.fillRect(0, 0, w2, h2);
++ }),
++ transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
++ }));
++ glow.scale.set(1.1, 1.1, 1);
++ glow.position.y = .3;
++ torchG.add(glow);
++ torchG.position.set(.42, 1.25, .3);
++ g.add(torchG);
for (const ax of [-.3, .3]) { // arms out front, classic
const arm = bx(.11, .11, .55, skin, ax, 1.3, .34);
arms.push(arm); g.add(arm);
@@@ -929,7 +955,29 @@ function start(token, user) {
if (!n) return;
scene.remove(n.group);
npcMap.delete(id);
-- if (burn) beep(190, .3, 'sawtooth', .08);
++ if (burn) { // minecraft sunrise: they go up in flames
++ beep(190, .3, 'sawtooth', .08);
++ const fl = new THREE.Sprite(new THREE.SpriteMaterial({
++ map: ct(64, 96, (gg, w2, h2) => {
++ const gr = gg.createLinearGradient(0, h2, 0, 0);
++ gr.addColorStop(0, 'rgba(255,120,20,.95)'); gr.addColorStop(.7, 'rgba(255,200,60,.5)'); gr.addColorStop(1, 'rgba(255,240,160,0)');
++ gg.fillStyle = gr; gg.fillRect(0, 0, w2, h2);
++ }),
++ transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
++ }));
++ fl.position.set(n.group.position.x, 1.1, n.group.position.z);
++ fl.scale.set(1.2, 2, 1);
++ scene.add(fl);
++ let life = 1;
++ const fade = () => {
++ life -= .06;
++ fl.material.opacity = Math.max(0, life);
++ fl.position.y += .05;
++ if (life > 0) requestAnimationFrame(fade);
++ else scene.remove(fl);
++ };
++ fade();
++ }
}
net.on('npcs', (m) => {
const seen = new Set();
@@@ -1224,6 +1272,11 @@ function start(token, user) {
});
// ---------- physgun: hold LMB to grab · wheel push/pull · R spin · X delete ----------
++ const muzzleV = new THREE.Vector3();
++ function myBeamFrom() { // the beam starts at the GUN, gmod-style
++ if (fp) return camera.localToWorld(muzzleV.set(.31, -.26, -.75)); // viewmodel gun tip
++ return myAvatar.heldAnchor.getWorldPosition(muzzleV);
++ }
const pgState = { held: null, dist: 0, lmb: false, missT: 0 };
const pgRay = new THREE.Raycaster();
function pgEquipped() { return my.held === 'physgun'; }
@@@ -1392,6 +1445,7 @@ function start(token, user) {
}
});
++ addEventListener('pagehide', () => { try { invApi.flush(); } catch {} });
connect(token, {
onOpen: () => {},
onClose: (e) => {
ddiff --git a/server/server.js b/server/server.js
iindex aa56a6b..98be51f 100644
---- a/server/server.js
++++ b/server/server.js
@@@ -218,7 +218,12 @@ setInterval(() => { // 5Hz herd-and-horde tick
for (const p of players.values()) { const dd = Math.hypot(p.x - n.x, p.z - n.z); if (dd < bd) { bd = dd; best = p; } }
if (best) {
n.ry = Math.atan2(best.x - n.x, best.z - n.z);
-- if (bd > 1.4) { n.x += Math.sin(n.ry) * sp * step; n.z += Math.cos(n.ry) * sp * step; }
++ if (bd > 1.4) {
++ const nx2 = n.x + Math.sin(n.ry) * sp * step, nz2 = n.z + Math.cos(n.ry) * sp * step;
++ // the facility is lit — zombies won't set foot inside
++ const INDOORS = [[58, 92, 31, 107], [62, 92, -17, 17], [58, 92, 17, 31], [52.5, 58, 39, 99], [48, 53, 63, 82]];
++ if (!INDOORS.some(([x0, x1, z0, z1]) => nx2 >= x0 && nx2 <= x1 && nz2 >= z0 && nz2 <= z1)) { n.x = nx2; n.z = nz2; }
++ }
else if (Date.now() - n.atkT > 1200) {
n.atkT = Date.now();
best.hp = (best.hp ?? 100) - 8;
@@@ -672,7 +677,11 @@ function handle(p, m) {
if (!s) return;
if (Math.hypot(p.x - s.x, p.z - s.z) > maxRange) return;
s.hp = (s.hp ?? 40) - dmg; // sleepers are fragile
-- if (s.hp > 0) { worldDirty = true; return; }
++ if (s.hp > 0) {
++ worldDirty = true;
++ broadcast({ t: 'sleephurt', key, by: p.id }); // red flash + hitmarker
++ return;
++ }
sleepers.delete(key);
spillInventory(key, s.x, s.z);
stats.kills++;