openvibers/breakroomsimulator Commits

NPCs, prop protection + friends, fading doors, craft-to-build, gmod physgun grip, sync-float fix

OP OpenVibers committed 30 Jul 2026 · 210687357c · 8 files, +475 −91

- NPCs (server-authoritative, 5Hz tick): a herd of cows and chickens grazes
  by the neighborhood; zombies spawn near players at night, chase and bite
  (8 dmg), and burn off at sunrise. Hunt them: cows/chickens drop food,
  zombies drop stone+wood. Melee cone and pistol rays target NPCs; hurt
  flash + boxy walk/shamble animations client-side.
- Building redo: the Q spawn menu is retired — props (box/crate/ball/
  barrel/melon/cone) are now CRAFTED items you place by clicking, same as
  walls/floors. New craftable FADING DOOR (gmod style): E fades it
  passable+transparent for 4s, owner & friends only.
- Protection: props and base pieces record their owner; only the owner and
  their friends can physgun/move/delete props or damage/upgrade builds —
  server-enforced with client force-release + lock messages. Friends
  whitelist UI in settings (add/remove chips), persisted per account.
- Physgun gmod grip: grabs anchor at the exact point you clicked (offset
  held, rotation happens about the grip point), and the beam is a real
  glowing beam (outer haze + bright core cylinders) that fires from the
  gun muzzle in first person / the held gun in third.
- Join-time floating fix: cannon interpolatedPosition/Quaternion were never
  initialized on direct pose sets, so sleeping props/cars rendered at
  stale/origin positions until first touched. Now synced on every snap.
- Night is now ~30% of the 8-minute cycle (2.4min) — enough for zombies
  and vibes, not enough to get boring.

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

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex 6f6544e..db8bc57 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -174,3 +174,9 @@ body.touch #hp-wrap { bottom: calc(174px + env(safe-area-inset-bottom)); }
#fps-meter { position: absolute; top: 12px; left: 12px; z-index: 25; font: 700 13px ui-monospace, monospace; color: #37e06f; background: rgba(10,14,20,.55); padding: 4px 8px; border-radius: 8px; pointer-events: none; }
.xlink { background: rgba(53,224,255,.12); border: 1px solid rgba(53,224,255,.5); color: #9fe8ff; border-radius: 8px; padding: 4px 10px; cursor: pointer; font-family: inherit; font-size: .8rem; margin-left: 10px; }
.xlink:hover { background: rgba(53,224,255,.22); }
++
++/* ---------- friends list ---------- */
++#friends-list { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
++#friends-list .fr { background: rgba(53,224,255,.1); border: 1px solid rgba(53,224,255,.4); color: #9fe8ff; border-radius: 999px; padding: 3px 10px; font-size: .82rem; cursor: pointer; }
++#friends-list .fr:hover { background: rgba(226,38,45,.2); border-color: #e2262d; }
++#friend-name { background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.15); color: #dfe7f0; border-radius: 8px; padding: 6px 10px; font-family: inherit; }
ddiff --git a/public/js/inventory.js b/public/js/inventory.js
iindex 01aad0a..bdd23bc 100644
---- a/public/js/inventory.js
++++ b/public/js/inventory.js
@@@ -29,6 +29,13 @@ export const ITEMS = {
pistol: { name: 'Pistol', icon: '🔫', type: 'gun' },
wall: { name: 'Wood wall', icon: '🧱', type: 'build' },
floor: { name: 'Wood floor', icon: '🟫', type: 'build' },
++ door: { name: 'Fading door', icon: '🚪', type: 'build' },
++ box: { name: 'Amazon box', icon: '📦', type: 'prop' },
++ crate: { name: 'Wood crate', icon: '🪵', type: 'prop' },
++ ball: { name: 'Kickball', icon: '🔴', type: 'prop' },
++ barrel: { name: 'Barrel', icon: '🛢️', type: 'prop' },
++ melon: { name: 'Watermelon', icon: '🍉', type: 'prop' },
++ cone: { name: 'Traffic cone', icon: '🚧', type: 'prop' },
'hat-cap': { name: 'Cap', icon: '🧢', type: 'clothes', ap: { hat: 1 } },
'hat-beanie': { name: 'Beanie', icon: '👒', type: 'clothes', ap: { hat: 2 } },
'hat-hardhat': { name: 'Hard hat', icon: '⛑️', type: 'clothes', ap: { hat: 3 } },
@@@ -134,7 +141,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
}
if (to.arr === from.arr && to.i === from.i) return;
const a = from.arr[from.i], b = to.arr[to.i];
-- if (b && a && b.id === a.id && ['food', 'mat', 'build'].includes(ITEMS[a.id].type)) { // stack
++ if (b && a && b.id === a.id && ['food', 'mat', 'build', 'prop'].includes(ITEMS[a.id].type)) { // stack
b.n += a.n; from.arr[from.i] = null;
} else {
from.arr[from.i] = b || null;
@@@ -179,7 +186,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
// ---------- API ----------
function findRoom(id) {
-- const stack = ['food', 'mat', 'build'].includes(ITEMS[id].type);
++ const stack = ['food', 'mat', 'build', 'prop'].includes(ITEMS[id].type);
if (stack) {
const h = inv.hotbar.find(s => s?.id === id);
if (h) return h;
ddiff --git a/public/js/lighting.js b/public/js/lighting.js
iindex 5a2eb99..239662b 100644
---- a/public/js/lighting.js
++++ b/public/js/lighting.js
@@@ -99,13 +99,17 @@ export function initLighting(scene, renderer, shadows = true) {
];
let indoorK = 0;
++ // night takes only 30% of the loop — it's for the vibes, not the duration
++ function skewT(rawT) {
++ return rawT < .3 ? rawT / .3 * .5 : .5 + (rawT - .3) / .7 * .5;
++ }
let frameN = 0;
function update(dt, px, pz) {
frameN++;
if (renderer.shadowMap.enabled && frameN % 3 === 0) renderer.shadowMap.needsUpdate = true;
const inside = INDOORS.some(([x0, x1, z0, z1]) => px >= x0 && px <= x1 && pz >= z0 && pz <= z1) ? 1 : 0;
indoorK += (inside - indoorK) * Math.min(1, (dt || .016) * 4);
-- const t = timeOfDay();
++ const t = skewT(timeOfDay());
const elev = -Math.cos(t * Math.PI * 2); // -1 midnight … +1 noon
const az = t * Math.PI * 2;
const d = smooth(-.14, .28, elev); // day factor
ddiff --git a/public/js/main.js b/public/js/main.js
iindex d7c5630..4adbc48 100644
---- a/public/js/main.js
++++ b/public/js/main.js
@@@ -224,7 +224,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);
++ if (item === 'physgun') toast('🧲 Hold left-click on a prop or empty car to grab · wheel push/pull · R spin · X delete', 4600);
},
onWear: (def) => {
if (def.ap) { me.ap = { ...(me.ap || {}), ...def.ap }; rebuildMyAvatar(); net.send({ t: 'appear', ap: me.ap }); }
@@@ -420,6 +420,15 @@ function start(token, user) {
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);
++ return;
++ }
++ for (const [id, n] of npcMap) { // hunting / zombie defense
++ const g = n.group.position;
++ const ddx = g.x - my.x, ddz = g.z - my.z;
++ const d2 = Math.hypot(ddx, ddz);
++ if (d2 > 2.1 || (ddx * fx + ddz * fz) / (d2 || 1) < .35) continue;
++ net.send({ t: 'hit', npc: id, item: my.held });
++ beep(300, .06, 'square', .1);
break;
}
}
@@@ -431,10 +440,11 @@ function start(token, user) {
function useKey() {
const r = invApi.useSelected();
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'); return; }
if (r.melee === 'flashlight') { toast('🔦 Lights wherever you look — best after dark.'); return; }
if (ITEMS[r.melee]?.type === 'gun') { firePistol(); return; }
if (ITEMS[r.melee]?.type === 'build') { placeBuild(); return; }
++ if (ITEMS[r.melee]?.type === 'prop') { placeProp(); return; }
if (ITEMS[r.melee]?.type === 'mat') { toast('🔨 Raw material — press C to craft with it.'); return; }
if (r.melee) { doSwing(); return; }
if (r.def) {
@@@ -453,7 +463,7 @@ function start(token, user) {
invApi.toggle();
} else if (e.code === 'KeyV') setFP(!fp);
else if (e.code === 'KeyF') useKey();
-- else if (e.code === 'KeyQ') toggleSpawn();
++ else if (e.code === 'KeyQ') toggleCraft(); // Q = crafting too (spawn menu retired)
else if (e.code === 'KeyC') toggleCraft();
else if (e.code === 'KeyG') { e.preventDefault(); openTag(); }
else if (e.code === 'KeyH') { const dropId = invApi.dropSelected(); if (dropId) { dropItemInWorld(dropId); beep(340, .05, 'sine', .07); } }
@@@ -496,7 +506,6 @@ function start(token, user) {
else if (e.code === 'Escape' && started) {
// Escape closes the top UI panel (craft/spawn/inventory/settings) like any game menu
if (craftOpen) toggleCraft(false);
-- else if (spawnOpen) toggleSpawn(false);
else if (invApi.state.open) invApi.toggle(false);
else if (!$('settings').classList.contains('hidden')) { $('settings').classList.add('hidden'); uiFocus('settings', false); }
}
@@@ -524,6 +533,9 @@ function start(token, user) {
(m.tags || []).forEach(addMark);
(m.drops || []).forEach(addWorldDrop);
(m.builds || []).forEach(addBuild);
++ (m.npcs || []).forEach(([id, kind, x, z, ry]) => ensureNpc(id, kind, x, z, ry));
++ myFriends = m.friends || [];
++ renderFriends();
// the HALL OF RECORDS by the front walkway — numbers that only ever grow
if (m.visitorNum) {
const pad6 = (v) => String(Math.min(v ?? 0, 999999)).padStart(6, '0');
@@@ -851,6 +863,93 @@ function start(token, user) {
}
net.on('tag', (m) => { if (m.op === 'add') addMark(m.tag); });
++
++ // ---------- NPCs: the herd and the horde ----------
++ const npcMap = new Map(); // id -> {kind, group, legs, arms, head, t, tx, tz, try, mats}
++ function buildNpcMesh(kind) {
++ const g = new THREE.Group();
++ const legs = [], arms = [];
++ let head = null;
++ const mats = [];
++ const M2 = (c, r = .85) => { const m2 = new THREE.MeshStandardMaterial({ color: c, roughness: r }); mats.push(m2); return m2; };
++ const bx = (w, h, d, m2, x, y, z) => { const o = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m2); o.position.set(x, y, z); return o; };
++ if (kind === 'cow') {
++ const white = M2(0xf2efe8), black = M2(0x26262a);
++ g.add(bx(.8, .7, 1.25, white, 0, .95, 0));
++ g.add(bx(.82, .3, .45, black, 0, 1.05, .2));
++ g.add(bx(.82, .25, .3, black, 0, .85, -.4));
++ head = bx(.42, .42, .4, white, 0, 1.25, .8);
++ head.add(bx(.44, .18, .12, M2(0xe8b8c8), 0, -.14, .18)); // muzzle
++ g.add(head);
++ for (const [lx, lz] of [[-.28, .45], [.28, .45], [-.28, -.45], [.28, -.45]]) {
++ const leg = bx(.16, .6, .16, white, lx, .3, lz);
++ legs.push(leg); g.add(leg);
++ }
++ } else if (kind === 'chicken') {
++ const white = M2(0xf5f2ea);
++ g.add(bx(.32, .3, .42, white, 0, .35, 0));
++ head = bx(.18, .2, .18, white, 0, .62, .16);
++ head.add(bx(.06, .05, .12, M2(0xe8920c, .6), 0, -.02, .14)); // beak
++ head.add(bx(.05, .08, .06, M2(0xd42020, .7), 0, .12, .02)); // comb
++ g.add(head);
++ for (const lx of [-.08, .08]) {
++ const leg = bx(.045, .22, .045, M2(0xe8920c, .6), lx, .11, 0);
++ legs.push(leg); g.add(leg);
++ }
++ } else { // zombie
++ 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);
++ g.add(head);
++ 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);
++ }
++ for (const lx of [-.12, .12]) {
++ const leg = bx(.15, .8, .18, M2(0x2b3038, .95), lx, .4, 0);
++ legs.push(leg); g.add(leg);
++ }
++ }
++ enableShadows(g);
++ return { group: g, legs, arms, head, mats };
++ }
++ function ensureNpc(id, kind, x, z, ry) {
++ let n = npcMap.get(id);
++ if (n) return n;
++ const built = buildNpcMesh(kind);
++ built.group.position.set(x, 0, z);
++ built.group.rotation.y = ry;
++ scene.add(built.group);
++ n = { id, kind, ...built, t: Math.random() * 9, tx: x, tz: z, try: ry };
++ npcMap.set(id, n);
++ return n;
++ }
++ function removeNpc(id, burn) {
++ const n = npcMap.get(id);
++ if (!n) return;
++ scene.remove(n.group);
++ npcMap.delete(id);
++ if (burn) beep(190, .3, 'sawtooth', .08);
++ }
++ net.on('npcs', (m) => {
++ const seen = new Set();
++ for (const [id, kind, x, z, ry] of m.d) {
++ seen.add(id);
++ const n = ensureNpc(id, kind, x, z, ry);
++ n.tx = x; n.tz = z; n.try = ry;
++ }
++ for (const id of [...npcMap.keys()]) if (!seen.has(id)) removeNpc(id);
++ });
++ net.on('npc', (m) => {
++ if (m.op === 'del') removeNpc(m.id, m.burn);
++ else if (m.op === 'hurt') {
++ const n = npcMap.get(m.id);
++ if (!n) return;
++ for (const mt of n.mats) { mt.emissive.setHex(0xd42020); mt.emissiveIntensity = .8; }
++ setTimeout(() => { for (const mt of n.mats) { mt.emissive.setHex(0); mt.emissiveIntensity = 0; } }, 150);
++ }
++ });
++
// ---------- knockables: mow down the landscaping, it grows back ----------
const knockAxis = new THREE.Vector3();
function knockIt(k, dx, dz, mine) {
@@@ -892,24 +991,18 @@ function start(token, user) {
closeTag();
});
-- // ---------- sandbox: spawn menu (Q) ----------
-- const spawnEl = $('spawn-menu'), spawnGrid = $('spawn-grid');
-- for (const [kind, def] of Object.entries(PHYS_KINDS)) {
-- const b = document.createElement('button');
-- b.innerHTML = `<span class="ico">${def.icon}</span>${def.label}`;
-- b.onclick = () => { spawnProp(kind); toggleSpawn(false); };
-- spawnGrid.appendChild(b);
-- }
-- let spawnOpen = false;
-- function toggleSpawn(open) {
-- spawnOpen = open ?? !spawnOpen;
-- spawnEl.classList.toggle('hidden', !spawnOpen);
-- uiFocus('spawn', spawnOpen);
-- }
-- function spawnProp(kind) {
++ // props are CRAFTED now (C) — equip one and click to place it in the world
++ function spawnProp(kind) { // kept for the debug handle / tests
const fx = -Math.sin(my.yaw), fz = -Math.cos(my.yaw);
net.send({ t: 'prop', op: 'spawn', kind, p: [+(my.x + fx * 2.2).toFixed(2), 1.3, +(my.z + fz * 2.2).toFixed(2)] });
-- toast(`${PHYS_KINDS[kind].icon} ${PHYS_KINDS[kind].label} incoming!`);
++ }
++ function placeProp() {
++ const kind = my.held;
++ if (ITEMS[kind]?.type !== 'prop') return;
++ const used = invApi.dropSelected();
++ if (used !== kind) return;
++ spawnProp(kind);
++ toast(`${ITEMS[kind].icon} placed`);
}
// ---------- crafting (C) — rust-lite ----------
@@@ -921,6 +1014,13 @@ function start(token, user) {
{ id: 'wall', cost: { wood: 12 }, desc: 'Placeable wall · upgradable to stone' },
{ id: 'floor', cost: { wood: 8 }, desc: 'Placeable floor panel' },
{ id: 'pistol', cost: { wood: 25, stone: 40 }, desc: 'Hitscan, 20 dmg — 40 on headshots' },
++ { id: 'door', cost: { wood: 14 }, desc: 'Fading door — E opens it for you & friends' },
++ { id: 'box', cost: { wood: 2 }, desc: 'Physics prop' },
++ { id: 'crate', cost: { wood: 6 }, desc: 'Physics prop, heavy' },
++ { id: 'ball', cost: { wood: 2, stone: 2 }, desc: 'Physics prop, bouncy' },
++ { id: 'barrel', cost: { stone: 6 }, desc: 'Physics prop' },
++ { id: 'melon', cost: { wood: 3 }, desc: 'Physics prop, organic' },
++ { id: 'cone', cost: { stone: 3 }, desc: 'Physics prop, official' },
];
const craftEl = $('craft-menu'), craftList = $('craft-list'), craftHave = $('craft-have');
let craftOpen = false;
@@@ -966,27 +1066,32 @@ function start(token, user) {
stoneWall: new THREE.MeshStandardMaterial({ color: 0x8d9196, roughness: .95 }),
};
function buildLabel(b) {
++ if (b.kind === 'door') return 'Door — E opens (owner & friends)';
return b.tier === 'wood'
? `${b.kind === 'wall' ? 'Wall' : 'Floor'} (wood) — E: upgrade to stone (15 🪨)`
: `${b.kind === 'wall' ? 'Wall' : 'Floor'} (stone)`;
}
function addBuild(b) {
removeBuild(b.id);
-- const mat = b.tier === 'wood' ? tierM.woodWall : tierM.stoneWall;
++ let mat = b.tier === 'wood' ? tierM.woodWall : tierM.stoneWall;
++ if (b.kind === 'door') mat = new THREE.MeshStandardMaterial({ color: 0x6b4a2e, roughness: .8, transparent: true }); // per-door: it fades
const mesh = b.kind === 'wall'
? new THREE.Mesh(new THREE.BoxGeometry(3, 3, .22), mat)
-- : new THREE.Mesh(new THREE.BoxGeometry(3, .15, 3), mat);
++ : b.kind === 'door'
++ ? new THREE.Mesh(new THREE.BoxGeometry(1.6, 2.7, .16), mat)
++ : new THREE.Mesh(new THREE.BoxGeometry(3, .15, 3), mat);
mesh.position.set(b.p[0], b.p[1], b.p[2]);
mesh.rotation.y = b.ry;
enableShadows(mesh);
scene.add(mesh);
let col = null, body = null;
-- if (b.kind === 'wall') { // yaw-aware AABB for walking + a real static body for cars
++ if (b.kind === 'wall' || b.kind === 'door') { // yaw-aware AABB + static body
++ const hw2 = b.kind === 'door' ? .8 : 1.5;
const s = Math.abs(Math.sin(b.ry)), c = Math.abs(Math.cos(b.ry));
-- const ex = 1.5 * s + .13 * c, ez = 1.5 * c + .13 * s;
++ const ex = hw2 * s + .13 * c, ez = hw2 * c + .13 * s;
col = { x0: b.p[0] - ex, x1: b.p[0] + ex, z0: b.p[2] - ez, z1: b.p[2] + ez };
W.colliders.push(col);
-- body = phys.addStatic(col.x0, col.x1, col.z0, col.z1, 0, 3);
++ body = phys.addStatic(col.x0, col.x1, col.z0, col.z1, 0, b.kind === 'door' ? 2.7 : 3);
}
const inter = { id: `build-${b.id}`, type: 'build', x: b.p[0], z: b.p[2], r: 2.4, label: buildLabel(b), data: { build: b.id } };
W.interactables.push(inter);
@@@ -1011,6 +1116,24 @@ function start(token, user) {
} else if (m.op === 'upgrade') {
const e = buildMap.get(m.id);
if (e) { e.b.tier = m.tier; e.b.hp = m.hp; addBuild(e.b); beep(820, .1, 'sine', .12); }
++ } else if (m.op === 'fade') { // gmod fading door: see-through + passable for 4s
++ const e = buildMap.get(m.id);
++ if (!e || e.fading) return;
++ e.fading = true;
++ e.mesh.material.opacity = .22;
++ if (e.col) e.col.off = true;
++ if (e.body) { phys.removeStatic(e.body); e.body = null; }
++ beep(980, .08, 'sine', .1);
++ setTimeout(() => {
++ if (!buildMap.has(m.id)) return;
++ e.fading = false;
++ e.mesh.material.opacity = 1;
++ if (e.col) {
++ e.col.off = false;
++ e.body = phys.addStatic(e.col.x0, e.col.x1, e.col.z0, e.col.z1, 0, 2.7);
++ }
++ beep(620, .08, 'sine', .09);
++ }, 4000);
}
});
// placement ghost while a build item is equipped
@@@ -1018,9 +1141,10 @@ function start(token, user) {
const ghosts = {
wall: new THREE.Mesh(new THREE.BoxGeometry(3, 3, .22), ghostM),
floor: new THREE.Mesh(new THREE.BoxGeometry(3, .15, 3), ghostM),
++ door: new THREE.Mesh(new THREE.BoxGeometry(1.6, 2.7, .16), ghostM),
};
-- ghosts.wall.visible = ghosts.floor.visible = false;
-- scene.add(ghosts.wall, ghosts.floor);
++ ghosts.wall.visible = ghosts.floor.visible = ghosts.door.visible = false;
++ scene.add(ghosts.wall, ghosts.floor, ghosts.door);
function ghostPose() {
const fx = -Math.sin(my.yaw), fz = -Math.cos(my.yaw);
const ry = Math.round(my.yaw / (Math.PI / 12)) * (Math.PI / 12);
@@@ -1031,7 +1155,7 @@ function start(token, user) {
const g = ghostPose();
const used = invApi.dropSelected(); // consumes one from the equipped stack
if (used !== kind) return;
-- net.send({ t: 'build', op: 'place', kind, p: [g.x, kind === 'wall' ? 1.5 : .08, g.z], ry: g.ry });
++ net.send({ t: 'build', op: 'place', kind, p: [g.x, kind === 'wall' ? 1.5 : kind === 'door' ? 1.35 : .08, g.z], ry: g.ry });
beep(520, .07, 'sine', .1);
}
@@@ -1071,6 +1195,10 @@ function start(token, user) {
const hits = pgRay.intersectObject(s.root, true);
if (hits.length && (!best || hits[0].distance < best.d)) best = { d: hits[0].distance, kind: 'sleeper', id: key, point: hits[0].point, hs: false };
}
++ for (const [nid, n] of npcMap) {
++ const hits = pgRay.intersectObject(n.group, true);
++ if (hits.length && (!best || hits[0].distance < best.d)) best = { d: hits[0].distance, kind: 'npc', id: nid, point: hits[0].point };
++ }
for (const e of buildMap.values()) {
const hits = pgRay.intersectObject(e.mesh, false);
if (hits.length && (!best || hits[0].distance < best.d)) best = { d: hits[0].distance, kind: 'build', id: e.b.id, point: hits[0].point };
@@@ -1085,6 +1213,7 @@ function start(token, user) {
net.send({ t: 'shoot', to: [+end.x.toFixed(1), +end.y.toFixed(1), +end.z.toFixed(1)] });
if (best?.kind === 'player') net.send({ t: 'hit', target: best.id, item: 'pistol', hs: best.hs });
else if (best?.kind === 'sleeper') net.send({ t: 'hit', sleeper: best.id, item: 'pistol' });
++ else if (best?.kind === 'npc') net.send({ t: 'hit', npc: best.id, item: 'pistol' });
else if (best?.kind === 'build') net.send({ t: 'build', op: 'hit', id: best.id, item: 'pistol' });
}
net.on('shoot', (m) => {
@@@ -1114,6 +1243,9 @@ function start(token, user) {
if (!hit) return;
pgState.held = hit.e;
pgState.lastYaw = my.yaw; // view-follow rotation baseline
++ // gmod grip: remember WHERE on the object you grabbed it
++ pgState.anchor = phys.grabLocal(hit.e, hit.point.x, hit.point.y, hit.point.z);
++ pgState.anchorOut = new hit.e.body.position.constructor();
pgState.dist = Math.max(1.6, Math.min(hit.e.isCar ? 16 : 12, hit.distance));
hit.e.grabbedBy = myId;
phys.claim(hit.e);
@@@ -1138,7 +1270,8 @@ function start(token, user) {
physgunGrab();
if (!pgState.held) beep(220, .05, 'square', .04); // dry-fire click
} else if (my.held === 'pistol') firePistol();
-- else if (my.held === 'wall' || my.held === 'floor') placeBuild();
++ else if (ITEMS[my.held]?.type === 'build') placeBuild();
++ else if (ITEMS[my.held]?.type === 'prop') placeProp();
});
addEventListener('mouseup', (e) => {
if (e.button !== 0) return;
@@@ -1158,45 +1291,52 @@ function start(token, user) {
sp.renderOrder = 7;
return sp;
}
-- const beams = new Map(); // key ('me' | propId) -> {line, dot}
++ // physgun beam: a real glowing beam (outer haze + bright core) instead of a
++ // 1px line, anchored at the gun muzzle
++ function makeBeamMesh() {
++ const g = new THREE.Group();
++ const geoOut = new THREE.CylinderGeometry(.03, .016, 1, 6, 1, true);
++ geoOut.rotateX(Math.PI / 2);
++ const geoIn = new THREE.CylinderGeometry(.011, .006, 1, 6, 1, true);
++ geoIn.rotateX(Math.PI / 2);
++ g.add(new THREE.Mesh(geoOut, new THREE.MeshBasicMaterial({ color: 0x35e0ff, transparent: true, opacity: .3, blending: THREE.AdditiveBlending, depthWrite: false })));
++ g.add(new THREE.Mesh(geoIn, new THREE.MeshBasicMaterial({ color: 0xd8f8ff, transparent: true, opacity: .85, blending: THREE.AdditiveBlending, depthWrite: false })));
++ g.renderOrder = 7;
++ return g;
++ }
++ const beams = new Map(); // key ('me' | id) -> {group, dot}
const beamV = new THREE.Vector3();
++ const beamMid = new THREE.Vector3();
++ const beamV2 = new THREE.Vector3();
function beamFor(key) {
let b = beams.get(key);
if (!b) {
-- const geo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]);
-- const line = new THREE.Line(geo, new THREE.LineBasicMaterial({ color: 0x35e0ff, transparent: true, opacity: .8, blending: THREE.AdditiveBlending, depthTest: false }));
-- line.renderOrder = 7;
-- line.frustumCulled = false;
++ const group = makeBeamMesh();
const dot = glowDot();
-- scene.add(line, dot);
-- b = { line, dot };
++ scene.add(group, dot);
++ b = { group, dot };
beams.set(key, b);
}
return b;
}
-- function setBeam(key, from, to) {
++ function setBeam(key, from, to, alpha = 1) {
const b = beamFor(key);
-- const pos = b.line.geometry.attributes.position;
-- pos.setXYZ(0, from.x, from.y, from.z);
-- pos.setXYZ(1, to.x, to.y, to.z);
-- pos.needsUpdate = true;
++ beamMid.addVectors(from, to).multiplyScalar(.5);
++ b.group.position.copy(beamMid);
++ b.group.lookAt(to);
++ const len = from.distanceTo(to);
++ b.group.scale.set(1, 1, Math.max(.001, len));
++ b.group.children[0].material.opacity = .3 * alpha;
++ b.group.children[1].material.opacity = .85 * alpha;
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());
++ b.dot.material.opacity = alpha;
}
function updateBeams() {
const live = new Set();
if (pgState.held) {
live.add('me');
-- setBeam('me', myBeamFrom(), pgState.held.body.position);
-- beamFor('me').line.material.opacity = .8;
++ const aw = phys.anchorWorld(pgState.held, pgState.anchor, pgState.anchorOut);
++ setBeam('me', myBeamFrom(), beamV.set(aw.x, aw.y, aw.z), 1);
} 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)
@@@ -1215,20 +1355,19 @@ function start(token, user) {
}
if (!end) end = pgRay.ray.origin.clone().addScaledVector(pgRay.ray.direction, 20);
}
-- setBeam('me', myBeamFrom(), end);
-- beamFor('me').line.material.opacity = .3;
++ setBeam('me', myBeamFrom(), end, .35);
}
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;
++ const ip2 = e.body.interpolatedPosition;
++ setBeam(e.id, o.avatar.heldAnchor.getWorldPosition(beamV), beamV2.set(ip2.x, ip2.y, ip2.z), 1);
}
}
for (const [key, b] of beams) {
-- if (!live.has(key)) { scene.remove(b.line, b.dot); beams.delete(key); }
++ if (!live.has(key)) { scene.remove(b.group, b.dot); beams.delete(key); }
}
}
@@@ -1246,7 +1385,10 @@ function start(token, user) {
if (e) { e.grabbedBy = m.by; e.owned = false; }
} else if (m.op === 'drop') {
const e = phys.props.get(m.id);
-- if (e) e.grabbedBy = null;
++ if (e) {
++ e.grabbedBy = null;
++ if (pgState.held === e) { physgunRelease(false); toast('🔒 Not yours — ask the owner to add you as a friend.'); }
++ }
}
});
@@@ -1310,6 +1452,27 @@ function start(token, user) {
? 'Guest session — create an account to save stats.'
: `🏓 Pong wins: <b>${s.pongWins || 0}</b> · 🔴 Connect 4 wins: <b>${s.c4Wins || 0}</b> · ♟️ Chess wins: <b>${s.chessWins || 0}</b>`;
};
++ // friends whitelist (prop & base protection)
++ let myFriends = [];
++ function renderFriends() {
++ const el = $('friends-list');
++ el.innerHTML = '';
++ for (const f of myFriends) {
++ const chip = document.createElement('span');
++ chip.className = 'fr';
++ chip.textContent = f + ' ✕';
++ chip.title = 'remove';
++ chip.onclick = () => net.send({ t: 'friend', op: 'del', name: f });
++ el.appendChild(chip);
++ }
++ if (!myFriends.length) el.innerHTML = '<span style="color:#5d6b7a;font-size:.82rem">nobody yet — friends can move your props and open your doors</span>';
++ }
++ net.on('friends', (m) => { myFriends = m.list || []; renderFriends(); });
++ $('btn-friend-add').onclick = () => {
++ const v = $('friend-name').value.trim();
++ if (v) net.send({ t: 'friend', op: 'add', name: v });
++ $('friend-name').value = '';
++ };
$('btn-settings').onclick = openSettings;
$('btn-settings2').onclick = openSettings; // the ⚙️ — same panel, graphics on top
const refreshCfgUI = () => {
@@@ -1452,7 +1615,7 @@ function start(token, user) {
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');
++ addChat('🧲 Hold left-click to grab (props & empty cars) · wheel push/pull · R spin · X delete · C crafts props & tools · H drops items · G leaves a permanent mark', 'sys');
}
return;
case 'dropitem':
@@@ -1461,6 +1624,7 @@ function start(token, user) {
case 'build': {
const be = buildMap.get(nearest.data.build);
if (!be) return;
++ if (be.b.kind === 'door') { net.send({ t: 'build', op: 'fade', id: be.b.id }); return; }
if (be.b.tier !== 'wood') { toast('🧱 Already stone — solid.'); return; }
if (invApi.count('stone') < 15) { toast('Need 15 🪨 to upgrade this to stone.'); return; }
invApi.consume('stone', 15);
@@@ -1663,7 +1827,8 @@ function start(token, user) {
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 aw = phys.anchorWorld(held, pgState.anchor, pgState.anchorOut);
++ let vx3 = (tx3 - aw.x) * 10, vy3 = (ty3 - aw.y) * 10, vz3 = (tz3 - aw.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);
@@@ -1673,7 +1838,7 @@ function start(token, user) {
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);
++ if (Math.abs(dyaw) > 1e-4) phys.yawBody(held, dyaw, pgState.anchor); // rotate about the grip point
}
}
}
@@@ -1775,6 +1940,27 @@ function start(token, user) {
s.zz.position.y = .7 + Math.sin(s.t * 1.6) * .07;
s.zz.material.opacity = .65 + Math.sin(s.t * 1.6) * .3;
}
++ // NPCs walk toward their server targets with a simple gait
++ for (const n of npcMap.values()) {
++ n.t += dt;
++ const g = n.group;
++ const mvx = n.tx - g.position.x, mvz = n.tz - g.position.z;
++ const moving = Math.hypot(mvx, mvz) > .05;
++ g.position.x += mvx * Math.min(1, dt * 6);
++ g.position.z += mvz * Math.min(1, dt * 6);
++ let drN = n.try - g.rotation.y;
++ while (drN > Math.PI) drN -= Math.PI * 2;
++ while (drN < -Math.PI) drN += Math.PI * 2;
++ g.rotation.y += drN * Math.min(1, dt * 8);
++ const sw = moving ? Math.sin(n.t * (n.kind === 'chicken' ? 16 : 8)) * .5 : 0;
++ n.legs.forEach((leg, li) => { leg.rotation.x = sw * (li % 2 ? -1 : 1); });
++ if (n.kind === 'chicken' && n.head) n.head.position.z = .16 + (moving ? Math.sin(n.t * 16) * .05 : 0);
++ if (n.kind === 'zombie') {
++ n.arms.forEach((a, ai) => { a.rotation.x = Math.sin(n.t * 3 + ai) * .12; });
++ g.rotation.z = Math.sin(n.t * 2.2) * .04; // shamble
++ }
++ }
++
// dropped items spin and bob, minecraft style
for (const e of worldDrops.values()) {
e.t += dt;
@@@ -1837,12 +2023,11 @@ function start(token, user) {
if (tr.ttl <= 0) { scene.remove(tr.line); tracers.splice(ti, 1); }
}
// build-placement ghost follows your aim while a piece is equipped
-- const bkind = (my.held === 'wall' || my.held === 'floor') && !carState.driving ? my.held : null;
-- ghosts.wall.visible = bkind === 'wall';
-- ghosts.floor.visible = bkind === 'floor';
++ const bkind = ITEMS[my.held]?.type === 'build' && !carState.driving ? my.held : null;
++ for (const k2 of ['wall', 'floor', 'door']) ghosts[k2].visible = bkind === k2;
if (bkind) {
const gp = ghostPose();
-- ghosts[bkind].position.set(gp.x, bkind === 'wall' ? 1.5 : .08, gp.z);
++ ghosts[bkind].position.set(gp.x, bkind === 'wall' ? 1.5 : bkind === 'door' ? 1.35 : .08, gp.z);
ghosts[bkind].rotation.y = gp.ry;
}
@@@ -1887,7 +2072,7 @@ function start(token, user) {
window.__brs = {
my, mg, scene, W, phys, input, renderer, daylight, 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,
++ spawnProp, physgunGrab, physgunRelease, pgState, sleepers: sleeperMap, inv: invApi,
drops: worldDrops, dropItem: dropItemInWorld, knockIt,
};
ddiff --git a/public/js/physics.js b/public/js/physics.js
iindex 1ce815b..d6daf75 100644
---- a/public/js/physics.js
++++ b/public/js/physics.js
@@@ -155,6 +155,8 @@ export function initPhysics(scene) {
body.addShape(new CANNON.Box(new CANNON.Vec3(hw * .86, .24, hl * .52)), new CANNON.Vec3(0, CAR_HH + .2, -.2));
body.position.set(car.x, CAR_HH + .05, car.z);
body.quaternion.setFromEuler(0, car.ry, 0);
++ body.interpolatedPosition.copy(body.position);
++ body.interpolatedQuaternion.copy(body.quaternion);
body.sleepSpeedLimit = .35;
body.sleepTimeLimit = .6;
body.sleep();
@@@ -225,6 +227,8 @@ export function initPhysics(scene) {
if (snap) {
e.body.position.copy(e.rp);
e.body.quaternion.copy(e.rq);
++ e.body.interpolatedPosition.copy(e.rp);
++ e.body.interpolatedQuaternion.copy(e.rq);
e.body.velocity.setZero();
e.body.angularVelocity.setZero();
e.remote = false;
@@@ -254,6 +258,8 @@ export function initPhysics(scene) {
body.quaternion.set(prop.q[0], prop.q[1], prop.q[2], prop.q[3]);
body.sleepSpeedLimit = .4;
body.sleepTimeLimit = .5;
++ body.interpolatedPosition.copy(body.position);
++ body.interpolatedQuaternion.copy(body.quaternion);
if (!mine) body.sleep(); // server-restored / remote props start at rest
world.addBody(body);
const mesh = def.build();
@@@ -285,6 +291,8 @@ export function initPhysics(scene) {
e.owned = false;
e.body.position.set(m.p[0], m.p[1], m.p[2]);
e.body.quaternion.set(m.q[0], m.q[1], m.q[2], m.q[3]);
++ e.body.interpolatedPosition.copy(e.body.position);
++ e.body.interpolatedQuaternion.copy(e.body.quaternion);
if (m.v) e.body.velocity.set(m.v[0], m.v[1], m.v[2]);
e.body.wakeUp();
}
@@@ -428,11 +436,33 @@ export function initPhysics(scene) {
return null;
}
-- // gmod-style: rotate a held body's yaw in place (view-follow / R-spin)
++ // gmod-style physgun grip: the grab anchor is WHERE you clicked the object,
++ // not its center — store it body-local, drag that point to the crosshair
const yawQ = new CANNON.Quaternion();
-- function yawBody(e, dyaw) {
++ const invQ = new CANNON.Quaternion();
++ function grabLocal(e, wx, wy, wz) {
++ const b = e.body;
++ e.body.quaternion.conjugate(invQ); // unit quat: conjugate == inverse
++ const rel = new CANNON.Vec3(wx - b.position.x, wy - b.position.y, wz - b.position.z);
++ return invQ.vmult(rel);
++ }
++ const anchorTmp = new CANNON.Vec3();
++ function anchorWorld(e, local, out) {
++ e.body.quaternion.vmult(local, anchorTmp);
++ out.set(e.body.position.x + anchorTmp.x, e.body.position.y + anchorTmp.y, e.body.position.z + anchorTmp.z);
++ return out;
++ }
++ function yawBody(e, dyaw, local = null) {
++ const b = e.body;
yawQ.setFromAxisAngle(V(0, 1, 0), dyaw);
-- yawQ.mult(e.body.quaternion, e.body.quaternion);
++ if (local) { // rotate ABOUT the grab anchor, like gmod
++ const aw = new CANNON.Vec3();
++ b.quaternion.vmult(local, anchorTmp);
++ aw.set(b.position.x + anchorTmp.x, b.position.y + anchorTmp.y, b.position.z + anchorTmp.z);
++ yawQ.mult(b.quaternion, b.quaternion);
++ b.quaternion.vmult(local, anchorTmp);
++ b.position.set(aw.x - anchorTmp.x, aw.y - anchorTmp.y, aw.z - anchorTmp.z);
++ } else yawQ.mult(b.quaternion, b.quaternion);
}
// dynamically placed base pieces need real static bodies (cars must crash
@@@ -450,7 +480,7 @@ export function initPhysics(scene) {
return {
world, props, cars, add, remove, claim, applyState, applyCarState, sendState, sendCarState,
-- drive, step, smack, raycast, yawBody, addStatic, removeStatic, PHYS_KINDS,
++ drive, step, smack, raycast, yawBody, grabLocal, anchorWorld, addStatic, removeStatic, PHYS_KINDS,
setMyId(id) { myId = id; },
};
}
ddiff --git a/public/play.html b/public/play.html
iindex 0f0afb8..d1594d0 100644
---- a/public/play.html
++++ b/public/play.html
@@@ -81,12 +81,6 @@
<!-- hotbar -->
<div id="hotbar"></div>
-- <!-- sandbox spawn menu (Q) -->
-- <div id="spawn-menu" class="hidden">
-- <div class="spawn-title">📦 Spawn a prop <span>(Q closes · physgun to move stuff)</span></div>
-- <div id="spawn-grid"></div>
-- </div>
--
<!-- pong swing UI -->
<div id="pong-ui" class="hidden">
<div id="pong-score"></div>
@@@ -126,6 +120,9 @@
<div class="set-row"><span>Extra lights</span><input id="set-xlights" type="checkbox"></div>
<div class="set-row"><span>Antialiasing</span><input id="set-aa" type="checkbox"></div>
<div class="set-row"><span>FPS counter</span><input id="set-fps" type="checkbox"></div>
++ <p style="margin-top:6px">Friends (can touch your props &amp; base)</p>
++ <div class="set-row"><input id="friend-name" type="text" maxlength="20" placeholder="associate name" style="flex:1"><button id="btn-friend-add" class="segbtn">Add</button></div>
++ <div id="friends-list"></div>
<p style="margin-top:6px">Game settings</p>
<div class="set-row"><span>Scroll wheel</span>
<div class="seg"><button id="set-wheel-zoom" class="segbtn">Zoom</button><button id="set-wheel-hotbar" class="segbtn">Hotbar</button></div>
ddiff --git a/server/server.js b/server/server.js
iindex 3f792d1..aa56a6b 100644
---- a/server/server.js
++++ b/server/server.js
@@@ -158,6 +158,88 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
});
}
++// death: spill everything, reset, respawn at the walkway — used by pvp AND npcs
++function killPlayer(t, byName) {
++ 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: byName, x: t.x, z: t.z }));
++ broadcast({ t: 'hp', id: t.id, hp: 100 });
++}
++
++// ---------- NPCs: pasture animals by the neighborhood, zombies after dark ----------
++const NPC_STATS = { cow: { hp: 40, speed: .9 }, chicken: { hp: 12, speed: 1.5 }, zombie: { hp: 50, speed: 2.3 } };
++const npcs = new Map(); // id -> {id, kind, x, z, ry, hp, hx, hz, tx, tz, atkT}
++let nextNpcId = 1;
++function spawnNpc(kind, x, z) {
++ const n = { id: nextNpcId++, kind, x, z, ry: Math.random() * 6.28, hp: NPC_STATS[kind].hp, hx: x, hz: z, tx: null, tz: null, atkT: 0 };
++ npcs.set(n.id, n);
++ return n;
++}
++for (let i = 0; i < 4; i++) spawnNpc('cow', 38 + i * 7, 148 + (i % 2) * 10);
++for (let i = 0; i < 5; i++) spawnNpc('chicken', 58 + i * 3.5, 156 + (i % 3) * 5);
++function dayFactorNow() { // must match lighting.js (skewed 8-min cycle)
++ const rawT = (Date.now() / 1000 % 480) / 480;
++ const t = rawT < .3 ? rawT / .3 * .5 : .5 + (rawT - .3) / .7 * .5;
++ const elev = -Math.cos(t * Math.PI * 2);
++ const k = Math.max(0, Math.min(1, (elev + .14) / .42));
++ return k * k * (3 - 2 * k);
++}
++function npcSnapshot() {
++ return [...npcs.values()].map(n => [n.id, n.kind, +n.x.toFixed(1), +n.z.toFixed(1), +n.ry.toFixed(2), n.hp]);
++}
++setInterval(() => { // 5Hz herd-and-horde tick
++ const night = dayFactorNow() < .22;
++ const zCount = [...npcs.values()].filter(n => n.kind === 'zombie').length;
++ if (night && players.size > 0 && zCount < 6 && Math.random() < .35) {
++ const ps = [...players.values()];
++ const t = ps[Math.floor(Math.random() * ps.length)];
++ const a = Math.random() * Math.PI * 2, r = 24 + Math.random() * 10;
++ spawnNpc('zombie', clampX(t.x + Math.cos(a) * r), clampZ(t.z + Math.sin(a) * r));
++ if (zCount === 0) sys('🧟 something is shuffling around out there…');
++ }
++ if (!night && zCount) { // sunrise burns them off
++ for (const [id, n] of npcs) if (n.kind === 'zombie') { npcs.delete(id); broadcast({ t: 'npc', op: 'del', id, burn: true }); }
++ }
++ // herd upkeep
++ if (Math.random() < .012) {
++ const cows = [...npcs.values()].filter(n => n.kind === 'cow').length;
++ const hens = [...npcs.values()].filter(n => n.kind === 'chicken').length;
++ if (cows < 4) spawnNpc('cow', 38 + Math.random() * 24, 148 + Math.random() * 14);
++ else if (hens < 5) spawnNpc('chicken', 56 + Math.random() * 16, 154 + Math.random() * 12);
++ }
++ const step = .2; // seconds per tick
++ for (const n of npcs.values()) {
++ const sp = NPC_STATS[n.kind].speed;
++ if (n.kind === 'zombie') {
++ let best = null, bd = 45;
++ 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; }
++ else if (Date.now() - n.atkT > 1200) {
++ n.atkT = Date.now();
++ best.hp = (best.hp ?? 100) - 8;
++ if (best.hp <= 0) { killPlayer(best, 'a zombie'); sys(`🧟 ${best.name} got eaten by a zombie`); }
++ else broadcast({ t: 'hp', id: best.id, hp: best.hp, by: -1 });
++ }
++ }
++ } else { // graze around the home spot
++ if (n.tx == null || Math.hypot(n.tx - n.x, n.tz - n.z) < .6) {
++ if (Math.random() < .08) { n.tx = n.hx + (Math.random() - .5) * 26; n.tz = n.hz + (Math.random() - .5) * 20; }
++ } else {
++ n.ry = Math.atan2(n.tx - n.x, n.tz - n.z);
++ n.x += Math.sin(n.ry) * sp * step;
++ n.z += Math.cos(n.ry) * sp * step;
++ }
++ }
++ n.x = clampX(n.x); n.z = clampZ(n.z);
++ }
++ if (players.size) broadcast({ t: 'npcs', d: npcSnapshot() });
++}, 200);
++
// a victim's whole inventory scatters on the floor around where they fell
function spillInventory(key, x, z) {
const u = store.getInventory(key);
@@@ -224,6 +306,8 @@ wss.on('connection', (ws, req) => {
cars,
sleepers: [...sleepers.values()],
props: [...props.values()],
++ npcs: npcSnapshot(),
++ friends: store.getFriends(user.key),
online: players.size,
}));
if (slept) broadcast({ t: 'wake', key: user.key }, id);
@@@ -412,7 +496,7 @@ function handle(p, m) {
if (!KINDS.includes(m.kind)) return;
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
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], owner: p.key };
props.set(prop.id, prop);
stats.props++;
worldDirty = true;
@@@ -420,6 +504,7 @@ function handle(p, m) {
} else if (m.op === 'state') {
const pr = props.get(String(m.id));
if (!pr) return;
++ if (pr.owner && !store.isFriend(pr.owner, p.key)) return; // prop protection
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
const q = Array.isArray(m.q) ? m.q.map(Number) : [];
if (pos.length !== 3 || !pos.every(Number.isFinite) || q.length !== 4 || !q.every(Number.isFinite)) return;
@@@ -431,10 +516,16 @@ function handle(p, m) {
} else if (m.op === 'grab' || m.op === 'drop') {
const pr = props.get(String(m.id));
if (!pr) return;
++ if (m.op === 'grab' && pr.owner && !store.isFriend(pr.owner, p.key)) {
++ if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 That prop belongs to someone else (they can add you as a friend).' }));
++ broadcast({ t: 'prop', op: 'drop', id: pr.id }, null); // force-release on their client
++ return;
++ }
broadcast({ t: 'prop', op: m.op, id: pr.id, by: p.id }, p.id);
} else if (m.op === 'del') {
const pr = props.get(String(m.id));
if (!pr) return;
++ if (pr.owner && !store.isFriend(pr.owner, p.key)) return;
props.delete(pr.id);
worldDirty = true;
broadcast({ t: 'prop', op: 'del', id: pr.id });
@@@ -485,13 +576,13 @@ function handle(p, m) {
if (now - (p.lastBuild || 0) < 400) return;
p.lastBuild = now;
if (builds.size >= 300) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🧱 Build limit reached (300).' })); return; }
-- if (!['wall', 'floor'].includes(m.kind)) return;
++ if (!['wall', 'floor', 'door'].includes(m.kind)) return;
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
if (pos.length !== 3 || !pos.every(Number.isFinite) || !Number.isFinite(+m.ry)) return;
const b = {
id: 'b' + nextBuildId++, kind: m.kind, tier: 'wood',
p: [clampX(pos[0]), Math.max(0, Math.min(12, pos[1])), clampZ(pos[2])],
-- ry: +(+m.ry).toFixed(3), hp: BUILD_HP.wood, owner: p.key,
++ ry: +(+m.ry).toFixed(3), hp: m.kind === 'door' ? 150 : BUILD_HP.wood, owner: p.key,
};
builds.set(b.id, b);
worldDirty = true;
@@@ -499,6 +590,10 @@ function handle(p, m) {
} else if (m.op === 'hit') {
const b = builds.get(String(m.id));
if (!b) return;
++ if (b.owner && !store.isFriend(b.owner, p.key)) { // base protection
++ if (p.ws.readyState === 1 && Math.random() < .3) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 Protected base — only the owner and their friends can touch it.' }));
++ return;
++ }
const BDMG = { axe: 10, stoneaxe: 14, pickaxe: 12, stonepick: 16, pistol: 15, wrench: 8 };
const dmg = BDMG[String(m.item)] ?? 4;
if (Math.hypot(p.x - b.p[0], p.z - b.p[2]) > (m.item === 'pistol' ? 50 : 4.5)) return;
@@@ -508,9 +603,18 @@ function handle(p, m) {
builds.delete(b.id);
broadcast({ t: 'build', op: 'del', id: b.id });
} else broadcast({ t: 'build', op: 'hp', id: b.id, hp: b.hp });
++ } else if (m.op === 'fade') { // gmod fading door
++ const b = builds.get(String(m.id));
++ if (!b || b.kind !== 'door') return;
++ if (b.owner && !store.isFriend(b.owner, p.key)) {
++ if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 Locked — this door only opens for its owner and their friends.' }));
++ return;
++ }
++ broadcast({ t: 'build', op: 'fade', id: b.id });
} else if (m.op === 'upgrade') {
const b = builds.get(String(m.id));
-- if (!b || b.tier !== 'wood') return;
++ if (!b || b.tier !== 'wood' || b.kind === 'door') return;
++ if (b.owner && !store.isFriend(b.owner, p.key)) return;
b.tier = 'stone';
b.hp = BUILD_HP.stone;
worldDirty = true;
@@@ -518,6 +622,14 @@ function handle(p, m) {
}
break;
}
++ case 'friend': { // build/prop protection whitelist
++ let list = null;
++ if (m.op === 'add') list = store.addFriend(p.key, m.name);
++ else if (m.op === 'del') list = store.delFriend(p.key, m.name);
++ else list = store.getFriends(p.key);
++ if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'friends', list: list || store.getFriends(p.key) }));
++ 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;
@@@ -534,6 +646,25 @@ function handle(p, m) {
const hs = weapon === 'pistol' && m.hs === true;
if (hs) dmg *= 2; // headshot
const maxRange = weapon === 'pistol' ? 55 : 3.4;
++ if (m.npc) { // hunting & zombie-slaying
++ const n = npcs.get(Number(m.npc));
++ if (!n) return;
++ if (Math.hypot(p.x - n.x, p.z - n.z) > maxRange) return;
++ n.hp -= dmg;
++ if (n.hp > 0) { broadcast({ t: 'npc', op: 'hurt', id: n.id }); return; }
++ npcs.delete(n.id);
++ broadcast({ t: 'npc', op: 'del', id: n.id });
++ const loot = n.kind === 'cow' ? [['food', 2]] : n.kind === 'chicken' ? [['food', 1]] : [['stone', 5], ['wood', 3]];
++ for (const [item, cnt] of loot) {
++ if (drops.size >= 200) break;
++ const d = { id: 'd' + nextDropId++, item, n: cnt, x: clampX(+(n.x + (Math.random() - .5)).toFixed(2)), y: .35, z: clampZ(+(n.z + (Math.random() - .5)).toFixed(2)) };
++ drops.set(d.id, d);
++ broadcast({ t: 'drop', op: 'add', d });
++ }
++ worldDirty = true;
++ if (n.kind === 'zombie') sys(`🧟 ${p.name} put down a zombie`);
++ 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);
@@@ -559,13 +690,7 @@ function handle(p, m) {
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 });
++ killPlayer(t, p.name);
sys(`💀 ${p.name} clocked ${t.name} with ${label} — their stuff hit the floor`);
break;
}
ddiff --git a/server/store.js b/server/store.js
iindex ed52053..9604457 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -126,7 +126,8 @@ export function setAppearance(key, ap) {
export const ITEM_IDS = ['chips','soda','candy','coffee','energy','water','food',
'paddle','broom','tapegun','tube','wrench','banana','physgun','flashlight',
-- 'wood','stone','axe','pickaxe','stoneaxe','stonepick','pistol','wall','floor',
++ 'wood','stone','axe','pickaxe','stoneaxe','stonepick','pistol','wall','floor','door',
++ 'box','crate','ball','barrel','melon','cone',
'hat-cap','hat-beanie','hat-hardhat','vest-yellow','vest-orange','vest-green','vest-blue','vest-pink'];
export function setInventory(key, inv, hotbar) {
const u = users[key] || guests.get(key);
@@@ -160,6 +161,35 @@ export function addWin(key, stat) {
saveSoon();
}
++// ---------- friends / build-protection whitelist ----------
++export function addFriend(key, friendName) {
++ const u = users[key] || guests.get(key);
++ const fk = String(friendName || '').trim().toLowerCase();
++ if (!u || !fk || fk === key || fk.length > 20) return null;
++ u.friends = u.friends || [];
++ if (!u.friends.includes(fk)) u.friends.push(fk);
++ if (u.friends.length > 30) u.friends.shift();
++ saveSoon();
++ return u.friends;
++}
++export function delFriend(key, friendName) {
++ const u = users[key] || guests.get(key);
++ const fk = String(friendName || '').trim().toLowerCase();
++ if (!u || !u.friends) return u?.friends || [];
++ u.friends = u.friends.filter(f => f !== fk);
++ saveSoon();
++ return u.friends;
++}
++export function getFriends(key) {
++ const u = users[key] || guests.get(key);
++ return u?.friends || [];
++}
++export function isFriend(ownerKey, otherKey) {
++ if (!ownerKey || ownerKey === otherKey) return true;
++ const u = users[ownerKey] || guests.get(ownerKey);
++ return !!u?.friends?.includes(otherKey);
++}
++
export function getInventory(key) {
const u = users[key] || guests.get(key);
return u ? { inv: u.inv || [], hotbar: u.hotbar || [] } : null;