openvibers/breakroomsimulator Commits
NPCs, prop protection + friends, fading doors, craft-to-build, gmod physgun grip, sync-float fix
Changes
@@@ -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; }@@@ -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)) { // stackb.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;@@@ -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 noonconst az = t * Math.PI * 2;const d = smooth(-.14, .28, elev); // day factor@@@ -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 menuif (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 growif (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 / testsconst 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 fadesconst 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 stackif (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 topconst 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 stylefor (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,};@@@ -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 restworld.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 crosshairconst 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; },};}@@@ -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 & 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>@@@ -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 fellfunction 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 protectionconst 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 barrelstats.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; // headshotconst 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 remembersconst 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;}@@@ -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;
@@ -174,3 +174,9 @@ body.touch #hp-wrap { bottom: calc(174px + env(safe-area-inset-bottom)); }
@@ -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; }
#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 { 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); }
.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; }
@@ -29,6 +29,13 @@ export const ITEMS = {
@@ -29,6 +29,13 @@ export const ITEMS = {
pistol: { name: 'Pistol', icon: '🔫', type: 'gun' },
pistol: { name: 'Pistol', icon: '🔫', type: 'gun' },
wall: { name: 'Wood wall', icon: '🧱', type: 'build' },
wall: { name: 'Wood wall', icon: '🧱', type: 'build' },
floor: { name: 'Wood floor', 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-cap': { name: 'Cap', icon: '🧢', type: 'clothes', ap: { hat: 1 } },
'hat-beanie': { name: 'Beanie', icon: '👒', type: 'clothes', ap: { hat: 2 } },
'hat-beanie': { name: 'Beanie', icon: '👒', type: 'clothes', ap: { hat: 2 } },
'hat-hardhat': { name: 'Hard hat', icon: '⛑️', type: 'clothes', ap: { hat: 3 } },
'hat-hardhat': { name: 'Hard hat', icon: '⛑️', type: 'clothes', ap: { hat: 3 } },
@@ -134,7 +141,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
@@ -134,7 +141,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
}
}
if (to.arr === from.arr && to.i === from.i) return;
if (to.arr === from.arr && to.i === from.i) return;
const a = from.arr[from.i], b = to.arr[to.i];
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;
b.n += a.n; from.arr[from.i] = null;
} else {
} else {
from.arr[from.i] = b || null;
from.arr[from.i] = b || null;
@@ -179,7 +186,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
@@ -179,7 +186,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
// ---------- API ----------
// ---------- API ----------
function findRoom(id) {
function findRoom(id) {
- const stack = ['food', 'mat', 'build'].includes(ITEMS[id].type);
+ const stack = ['food', 'mat', 'build', 'prop'].includes(ITEMS[id].type);
if (stack) {
if (stack) {
const h = inv.hotbar.find(s => s?.id === id);
const h = inv.hotbar.find(s => s?.id === id);
if (h) return h;
if (h) return h;
@@ -99,13 +99,17 @@ export function initLighting(scene, renderer, shadows = true) {
@@ -99,13 +99,17 @@ export function initLighting(scene, renderer, shadows = true) {
];
];
let indoorK = 0;
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;
let frameN = 0;
function update(dt, px, pz) {
function update(dt, px, pz) {
frameN++;
frameN++;
if (renderer.shadowMap.enabled && frameN % 3 === 0) renderer.shadowMap.needsUpdate = true;
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;
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);
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 elev = -Math.cos(t * Math.PI * 2); // -1 midnight … +1 noon
const az = t * Math.PI * 2;
const az = t * Math.PI * 2;
const d = smooth(-.14, .28, elev); // day factor
const d = smooth(-.14, .28, elev); // day factor
@@ -224,7 +224,7 @@ function start(token, user) {
@@ -224,7 +224,7 @@ function start(token, user) {
myAvatar.setHeld(item);
myAvatar.setHeld(item);
net.send({ t: 'held', item });
net.send({ t: 'held', item });
updateViewmodel();
updateViewmodel();
- if (item === 'physgun') toast('🧲 Hold left-click on a prop or empty car to grab · wheel push/pull · R spin · X delete · Q spawns props', 4600);
+ 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) => {
onWear: (def) => {
if (def.ap) { me.ap = { ...(me.ap || {}), ...def.ap }; rebuildMyAvatar(); net.send({ t: 'appear', ap: me.ap }); }
if (def.ap) { me.ap = { ...(me.ap || {}), ...def.ap }; rebuildMyAvatar(); net.send({ t: 'appear', ap: me.ap }); }
@@ -420,6 +420,15 @@ function start(token, user) {
@@ -420,6 +420,15 @@ function start(token, user) {
if (d2 > 1.9 || (ddx * fx + ddz * fz) / (d2 || 1) < .35) continue;
if (d2 > 1.9 || (ddx * fx + ddz * fz) / (d2 || 1) < .35) continue;
net.send({ t: 'hit', sleeper: key, item: my.held });
net.send({ t: 'hit', sleeper: key, item: my.held });
beep(280, .06, 'square', .09);
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;
break;
}
}
}
}
@@ -431,10 +440,11 @@ function start(token, user) {
@@ -431,10 +440,11 @@ function start(token, user) {
function useKey() {
function useKey() {
const r = invApi.useSelected();
const r = invApi.useSelected();
if (!r) { doSwing(); return; } // nothing selected: throw hands
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 (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 === 'gun') { firePistol(); return; }
if (ITEMS[r.melee]?.type === 'build') { placeBuild(); 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 (ITEMS[r.melee]?.type === 'mat') { toast('🔨 Raw material — press C to craft with it.'); return; }
if (r.melee) { doSwing(); return; }
if (r.melee) { doSwing(); return; }
if (r.def) {
if (r.def) {
@@ -453,7 +463,7 @@ function start(token, user) {
@@ -453,7 +463,7 @@ function start(token, user) {
invApi.toggle();
invApi.toggle();
} else if (e.code === 'KeyV') setFP(!fp);
} else if (e.code === 'KeyV') setFP(!fp);
else if (e.code === 'KeyF') useKey();
else if (e.code === 'KeyF') useKey();
- else if (e.code === 'KeyQ') toggleSpawn();
+ else if (e.code === 'KeyQ') toggleCraft(); // Q = crafting too (spawn menu retired)
else if (e.code === 'KeyC') toggleCraft();
else if (e.code === 'KeyC') toggleCraft();
else if (e.code === 'KeyG') { e.preventDefault(); openTag(); }
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); } }
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) {
@@ -496,7 +506,6 @@ function start(token, user) {
else if (e.code === 'Escape' && started) {
else if (e.code === 'Escape' && started) {
// Escape closes the top UI panel (craft/spawn/inventory/settings) like any game menu
// Escape closes the top UI panel (craft/spawn/inventory/settings) like any game menu
if (craftOpen) toggleCraft(false);
if (craftOpen) toggleCraft(false);
- else if (spawnOpen) toggleSpawn(false);
else if (invApi.state.open) invApi.toggle(false);
else if (invApi.state.open) invApi.toggle(false);
else if (!$('settings').classList.contains('hidden')) { $('settings').classList.add('hidden'); uiFocus('settings', false); }
else if (!$('settings').classList.contains('hidden')) { $('settings').classList.add('hidden'); uiFocus('settings', false); }
}
}
@@ -524,6 +533,9 @@ function start(token, user) {
@@ -524,6 +533,9 @@ function start(token, user) {
(m.tags || []).forEach(addMark);
(m.tags || []).forEach(addMark);
(m.drops || []).forEach(addWorldDrop);
(m.drops || []).forEach(addWorldDrop);
(m.builds || []).forEach(addBuild);
(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
// 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 ?? 0, 999999)).padStart(6, '0');
const pad6 = (v) => String(Math.min(v ?? 0, 999999)).padStart(6, '0');
@@ -851,6 +863,93 @@ function start(token, user) {
@@ -851,6 +863,93 @@ function start(token, user) {
}
}
net.on('tag', (m) => { if (m.op === 'add') addMark(m.tag); });
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 ----------
// ---------- knockables: mow down the landscaping, it grows back ----------
const knockAxis = new THREE.Vector3();
const knockAxis = new THREE.Vector3();
function knockIt(k, dx, dz, mine) {
function knockIt(k, dx, dz, mine) {
@@ -892,24 +991,18 @@ function start(token, user) {
@@ -892,24 +991,18 @@ function start(token, user) {
closeTag();
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);
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)] });
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 ----------
// ---------- crafting (C) — rust-lite ----------
@@ -921,6 +1014,13 @@ function start(token, user) {
@@ -921,6 +1014,13 @@ function start(token, user) {
{ id: 'wall', cost: { wood: 12 }, desc: 'Placeable wall · upgradable to stone' },
{ id: 'wall', cost: { wood: 12 }, desc: 'Placeable wall · upgradable to stone' },
{ id: 'floor', cost: { wood: 8 }, desc: 'Placeable floor panel' },
{ id: 'floor', cost: { wood: 8 }, desc: 'Placeable floor panel' },
{ id: 'pistol', cost: { wood: 25, stone: 40 }, desc: 'Hitscan, 20 dmg — 40 on headshots' },
{ 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');
const craftEl = $('craft-menu'), craftList = $('craft-list'), craftHave = $('craft-have');
let craftOpen = false;
let craftOpen = false;
@@ -966,27 +1066,32 @@ function start(token, user) {
@@ -966,27 +1066,32 @@ function start(token, user) {
stoneWall: new THREE.MeshStandardMaterial({ color: 0x8d9196, roughness: .95 }),
stoneWall: new THREE.MeshStandardMaterial({ color: 0x8d9196, roughness: .95 }),
};
};
function buildLabel(b) {
function buildLabel(b) {
+ if (b.kind === 'door') return 'Door — E opens (owner & friends)';
return b.tier === 'wood'
return b.tier === 'wood'
? `${b.kind === 'wall' ? 'Wall' : 'Floor'} (wood) — E: upgrade to stone (15 🪨)`
? `${b.kind === 'wall' ? 'Wall' : 'Floor'} (wood) — E: upgrade to stone (15 🪨)`
: `${b.kind === 'wall' ? 'Wall' : 'Floor'} (stone)`;
: `${b.kind === 'wall' ? 'Wall' : 'Floor'} (stone)`;
}
}
function addBuild(b) {
function addBuild(b) {
removeBuild(b.id);
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'
const mesh = b.kind === 'wall'
? new THREE.Mesh(new THREE.BoxGeometry(3, 3, .22), mat)
? 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.position.set(b.p[0], b.p[1], b.p[2]);
mesh.rotation.y = b.ry;
mesh.rotation.y = b.ry;
enableShadows(mesh);
enableShadows(mesh);
scene.add(mesh);
scene.add(mesh);
let col = null, body = null;
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 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 };
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);
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 } };
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);
W.interactables.push(inter);
@@ -1011,6 +1116,24 @@ function start(token, user) {
@@ -1011,6 +1116,24 @@ function start(token, user) {
} else if (m.op === 'upgrade') {
} else if (m.op === 'upgrade') {
const e = buildMap.get(m.id);
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); }
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
// placement ghost while a build item is equipped
@@ -1018,9 +1141,10 @@ function start(token, user) {
@@ -1018,9 +1141,10 @@ function start(token, user) {
const ghosts = {
const ghosts = {
wall: new THREE.Mesh(new THREE.BoxGeometry(3, 3, .22), ghostM),
wall: new THREE.Mesh(new THREE.BoxGeometry(3, 3, .22), ghostM),
floor: new THREE.Mesh(new THREE.BoxGeometry(3, .15, 3), 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() {
function ghostPose() {
const fx = -Math.sin(my.yaw), fz = -Math.cos(my.yaw);
const fx = -Math.sin(my.yaw), fz = -Math.cos(my.yaw);
const ry = Math.round(my.yaw / (Math.PI / 12)) * (Math.PI / 12);
const ry = Math.round(my.yaw / (Math.PI / 12)) * (Math.PI / 12);
@@ -1031,7 +1155,7 @@ function start(token, user) {
@@ -1031,7 +1155,7 @@ function start(token, user) {
const g = ghostPose();
const g = ghostPose();
const used = invApi.dropSelected(); // consumes one from the equipped stack
const used = invApi.dropSelected(); // consumes one from the equipped stack
if (used !== kind) return;
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);
beep(520, .07, 'sine', .1);
}
}
@@ -1071,6 +1195,10 @@ function start(token, user) {
@@ -1071,6 +1195,10 @@ function start(token, user) {
const hits = pgRay.intersectObject(s.root, true);
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 };
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()) {
for (const e of buildMap.values()) {
const hits = pgRay.intersectObject(e.mesh, false);
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 };
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) {
@@ -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)] });
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 });
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 === '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' });
else if (best?.kind === 'build') net.send({ t: 'build', op: 'hit', id: best.id, item: 'pistol' });
}
}
net.on('shoot', (m) => {
net.on('shoot', (m) => {
@@ -1114,6 +1243,9 @@ function start(token, user) {
@@ -1114,6 +1243,9 @@ function start(token, user) {
if (!hit) return;
if (!hit) return;
pgState.held = hit.e;
pgState.held = hit.e;
pgState.lastYaw = my.yaw; // view-follow rotation baseline
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));
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);
@@ -1138,7 +1270,8 @@ function start(token, user) {
@@ -1138,7 +1270,8 @@ function start(token, user) {
physgunGrab();
physgunGrab();
if (!pgState.held) beep(220, .05, 'square', .04); // dry-fire click
if (!pgState.held) beep(220, .05, 'square', .04); // dry-fire click
} else if (my.held === 'pistol') firePistol();
} 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) => {
addEventListener('mouseup', (e) => {
if (e.button !== 0) return;
if (e.button !== 0) return;
@@ -1158,45 +1291,52 @@ function start(token, user) {
@@ -1158,45 +1291,52 @@ function start(token, user) {
sp.renderOrder = 7;
sp.renderOrder = 7;
return sp;
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 beamV = new THREE.Vector3();
+ const beamMid = new THREE.Vector3();
+ const beamV2 = new THREE.Vector3();
function beamFor(key) {
function beamFor(key) {
let b = beams.get(key);
let b = beams.get(key);
if (!b) {
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();
const dot = glowDot();
- scene.add(line, dot);
- b = { line, dot };
+ scene.add(group, dot);
+ b = { group, dot };
beams.set(key, b);
beams.set(key, b);
}
}
return b;
return b;
}
}
- function setBeam(key, from, to) {
+ function setBeam(key, from, to, alpha = 1) {
const b = beamFor(key);
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);
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() {
function updateBeams() {
const live = new Set();
const live = new Set();
if (pgState.held) {
if (pgState.held) {
live.add('me');
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()) {
} else if (pgState.lmb && pgEquipped()) {
// gmod-style: the beam fires even with nothing grabbed, ending on
// gmod-style: the beam fires even with nothing grabbed, ending on
// whatever the crosshair touches (dimmer — it isn't holding anything)
// whatever the crosshair touches (dimmer — it isn't holding anything)
@@ -1215,20 +1355,19 @@ function start(token, user) {
@@ -1215,20 +1355,19 @@ function start(token, user) {
}
}
if (!end) end = pgRay.ray.origin.clone().addScaledVector(pgRay.ray.direction, 20);
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()]) {
for (const e of [...phys.props.values(), ...phys.cars.values()]) {
if (e.grabbedBy && e.grabbedBy !== myId) {
if (e.grabbedBy && e.grabbedBy !== myId) {
const o = others.get(e.grabbedBy);
const o = others.get(e.grabbedBy);
if (!o) continue;
if (!o) continue;
live.add(e.id);
live.add(e.id);
- setBeam(e.id, o.avatar.heldAnchor.getWorldPosition(beamV), e.body.position);
- 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) {
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) {
@@ -1246,7 +1385,10 @@ function start(token, user) {
if (e) { e.grabbedBy = m.by; e.owned = false; }
if (e) { e.grabbedBy = m.by; e.owned = false; }
} else if (m.op === 'drop') {
} else if (m.op === 'drop') {
const e = phys.props.get(m.id);
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) {
@@ -1310,6 +1452,27 @@ function start(token, user) {
? 'Guest session — create an account to save stats.'
? '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>`;
: `🏓 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-settings').onclick = openSettings;
$('btn-settings2').onclick = openSettings; // the ⚙️ — same panel, graphics on top
$('btn-settings2').onclick = openSettings; // the ⚙️ — same panel, graphics on top
const refreshCfgUI = () => {
const refreshCfgUI = () => {
@@ -1452,7 +1615,7 @@ function start(token, user) {
@@ -1452,7 +1615,7 @@ function start(token, user) {
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
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;
return;
case 'dropitem':
case 'dropitem':
@@ -1461,6 +1624,7 @@ function start(token, user) {
@@ -1461,6 +1624,7 @@ function start(token, user) {
case 'build': {
case 'build': {
const be = buildMap.get(nearest.data.build);
const be = buildMap.get(nearest.data.build);
if (!be) return;
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 (be.b.tier !== 'wood') { toast('🧱 Already stone — solid.'); return; }
if (invApi.count('stone') < 15) { toast('Need 15 🪨 to upgrade this to stone.'); return; }
if (invApi.count('stone') < 15) { toast('Need 15 🪨 to upgrade this to stone.'); return; }
invApi.consume('stone', 15);
invApi.consume('stone', 15);
@@ -1663,7 +1827,8 @@ function start(token, user) {
@@ -1663,7 +1827,8 @@ function start(token, user) {
const tx3 = camera.position.x + dir2.x * pgState.dist;
const tx3 = camera.position.x + dir2.x * pgState.dist;
const ty3 = Math.max(.3, camera.position.y + dir2.y * pgState.dist);
const ty3 = Math.max(.3, camera.position.y + dir2.y * pgState.dist);
const tz3 = camera.position.z + dir2.z * 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);
const vl = Math.hypot(vx3, vy3, vz3);
if (vl > 22) { vx3 *= 22 / vl; vy3 *= 22 / vl; vz3 *= 22 / vl; }
if (vl > 22) { vx3 *= 22 / vl; vy3 *= 22 / vl; vz3 *= 22 / vl; }
b.velocity.set(vx3, vy3, vz3);
b.velocity.set(vx3, vy3, vz3);
@@ -1673,7 +1838,7 @@ function start(token, user) {
@@ -1673,7 +1838,7 @@ function start(token, user) {
if (input.keys.KeyR) b.angularVelocity.set(0, 2.8, 0);
if (input.keys.KeyR) b.angularVelocity.set(0, 2.8, 0);
else {
else {
b.angularVelocity.set(0, 0, 0);
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) {
@@ -1775,6 +1940,27 @@ function start(token, user) {
s.zz.position.y = .7 + Math.sin(s.t * 1.6) * .07;
s.zz.position.y = .7 + Math.sin(s.t * 1.6) * .07;
s.zz.material.opacity = .65 + Math.sin(s.t * 1.6) * .3;
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
// dropped items spin and bob, minecraft style
for (const e of worldDrops.values()) {
for (const e of worldDrops.values()) {
e.t += dt;
e.t += dt;
@@ -1837,12 +2023,11 @@ function start(token, user) {
@@ -1837,12 +2023,11 @@ function start(token, user) {
if (tr.ttl <= 0) { scene.remove(tr.line); tracers.splice(ti, 1); }
if (tr.ttl <= 0) { scene.remove(tr.line); tracers.splice(ti, 1); }
}
}
// build-placement ghost follows your aim while a piece is equipped
// 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) {
if (bkind) {
const gp = ghostPose();
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;
ghosts[bkind].rotation.y = gp.ry;
}
}
@@ -1887,7 +2072,7 @@ function start(token, user) {
@@ -1887,7 +2072,7 @@ function start(token, user) {
window.__brs = {
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); },
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,
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,
drops: worldDrops, dropItem: dropItemInWorld, knockIt,
};
};
@@ -155,6 +155,8 @@ export function initPhysics(scene) {
@@ -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.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.position.set(car.x, CAR_HH + .05, car.z);
body.quaternion.setFromEuler(0, car.ry, 0);
body.quaternion.setFromEuler(0, car.ry, 0);
+ body.interpolatedPosition.copy(body.position);
+ body.interpolatedQuaternion.copy(body.quaternion);
body.sleepSpeedLimit = .35;
body.sleepSpeedLimit = .35;
body.sleepTimeLimit = .6;
body.sleepTimeLimit = .6;
body.sleep();
body.sleep();
@@ -225,6 +227,8 @@ export function initPhysics(scene) {
@@ -225,6 +227,8 @@ export function initPhysics(scene) {
if (snap) {
if (snap) {
e.body.position.copy(e.rp);
e.body.position.copy(e.rp);
e.body.quaternion.copy(e.rq);
e.body.quaternion.copy(e.rq);
+ e.body.interpolatedPosition.copy(e.rp);
+ e.body.interpolatedQuaternion.copy(e.rq);
e.body.velocity.setZero();
e.body.velocity.setZero();
e.body.angularVelocity.setZero();
e.body.angularVelocity.setZero();
e.remote = false;
e.remote = false;
@@ -254,6 +258,8 @@ export function initPhysics(scene) {
@@ -254,6 +258,8 @@ export function initPhysics(scene) {
body.quaternion.set(prop.q[0], prop.q[1], prop.q[2], prop.q[3]);
body.quaternion.set(prop.q[0], prop.q[1], prop.q[2], prop.q[3]);
body.sleepSpeedLimit = .4;
body.sleepSpeedLimit = .4;
body.sleepTimeLimit = .5;
body.sleepTimeLimit = .5;
+ body.interpolatedPosition.copy(body.position);
+ body.interpolatedQuaternion.copy(body.quaternion);
if (!mine) body.sleep(); // server-restored / remote props start at rest
if (!mine) body.sleep(); // server-restored / remote props start at rest
world.addBody(body);
world.addBody(body);
const mesh = def.build();
const mesh = def.build();
@@ -285,6 +291,8 @@ export function initPhysics(scene) {
@@ -285,6 +291,8 @@ export function initPhysics(scene) {
e.owned = false;
e.owned = false;
e.body.position.set(m.p[0], m.p[1], m.p[2]);
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.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]);
if (m.v) e.body.velocity.set(m.v[0], m.v[1], m.v[2]);
e.body.wakeUp();
e.body.wakeUp();
}
}
@@ -428,11 +436,33 @@ export function initPhysics(scene) {
@@ -428,11 +436,33 @@ export function initPhysics(scene) {
return null;
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();
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.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
// dynamically placed base pieces need real static bodies (cars must crash
@@ -450,7 +480,7 @@ export function initPhysics(scene) {
@@ -450,7 +480,7 @@ export function initPhysics(scene) {
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, yawBody, addStatic, removeStatic, PHYS_KINDS,
+ drive, step, smack, raycast, yawBody, grabLocal, anchorWorld, addStatic, removeStatic, PHYS_KINDS,
setMyId(id) { myId = id; },
setMyId(id) { myId = id; },
};
};
}
}
@@ -81,12 +81,6 @@
@@ -81,12 +81,6 @@
<!-- hotbar -->
<!-- hotbar -->
<div id="hotbar"></div>
<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 -->
<!-- pong swing UI -->
<div id="pong-ui" class="hidden">
<div id="pong-ui" class="hidden">
<div id="pong-score"></div>
<div id="pong-score"></div>
@@ -126,6 +120,9 @@
@@ -126,6 +120,9 @@
<div class="set-row"><span>Extra lights</span><input id="set-xlights" type="checkbox"></div>
<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>Antialiasing</span><input id="set-aa" type="checkbox"></div>
<div class="set-row"><span>FPS counter</span><input id="set-fps" 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 & 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>
<p style="margin-top:6px">Game settings</p>
<div class="set-row"><span>Scroll wheel</span>
<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>
<div class="seg"><button id="set-wheel-zoom" class="segbtn">Zoom</button><button id="set-wheel-hotbar" class="segbtn">Hotbar</button></div>
@@ -158,6 +158,88 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
@@ -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
// a victim's whole inventory scatters on the floor around where they fell
function spillInventory(key, x, z) {
function spillInventory(key, x, z) {
const u = store.getInventory(key);
const u = store.getInventory(key);
@@ -224,6 +306,8 @@ wss.on('connection', (ws, req) => {
@@ -224,6 +306,8 @@ wss.on('connection', (ws, req) => {
cars,
cars,
sleepers: [...sleepers.values()],
sleepers: [...sleepers.values()],
props: [...props.values()],
props: [...props.values()],
+ npcs: npcSnapshot(),
+ friends: store.getFriends(user.key),
online: players.size,
online: players.size,
}));
}));
if (slept) broadcast({ t: 'wake', key: user.key }, id);
if (slept) broadcast({ t: 'wake', key: user.key }, id);
@@ -412,7 +496,7 @@ function handle(p, m) {
@@ -412,7 +496,7 @@ function handle(p, m) {
if (!KINDS.includes(m.kind)) return;
if (!KINDS.includes(m.kind)) return;
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
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], owner: p.key };
props.set(prop.id, prop);
props.set(prop.id, prop);
stats.props++;
stats.props++;
worldDirty = true;
worldDirty = true;
@@ -420,6 +504,7 @@ function handle(p, m) {
@@ -420,6 +504,7 @@ function handle(p, m) {
} else if (m.op === 'state') {
} else if (m.op === 'state') {
const pr = props.get(String(m.id));
const pr = props.get(String(m.id));
if (!pr) return;
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 pos = Array.isArray(m.p) ? m.p.map(Number) : [];
const q = Array.isArray(m.q) ? m.q.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;
if (pos.length !== 3 || !pos.every(Number.isFinite) || q.length !== 4 || !q.every(Number.isFinite)) return;
@@ -431,10 +516,16 @@ function handle(p, m) {
@@ -431,10 +516,16 @@ function handle(p, m) {
} else if (m.op === 'grab' || m.op === 'drop') {
} else if (m.op === 'grab' || m.op === 'drop') {
const pr = props.get(String(m.id));
const pr = props.get(String(m.id));
if (!pr) return;
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);
broadcast({ t: 'prop', op: m.op, id: pr.id, by: p.id }, p.id);
} else if (m.op === 'del') {
} else if (m.op === 'del') {
const pr = props.get(String(m.id));
const pr = props.get(String(m.id));
if (!pr) return;
if (!pr) return;
+ if (pr.owner && !store.isFriend(pr.owner, p.key)) return;
props.delete(pr.id);
props.delete(pr.id);
worldDirty = true;
worldDirty = true;
broadcast({ t: 'prop', op: 'del', id: pr.id });
broadcast({ t: 'prop', op: 'del', id: pr.id });
@@ -485,13 +576,13 @@ function handle(p, m) {
@@ -485,13 +576,13 @@ function handle(p, m) {
if (now - (p.lastBuild || 0) < 400) return;
if (now - (p.lastBuild || 0) < 400) return;
p.lastBuild = now;
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 (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) : [];
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
if (pos.length !== 3 || !pos.every(Number.isFinite) || !Number.isFinite(+m.ry)) return;
if (pos.length !== 3 || !pos.every(Number.isFinite) || !Number.isFinite(+m.ry)) return;
const b = {
const b = {
id: 'b' + nextBuildId++, kind: m.kind, tier: 'wood',
id: 'b' + nextBuildId++, kind: m.kind, tier: 'wood',
p: [clampX(pos[0]), Math.max(0, Math.min(12, pos[1])), clampZ(pos[2])],
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);
builds.set(b.id, b);
worldDirty = true;
worldDirty = true;
@@ -499,6 +590,10 @@ function handle(p, m) {
@@ -499,6 +590,10 @@ function handle(p, m) {
} else if (m.op === 'hit') {
} else if (m.op === 'hit') {
const b = builds.get(String(m.id));
const b = builds.get(String(m.id));
if (!b) return;
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 BDMG = { axe: 10, stoneaxe: 14, pickaxe: 12, stonepick: 16, pistol: 15, wrench: 8 };
const dmg = BDMG[String(m.item)] ?? 4;
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;
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) {
@@ -508,9 +603,18 @@ function handle(p, m) {
builds.delete(b.id);
builds.delete(b.id);
broadcast({ t: 'build', op: 'del', id: b.id });
broadcast({ t: 'build', op: 'del', id: b.id });
} else broadcast({ t: 'build', op: 'hp', id: b.id, hp: b.hp });
} 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') {
} else if (m.op === 'upgrade') {
const b = builds.get(String(m.id));
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.tier = 'stone';
b.hp = BUILD_HP.stone;
b.hp = BUILD_HP.stone;
worldDirty = true;
worldDirty = true;
@@ -518,6 +622,14 @@ function handle(p, m) {
@@ -518,6 +622,14 @@ function handle(p, m) {
}
}
break;
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
case 'burn': { // hall-of-records bookkeeping for the barrel
stats.burns += Math.max(1, Math.min(99, Math.floor(Number(m.n) || 1)));
stats.burns += Math.max(1, Math.min(99, Math.floor(Number(m.n) || 1)));
worldDirty = true;
worldDirty = true;
@@ -534,6 +646,25 @@ function handle(p, m) {
@@ -534,6 +646,25 @@ function handle(p, m) {
const hs = weapon === 'pistol' && m.hs === true;
const hs = weapon === 'pistol' && m.hs === true;
if (hs) dmg *= 2; // headshot
if (hs) dmg *= 2; // headshot
const maxRange = weapon === 'pistol' ? 55 : 3.4;
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}`;
const label = weapon === 'fists' ? 'their fists' : `a ${weapon}`;
if (m.sleeper) { // sleepers are fair game — brutal, but this place remembers
if (m.sleeper) { // sleepers are fair game — brutal, but this place remembers
const key = String(m.sleeper);
const key = String(m.sleeper);
@@ -559,13 +690,7 @@ function handle(p, m) {
@@ -559,13 +690,7 @@ function handle(p, m) {
return;
return;
}
}
// death: everything they carried spills where they fell, then respawn
// 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`);
sys(`💀 ${p.name} clocked ${t.name} with ${label} — their stuff hit the floor`);
break;
break;
}
}
@@ -126,7 +126,8 @@ export function setAppearance(key, ap) {
@@ -126,7 +126,8 @@ export function setAppearance(key, ap) {
export const ITEM_IDS = ['chips','soda','candy','coffee','energy','water','food',
export const ITEM_IDS = ['chips','soda','candy','coffee','energy','water','food',
'paddle','broom','tapegun','tube','wrench','banana','physgun','flashlight',
'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'];
'hat-cap','hat-beanie','hat-hardhat','vest-yellow','vest-orange','vest-green','vest-blue','vest-pink'];
export function setInventory(key, inv, hotbar) {
export function setInventory(key, inv, hotbar) {
const u = users[key] || guests.get(key);
const u = users[key] || guests.get(key);
@@ -160,6 +161,35 @@ export function addWin(key, stat) {
@@ -160,6 +161,35 @@ export function addWin(key, stat) {
saveSoon();
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) {
export function getInventory(key) {
const u = users[key] || guests.get(key);
const u = users[key] || guests.get(key);
return u ? { inv: u.inv || [], hotbar: u.hotbar || [] } : null;
return u ? { inv: u.inv || [], hotbar: u.hotbar || [] } : null;