openvibers/breakroomsimulator Commits

Rust-lite: harvesting, crafting, base building, pistol PvP, combat feedback

OP OpenVibers committed 30 Jul 2026 · b70b1379ee · 9 files, +558 −37

- Trees (new groves + existing ones) and rock formations are harvestable:
  swing to chop wood / mine stone with tool multipliers (axe 2x, stone axe
  3x; pickaxe 2x, stone pickaxe 3x), felling/shatter bonus, rocks crumble
  instead of toppling, everything respawns ~30s later with fresh hp.
- Crafting menu (C): axe, pickaxe, stone-tier upgrades of both, wood wall,
  wood floor, and a pistol — costs shown live against your wood/stone.
  Materials stack to 999.
- Base building: equip a wall/floor and click to place (snapped ghost
  preview). Pieces persist server-side with hp (wood 200 / stone 500),
  E-upgrade wood to stone for 15 stone (networked), take melee/pistol
  damage and break — bases are raidable. Placed walls get real static
  physics bodies so cars crash into them immediately.
- Pistol: hitscan with a small ray fan, 20 dmg, 2x headshots, tracers +
  gunshot relayed to everyone. Server hit validation is now range-aware
  (55m pistol vs 3.4m melee — melee range was silently rejecting every
  pistol hit).
- Combat feedback: victims flash red on everyone's screen, attackers get a
  crosshair hitmarker + HEADSHOT tag and distinct sounds.
- Bug49: hp bar now sits above the hotbar (fixed z-order/position);
  'press G to leave your mark' plaque added under the Hall of Records.

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

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex b4cc301..6430921 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -145,6 +145,27 @@ body.touch #hotbar { bottom: calc(96px + env(safe-area-inset-bottom)); }
#tag-form input::placeholder { color: rgba(255,211,77,.45); }
/* ---------- health ---------- */
--#hp-wrap { position: absolute; bottom: 98px; left: 50%; transform: translateX(-50%); width: 340px; height: 10px; background: rgba(10,14,20,.6); border: 1px solid rgba(255,255,255,.18); border-radius: 6px; overflow: hidden; z-index: 12; }
++#hp-wrap { position: fixed; bottom: calc(88px + env(safe-area-inset-bottom)); left: 50%; transform: translateX(-50%); width: 340px; height: 10px; background: rgba(10,14,20,.6); border: 1px solid rgba(255,255,255,.18); border-radius: 6px; overflow: hidden; z-index: 21; }
++body.touch #hp-wrap { bottom: calc(174px + env(safe-area-inset-bottom)); }
#hp-fill { height: 100%; width: 100%; background: #37e06f; transition: width .18s ease, background .3s ease; }
#dmg-flash { position: absolute; inset: 0; background: radial-gradient(ellipse at center, rgba(226,38,45,0) 42%, rgba(226,38,45,.75) 100%); opacity: 0; pointer-events: none; transition: opacity .35s ease; z-index: 40; }
++
++
++/* ---------- combat feedback ---------- */
++#hitmark { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%) rotate(45deg); width: 26px; height: 26px; opacity: 0; pointer-events: none; z-index: 30; transition: opacity .15s ease; }
++#hitmark::before, #hitmark::after { content: ''; position: absolute; background: #ff5348; box-shadow: 0 0 6px rgba(255,60,50,.9); }
++#hitmark::before { left: 11px; top: 0; width: 4px; height: 26px; }
++#hitmark::after { top: 11px; left: 0; height: 4px; width: 26px; }
++#headshot-tag { position: absolute; left: 50%; top: 42%; transform: translateX(-50%); color: #ff5348; font: 900 26px "Segoe UI", sans-serif; letter-spacing: .18em; text-shadow: 0 0 14px rgba(255,60,50,.8); opacity: 0; pointer-events: none; z-index: 30; transition: opacity .2s ease; }
++
++/* ---------- crafting menu ---------- */
++#craft-menu { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); background: rgba(12,16,22,.95); border: 1px solid rgba(255,255,255,.12); border-radius: 14px; padding: 16px 18px; z-index: 30; width: 460px; box-shadow: 0 12px 40px rgba(0,0,0,.5); }
++#craft-menu h3 { color: #dfe7f0; margin-bottom: 4px; }
++#craft-menu .have { color: #8fa0b3; font-size: .85rem; margin-bottom: 12px; }
++#craft-list { display: flex; flex-direction: column; gap: 6px; max-height: 46vh; overflow-y: auto; }
++.craft-row { display: flex; align-items: center; gap: 10px; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); border-radius: 10px; padding: 8px 12px; }
++.craft-row .ci { font-size: 1.4rem; }
++.craft-row .cn { flex: 1; color: #dfe7f0; font-size: .92rem; }
++.craft-row .cn small { display: block; color: #8fa0b3; }
++.craft-row button { background: rgba(55,224,111,.15); border: 1px solid #37e06f; color: #b8f5cf; border-radius: 8px; padding: 6px 14px; cursor: pointer; font-family: inherit; }
++.craft-row button:disabled { background: rgba(255,255,255,.04); border-color: rgba(255,255,255,.15); color: #5d6b7a; cursor: default; }
ddiff --git a/public/js/avatar.js b/public/js/avatar.js
iindex 77820c8..ed71fb4 100644
---- a/public/js/avatar.js
++++ b/public/js/avatar.js
@@@ -48,6 +48,13 @@ export const GRIPS = {
banana: { p: [0, .04, .05], r: [-1.9, .3, .6] },
physgun: { p: [0, .03, .06], r: [-1.45, 0, 0] },
flashlight: { p: [0, .03, .05], r: [-1.4, 0, 0] },
++ axe: { p: [0, .1, .04], r: [-1.0, 0, .1] },
++ stoneaxe: { p: [0, .1, .04], r: [-1.0, 0, .1] },
++ pickaxe: { p: [0, .1, .04], r: [-1.0, 0, .1] },
++ stonepick: { p: [0, .1, .04], r: [-1.0, 0, .1] },
++ pistol: { p: [0, .04, .05], r: [-1.55, 0, 0] },
++ wall: { p: [0, .04, .05], r: [-.6, 0, 0] },
++ floor: { p: [0, .04, .05], r: [-.6, 0, 0] },
chips: { p: [0, .04, .05], r: [-.5, 0, 0] },
food: { p: [0, .02, .06], r: [-.4, 0, 0] },
candy: { p: [0, .03, .05], r: [-.5, 0, 0] },
@@@ -98,6 +105,43 @@ export function buildHeldMesh(item) {
const b = new THREE.Mesh(new THREE.TorusGeometry(.09, .026, 8, 12, Math.PI * .9), new THREE.MeshStandardMaterial({ color: 0xf2d21f, roughness: .6 }));
b.rotation.z = .6;
g.add(b);
++ } else if (item === 'axe' || item === 'stoneaxe' || item === 'pickaxe' || item === 'stonepick') {
++ const stone = item.startsWith('stone');
++ const headM = new THREE.MeshStandardMaterial({ color: stone ? 0x757b82 : 0x9aa4ae, roughness: stone ? .85 : .35, metalness: stone ? .1 : .6 });
++ const pole = new THREE.Mesh(new THREE.CylinderGeometry(.02, .024, .62, 8), wood);
++ pole.position.y = -.14;
++ g.add(pole);
++ if (item.includes('axe')) {
++ const blade = new THREE.Mesh(new THREE.BoxGeometry(.05, .16, .14), headM);
++ blade.position.set(0, .14, .08);
++ g.add(blade);
++ } else { // pick: two tapered spikes
++ for (const s of [-1, 1]) {
++ const spike = new THREE.Mesh(new THREE.ConeGeometry(.035, .2, 6), headM);
++ spike.rotation.x = s * Math.PI / 2;
++ spike.position.set(0, .15, s * .12);
++ g.add(spike);
++ }
++ }
++ } else if (item === 'pistol') {
++ const gm = new THREE.MeshStandardMaterial({ color: 0x23272d, roughness: .35, metalness: .5 });
++ const slide = new THREE.Mesh(new THREE.BoxGeometry(.05, .06, .22), gm);
++ slide.position.set(0, .03, -.05);
++ g.add(slide);
++ const grip = new THREE.Mesh(new THREE.BoxGeometry(.045, .13, .06), new THREE.MeshStandardMaterial({ color: 0x33281c, roughness: .7 }));
++ grip.position.set(0, -.05, .05);
++ grip.rotation.x = .25;
++ g.add(grip);
++ const barrel = new THREE.Mesh(new THREE.CylinderGeometry(.014, .014, .07, 8), gm);
++ barrel.rotation.x = Math.PI / 2;
++ barrel.position.set(0, .035, -.18);
++ g.add(barrel);
++ } else if (item === 'wall' || item === 'floor') {
++ for (let i = 0; i < 3; i++) { // bundle of planks
++ const pl = new THREE.Mesh(new THREE.BoxGeometry(.05, .02, .34), wood);
++ pl.position.set((i - 1) * .05, i * .02, 0);
++ g.add(pl);
++ }
} else if (item === 'flashlight') {
const bodyC = new THREE.Mesh(new THREE.CylinderGeometry(.035, .042, .22, 10), dark);
bodyC.rotation.x = Math.PI / 2;
@@@ -272,6 +316,19 @@ export function makeAvatar(name, vest = 'yellow', guest = false, ap = null) {
av.heldMesh = m;
},
swing() { av.swingT = 1; },
++ flash() { // hurt: whole body glows red for a beat
++ for (const m of [shirtM, skinM, pantsM, parts.vest.material]) {
++ m.emissive.setHex(0xd42020);
++ m.emissiveIntensity = .85;
++ }
++ clearTimeout(av._flashT);
++ av._flashT = setTimeout(() => {
++ for (const m of [shirtM, skinM, pantsM, parts.vest.material]) {
++ m.emissive.setHex(0x000000);
++ m.emissiveIntensity = 0;
++ }
++ }, 160);
++ },
say(text) {
const tex = ct(512, 160, (gg, w, h) => {
gg.font = '400 26px "Segoe UI", sans-serif';
ddiff --git a/public/js/inventory.js b/public/js/inventory.js
iindex df478ee..01aad0a 100644
---- a/public/js/inventory.js
++++ b/public/js/inventory.js
@@@ -20,6 +20,15 @@ export const ITEMS = {
banana: { name: 'Banana', icon: '🍌', type: 'melee' },
physgun: { name: 'Physgun', icon: '🧲', type: 'tool' },
flashlight: { name: 'Flashlight', icon: '🔦', type: 'tool' },
++ wood: { name: 'Wood', icon: '🪵', type: 'mat' },
++ stone: { name: 'Stone', icon: '🪨', type: 'mat' },
++ axe: { name: 'Axe', icon: '🪓', type: 'melee' },
++ pickaxe: { name: 'Pickaxe', icon: '⛏️', type: 'melee' },
++ stoneaxe: { name: 'Stone axe', icon: '🪓', type: 'melee' },
++ stonepick: { name: 'Stone pickaxe', icon: '⛏️', type: 'melee' },
++ pistol: { name: 'Pistol', icon: '🔫', type: 'gun' },
++ wall: { name: 'Wood wall', icon: '🧱', type: 'build' },
++ floor: { name: 'Wood floor', icon: '🟫', type: 'build' },
'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 } },
@@@ -125,7 +134,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 && ITEMS[a.id].type === 'food') { // stack
++ if (b && a && b.id === a.id && ['food', 'mat', 'build'].includes(ITEMS[a.id].type)) { // stack
b.n += a.n; from.arr[from.i] = null;
} else {
from.arr[from.i] = b || null;
@@@ -170,7 +179,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
// ---------- API ----------
function findRoom(id) {
-- const stack = ITEMS[id].type === 'food';
++ const stack = ['food', 'mat', 'build'].includes(ITEMS[id].type);
if (stack) {
const h = inv.hotbar.find(s => s?.id === id);
if (h) return h;
@@@ -233,6 +242,30 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
renderHotbar(); renderInv();
}
++ // crafting support: how many of an item across all slots / consume n of it
++ function count(id) {
++ let n = 0;
++ for (const s2 of [...inv.slots, ...inv.hotbar]) if (s2?.id === id) n += s2.n;
++ return n;
++ }
++ function consume(id, n) {
++ if (count(id) < n) return false;
++ for (const arr of [inv.slots, inv.hotbar]) {
++ for (let i = 0; i < arr.length && n > 0; i++) {
++ const s2 = arr[i];
++ if (s2?.id !== id) continue;
++ const take = Math.min(s2.n, n);
++ s2.n -= take; n -= take;
++ if (s2.n <= 0) {
++ arr[i] = null;
++ if (arr === inv.hotbar && i === inv.sel) { onEquip(null); inv.sel = -1; }
++ }
++ }
++ }
++ renderHotbar(); renderInv(); save();
++ return true;
++ }
++
function toggle(open) {
inv.open = open ?? !inv.open;
invEl.classList.toggle('hidden', !inv.open);
@@@ -248,5 +281,5 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
if (el?.dataset?.bar !== undefined) select(+el.dataset.bar);
});
-- return { add, select, useSelected, dropSelected, toggle, selectedItem, restore, state: inv };
++ return { add, select, useSelected, dropSelected, count, consume, toggle, selectedItem, restore, state: inv };
}
ddiff --git a/public/js/main.js b/public/js/main.js
iindex 73207c5..274eb0f 100644
---- a/public/js/main.js
++++ b/public/js/main.js
@@@ -10,7 +10,7 @@ import { initProps } from './props.js';
import { initEditor } from './editor.js';
import { initPhysics, PHYS_KINDS } from './physics.js';
import { initLighting, enableShadows } from './lighting.js';
--import { ct } from './textures.js';
++import { ct, woodTexture } from './textures.js';
const $ = (id) => document.getElementById(id);
@@@ -324,6 +324,9 @@ function start(token, user) {
function doSwing() {
if (my.held && ITEMS[my.held]?.type !== 'melee') return; // empty hand = fists
++ const nowS = performance.now();
++ if (nowS - (doSwing._t || 0) < 320) return;
++ doSwing._t = nowS;
myAvatar.swing();
vmSwing = 1;
net.send({ t: 'swing' });
@@@ -331,6 +334,40 @@ function start(token, user) {
// send nearby physics props flying
const fx = -Math.sin(my.yaw), fz = -Math.cos(my.yaw);
if (phys.smack(my.x, my.y, my.z, fx, fz)) beep(160, .08, 'square', .09);
++ // harvest: chop trees / mine rocks in front
++ const toolWood = my.held === 'stoneaxe' ? 3 : my.held === 'axe' ? 2 : 1;
++ const toolStone = my.held === 'stonepick' ? 3 : my.held === 'pickaxe' ? 2 : 1;
++ for (const k of W.knockables) {
++ if (k.down || (k.kind !== 'tree' && k.kind !== 'rock')) continue;
++ const ddx = k.x - my.x, ddz = k.z - my.z;
++ const d2 = Math.hypot(ddx, ddz);
++ if (d2 > k.r + 1.6 || (ddx * fx + ddz * fz) / (d2 || 1) < .35) continue;
++ const isTree = k.kind === 'tree';
++ const power = isTree ? toolWood : toolStone;
++ k.hp -= power;
++ const mat = isTree ? 'wood' : 'stone';
++ const got = power + (Math.random() < .5 ? 1 : 0);
++ invApi.add(mat, got);
++ toast(`${ITEMS[mat].icon} +${got} ${mat}`, 900);
++ beep(isTree ? 190 : 320, .07, isTree ? 'square' : 'triangle', .13);
++ k.obj.scale.setScalar(1.045);
++ setTimeout(() => { if (!k.down) k.obj.scale.setScalar(1); }, 90);
++ if (k.hp <= 0) {
++ invApi.add(mat, 4); // felling / shattering bonus
++ toast(`${ITEMS[mat].icon} +4 bonus — ${isTree ? 'timber!' : 'rock shattered!'}`, 1600);
++ knockIt(k, ddx / (d2 || 1), ddz / (d2 || 1), true);
++ }
++ return; // the swing spent itself on the resource
++ }
++ // whack placed base pieces
++ for (const e of buildMap.values()) {
++ const ddx = e.b.p[0] - my.x, ddz = e.b.p[2] - my.z;
++ const d2 = Math.hypot(ddx, ddz);
++ if (d2 > 3 || (ddx * fx + ddz * fz) / (d2 || 1) < .3) continue;
++ net.send({ t: 'build', op: 'hit', id: e.b.id, item: my.held });
++ beep(140, .07, 'square', .12);
++ return;
++ }
// did we clock somebody? nearest target in a front cone
let victim = null, bestD = 1.9;
for (const [id, o] of others) {
@@@ -365,6 +402,9 @@ function start(token, user) {
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 === '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 === 'mat') { toast('🔨 Raw material — press C to craft with it.'); return; }
if (r.melee) { doSwing(); return; }
if (r.def) {
toast(`😋 ${r.def.icon} ${r.def.name} — delicious.`);
@@@ -383,6 +423,7 @@ function start(token, user) {
} else if (e.code === 'KeyV') setFP(!fp);
else if (e.code === 'KeyF') useKey();
else if (e.code === 'KeyQ') toggleSpawn();
++ else if (e.code === '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); } }
else if (e.code === 'KeyX' && pgState.held && !pgState.held.isCar) net.send({ t: 'prop', op: 'del', id: pgState.held.id });
@@@ -422,8 +463,9 @@ function start(token, user) {
if (e.code === 'Enter' && !input.chatOpen && !mg.inArcade() && started) { openChat(); e.preventDefault(); }
else if (e.code === 'Escape' && input.chatOpen) { if (tagOpen) closeTag(); else closeChat(); }
else if (e.code === 'Escape' && started) {
-- // Escape closes the top UI panel (spawn/inventory/settings) like any game menu
-- if (spawnOpen) toggleSpawn(false);
++ // Escape closes the top UI panel (craft/spawn/inventory/settings) like any game menu
++ if (craftOpen) toggleCraft(false);
++ else if (spawnOpen) toggleSpawn(false);
else if (invApi.state.open) invApi.toggle(false);
else if (!$('settings').classList.contains('hidden')) { $('settings').classList.add('hidden'); uiFocus('settings', false); }
}
@@@ -450,6 +492,7 @@ function start(token, user) {
(m.props || []).forEach(pr => phys.add(pr));
(m.tags || []).forEach(addMark);
(m.drops || []).forEach(addWorldDrop);
++ (m.builds || []).forEach(addBuild);
// the HALL OF RECORDS by the front walkway — numbers that only ever grow
if (m.visitorNum) {
const pad6 = (v) => String(Math.min(v ?? 0, 999999)).padStart(6, '0');
@@@ -504,6 +547,16 @@ function start(token, user) {
const back = new THREE.Mesh(new THREE.PlaneGeometry(3.7, 1.85), new THREE.MeshStandardMaterial({ color: 0x181d23, roughness: .8, side: THREE.BackSide }));
back.position.set(0, 2.6, .012);
sg.add(back);
++ // plaque under the board: tell people about the marker
++ const plaqueTex = ct(768, 96, (g, w, h) => {
++ g.fillStyle = '#161b21'; g.fillRect(0, 0, w, h);
++ g.strokeStyle = '#3a4450'; g.lineWidth = 6; g.strokeRect(3, 3, w - 6, h - 6);
++ g.fillStyle = '#ffd34d'; g.font = '600 36px "Segoe UI", sans-serif'; g.textAlign = 'center';
++ g.fillText('🖊️ press G to leave your mark — it stays forever', w / 2, 62);
++ });
++ const plaque = new THREE.Mesh(new THREE.PlaneGeometry(3.1, .39), new THREE.MeshBasicMaterial({ map: plaqueTex }));
++ plaque.position.y = 1.43;
++ sg.add(plaque);
sg.position.set(103, 0, 6.6);
sg.rotation.y = Math.PI;
scene.add(sg);
@@@ -557,7 +610,24 @@ function start(token, user) {
beep(170, .1, 'sawtooth', .13);
}
}
-- net.on('hp', (m) => { if (m.id === myId) setHp(m.hp, m.by != null); });
++ function hitmarker(hs) {
++ const hm = $('hitmark');
++ hm.style.opacity = 1;
++ clearTimeout(hitmarker._t);
++ hitmarker._t = setTimeout(() => { hm.style.opacity = 0; }, 140);
++ beep(hs ? 1250 : 950, .05, 'sine', .14);
++ if (hs) {
++ const ht = $('headshot-tag');
++ ht.style.opacity = 1;
++ clearTimeout(hitmarker._h);
++ hitmarker._h = setTimeout(() => { ht.style.opacity = 0; }, 550);
++ }
++ }
++ net.on('hp', (m) => {
++ if (m.id === myId) { setHp(m.hp, m.by != null); return; }
++ others.get(m.id)?.avatar.flash?.(); // victims glow red so everyone can tell
++ if (m.by === myId) hitmarker(m.hs === true);
++ });
net.on('died', (m) => {
setHp(100, false);
my.x = m.x; my.z = m.z; my.y = 0; my.vy = 0;
@@@ -811,6 +881,185 @@ function start(token, user) {
toast(`${PHYS_KINDS[kind].icon} ${PHYS_KINDS[kind].label} incoming!`);
}
++ // ---------- crafting (C) — rust-lite ----------
++ const RECIPES = [
++ { id: 'axe', cost: { wood: 15 }, desc: 'Chops trees 2× faster, 14 dmg' },
++ { id: 'pickaxe', cost: { wood: 12, stone: 6 }, desc: 'Mines rocks 2× faster, 12 dmg' },
++ { id: 'stoneaxe', cost: { wood: 12, stone: 18 }, desc: 'Upgraded axe: 3× wood, 18 dmg' },
++ { id: 'stonepick', cost: { wood: 10, stone: 22 }, desc: 'Upgraded pick: 3× stone, 15 dmg' },
++ { 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' },
++ ];
++ const craftEl = $('craft-menu'), craftList = $('craft-list'), craftHave = $('craft-have');
++ let craftOpen = false;
++ function renderCraft() {
++ craftHave.textContent = `you have: 🪵 ${invApi.count('wood')} wood · 🪨 ${invApi.count('stone')} stone`;
++ craftList.innerHTML = '';
++ for (const r of RECIPES) {
++ const d = ITEMS[r.id];
++ const cost = Object.entries(r.cost).map(([k, v]) => `${ITEMS[k].icon}${v}`).join(' + ');
++ const can = Object.entries(r.cost).every(([k, v]) => invApi.count(k) >= v);
++ const row = document.createElement('div');
++ row.className = 'craft-row';
++ row.innerHTML = `<span class="ci">${d.icon}</span><span class="cn">${d.name}<small>${cost} — ${r.desc}</small></span>`;
++ const b = document.createElement('button');
++ b.textContent = 'Craft';
++ b.disabled = !can;
++ b.onclick = () => {
++ if (!Object.entries(r.cost).every(([k, v]) => invApi.count(k) >= v)) return;
++ for (const [k, v] of Object.entries(r.cost)) invApi.consume(k, v);
++ invApi.add(r.id);
++ beep(740, .09, 'sine', .1);
++ toast(`🔨 Crafted ${d.icon} ${d.name}`);
++ renderCraft();
++ };
++ row.appendChild(b);
++ craftList.appendChild(row);
++ }
++ }
++ function toggleCraft(open) {
++ craftOpen = open ?? !craftOpen;
++ craftEl.classList.toggle('hidden', !craftOpen);
++ uiFocus('craft', craftOpen);
++ if (craftOpen) renderCraft();
++ }
++
++ // ---------- base building: walls & floors with hp and upgrades ----------
++ const buildMap = new Map(); // id -> {b, mesh, col, body, inter}
++ const tierM = {
++ woodWall: new THREE.MeshStandardMaterial({ map: woodTexture('#a8896a', '#8a6a4e'), roughness: .85 }),
++ stoneWall: new THREE.MeshStandardMaterial({ color: 0x8d9196, roughness: .95 }),
++ };
++ function buildLabel(b) {
++ 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;
++ const mesh = b.kind === 'wall'
++ ? new THREE.Mesh(new THREE.BoxGeometry(3, 3, .22), mat)
++ : new THREE.Mesh(new THREE.BoxGeometry(3, .15, 3), mat);
++ 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
++ 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;
++ 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);
++ }
++ 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);
++ buildMap.set(b.id, { b, mesh, col, body, inter });
++ }
++ function removeBuild(id) {
++ const e = buildMap.get(id);
++ if (!e) return;
++ scene.remove(e.mesh);
++ if (e.col) { const i = W.colliders.indexOf(e.col); if (i !== -1) W.colliders.splice(i, 1); }
++ if (e.body) phys.removeStatic(e.body);
++ const j = W.interactables.indexOf(e.inter);
++ if (j !== -1) W.interactables.splice(j, 1);
++ buildMap.delete(id);
++ }
++ net.on('build', (m) => {
++ if (m.op === 'add') addBuild(m.b);
++ else if (m.op === 'del') { removeBuild(m.id); beep(120, .2, 'square', .12); }
++ else if (m.op === 'hp') {
++ const e = buildMap.get(m.id);
++ if (e) { e.b.hp = m.hp; e.mesh.position.y += .015; setTimeout(() => { e.mesh.position.y = e.b.p[1]; }, 60); }
++ } 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); }
++ }
++ });
++ // placement ghost while a build item is equipped
++ const ghostM = new THREE.MeshBasicMaterial({ color: 0x37e06f, transparent: true, opacity: .35, depthWrite: false });
++ const ghosts = {
++ wall: new THREE.Mesh(new THREE.BoxGeometry(3, 3, .22), ghostM),
++ floor: new THREE.Mesh(new THREE.BoxGeometry(3, .15, 3), ghostM),
++ };
++ ghosts.wall.visible = ghosts.floor.visible = false;
++ scene.add(ghosts.wall, ghosts.floor);
++ 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);
++ return { x: +(my.x + fx * 2.9).toFixed(2), z: +(my.z + fz * 2.9).toFixed(2), ry: +ry.toFixed(3) };
++ }
++ function placeBuild() {
++ const kind = my.held;
++ const g = ghostPose();
++ const used = invApi.dropSelected(); // consumes one from the equipped stack
++ if (used !== kind) return;
++ net.send({ t: 'build', op: 'place', kind, p: [g.x, kind === 'wall' ? 1.5 : .08, g.z], ry: g.ry });
++ beep(520, .07, 'sine', .1);
++ }
++
++ // ---------- pistol ----------
++ const tracers = []; // {line, ttl}
++ const tracerM = new THREE.LineBasicMaterial({ color: 0xffd27a, transparent: true, opacity: .9, blending: THREE.AdditiveBlending, depthTest: false });
++ function drawTracer(from, to) {
++ const geo = new THREE.BufferGeometry().setFromPoints([from, to]);
++ const line = new THREE.Line(geo, tracerM.clone());
++ line.renderOrder = 8;
++ line.frustumCulled = false;
++ scene.add(line);
++ tracers.push({ line, ttl: .12 });
++ }
++ let lastFire = 0;
++ function firePistol() {
++ const now = performance.now();
++ if (now - lastFire < 380) return;
++ lastFire = now;
++ myAvatar.swing();
++ vmSwing = 1;
++ beep(1600, .03, 'square', .12);
++ beep(160, .12, 'sawtooth', .16);
++ // small ray fan — a single hairline ray whiffs on thin avatars at range
++ let best = null; // {d, kind, id, point, hs}
++ for (const [ox, oy] of [[0, 0], [.012, 0], [-.012, 0], [0, .012], [0, -.012]]) {
++ pgRay.setFromCamera(new THREE.Vector2(ox, oy), camera);
++ pgRay.far = 50;
++ for (const [id, o] of others) {
++ const hits = pgRay.intersectObject(o.avatar.group, true);
++ if (hits.length && (!best || hits[0].distance < best.d)) {
++ const hy = hits[0].point.y - o.avatar.group.position.y;
++ best = { d: hits[0].distance, kind: 'player', id, point: hits[0].point, hs: hy > 1.45 };
++ }
++ }
++ for (const [key, s] of sleeperMap) {
++ 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 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 };
++ }
++ }
++ pgRay.setFromCamera(new THREE.Vector2(0, 0), camera);
++ pgRay.far = 50;
++ const wallHits = pgRay.intersectObjects(W.camBlockers, false);
++ if (wallHits.length && (!best || wallHits[0].distance < best.d)) best = { d: wallHits[0].distance, kind: 'world', point: wallHits[0].point };
++ const end = best ? best.point : pgRay.ray.origin.clone().addScaledVector(pgRay.ray.direction, 50);
++ drawTracer(myBeamFrom(), end);
++ 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 === 'build') net.send({ t: 'build', op: 'hit', id: best.id, item: 'pistol' });
++ }
++ net.on('shoot', (m) => {
++ const o = others.get(m.id);
++ const from = o ? o.avatar.heldAnchor.getWorldPosition(new THREE.Vector3()) : new THREE.Vector3(m.from[0], m.from[1], m.from[2]);
++ drawTracer(from, new THREE.Vector3(m.to[0], m.to[1], m.to[2]));
++ beep(1400, .03, 'square', .07);
++ });
++
// ---------- physgun: hold LMB to grab · wheel push/pull · R spin · X delete ----------
const pgState = { held: null, dist: 0, lmb: false, missT: 0 };
const pgRay = new THREE.Raycaster();
@@@ -854,7 +1103,8 @@ function start(token, user) {
pgState.lmb = true;
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();
});
addEventListener('mouseup', (e) => {
if (e.button !== 0) return;
@@@ -1126,6 +1376,15 @@ function start(token, user) {
case 'dropitem':
net.send({ t: 'drop', op: 'take', id: nearest.data.drop });
return;
++ case 'build': {
++ const be = buildMap.get(nearest.data.build);
++ if (!be) 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);
++ net.send({ t: 'build', op: 'upgrade', id: be.b.id });
++ return;
++ }
case 'fire': {
const sel = invApi.selectedItem();
if (!sel) { toast('🔥 Warm. Select an item (1–6) and press E to burn it.'); return; }
@@@ -1457,29 +1716,53 @@ function start(token, user) {
if (!k.down) continue;
k.t += dt;
const o = k.obj;
-- if (k.t < 1.1) { // topple with a wobble at the end
-- const p = Math.min(1, k.t / 1.1);
-- const fall = 1 - (1 - p) ** 2;
-- const wob = p > .75 ? Math.sin((p - .75) * 26) * .18 * (1 - p) : 0;
-- knockAxis.set(k.dz, 0, -k.dx).normalize();
-- o.quaternion.setFromAxisAngle(knockAxis, (Math.PI / 2 - .1) * fall + wob);
-- o.position.set(
-- o.userData.homePos.x + k.dx * fall * .6,
-- o.userData.homePos.y,
-- o.userData.homePos.z + k.dz * fall * .6,
-- );
++ if (k.t < 1.1) {
++ if (k.kind === 'rock') { // rocks crumble instead of toppling
++ const p = Math.min(1, k.t / .6);
++ o.scale.setScalar(Math.max(.1, 1 - p * .9));
++ o.position.y = o.userData.homePos.y - p * .3;
++ } else { // topple with a wobble at the end
++ const p = Math.min(1, k.t / 1.1);
++ const fall = 1 - (1 - p) ** 2;
++ const wob = p > .75 ? Math.sin((p - .75) * 26) * .18 * (1 - p) : 0;
++ knockAxis.set(k.dz, 0, -k.dx).normalize();
++ o.quaternion.setFromAxisAngle(knockAxis, (Math.PI / 2 - .1) * fall + wob);
++ o.position.set(
++ o.userData.homePos.x + k.dx * fall * .6,
++ o.userData.homePos.y,
++ o.userData.homePos.z + k.dz * fall * .6,
++ );
++ }
} else if (k.t > k.respawn) { // grow back
const gr = (k.t - k.respawn) / .5;
o.quaternion.copy(o.userData.homeQuat);
o.position.copy(o.userData.homePos);
if (gr >= 1) {
k.down = false;
++ k.hp = k.maxHp; // fresh tree, fresh rock
o.scale.setScalar(1);
if (k.col) k.col.off = false;
} else o.scale.setScalar(Math.max(.05, gr));
}
}
++ // pistol tracers fade fast
++ for (let ti = tracers.length - 1; ti >= 0; ti--) {
++ const tr = tracers[ti];
++ tr.ttl -= dt;
++ tr.line.material.opacity = Math.max(0, tr.ttl / .12) * .9;
++ 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';
++ if (bkind) {
++ const gp = ghostPose();
++ ghosts[bkind].position.set(gp.x, bkind === 'wall' ? 1.5 : .08, gp.z);
++ ghosts[bkind].rotation.y = gp.ry;
++ }
++
// headlights on driven cars, brighter after dark
for (const e2 of phys.cars.values()) {
const car = e2.car;
ddiff --git a/public/js/physics.js b/public/js/physics.js
iindex 259f995..3dd33ed 100644
---- a/public/js/physics.js
++++ b/public/js/physics.js
@@@ -435,9 +435,22 @@ export function initPhysics(scene) {
yawQ.mult(e.body.quaternion, e.body.quaternion);
}
++ // dynamically placed base pieces need real static bodies (cars must crash
++ // into fresh walls, not phase through until reload)
++ function addStatic(x0, x1, z0, z1, y0, y1) {
++ const b = new CANNON.Body({
++ type: CANNON.Body.STATIC, material: groundMat,
++ shape: new CANNON.Box(new CANNON.Vec3((x1 - x0) / 2, (y1 - y0) / 2, (z1 - z0) / 2)),
++ });
++ b.position.set((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2);
++ world.addBody(b);
++ return b;
++ }
++ function removeStatic(b) { world.removeBody(b); }
++
return {
world, props, cars, add, remove, claim, applyState, applyCarState, sendState, sendCarState,
-- drive, step, smack, raycast, yawBody, PHYS_KINDS,
++ drive, step, smack, raycast, yawBody, addStatic, removeStatic, PHYS_KINDS,
setMyId(id) { myId = id; },
};
}
ddiff --git a/public/js/world.js b/public/js/world.js
iindex 0c47a11..1beb4d4 100644
---- a/public/js/world.js
++++ b/public/js/world.js
@@@ -18,11 +18,13 @@ export const W = { // world registry
nightLights: [], // {light, night} real lights that switch on after dark
knockables: [], // trees/lamps/bushes cars can flatten (they respawn)
};
--// register a crashable decoration: world-space object, hit circle, optional collider
--function knockable(obj, x, z, r, col = null) {
++// register a crashable decoration: world-space object, hit circle, optional
++// collider; kind 'tree'/'rock' makes it harvestable (wood / stone)
++function knockable(obj, x, z, r, col = null, kind = 'decor') {
obj.userData.homePos = obj.position.clone();
obj.userData.homeQuat = obj.quaternion.clone();
-- W.knockables.push({ id: 'k' + W.knockables.length, obj, x, z, r, col, down: false });
++ const maxHp = kind === 'tree' ? 6 : kind === 'rock' ? 8 : 1;
++ W.knockables.push({ id: 'k' + W.knockables.length, obj, x, z, r, col, kind, down: false, hp: maxHp, maxHp });
}
const blocker = (m) => { W.camBlockers.push(m); return m; };
@@@ -2345,12 +2347,12 @@ function exteriorEast(scene) {
t.add(fol);
t.position.set(bx2, 0, bz);
scene.add(t);
-- knockable(t, bx2, bz, .7);
++ knockable(t, bx2, bz, .7, null, 'tree');
} else if (i % 3 === 1) {
const sh = new THREE.Mesh(new THREE.SphereGeometry(.42, 8, 6), new THREE.MeshStandardMaterial({ color: 0x3f6f35, roughness: .95 }));
sh.position.set(bx2, .3, bz);
scene.add(sh);
-- knockable(sh, bx2, bz, .55);
++ knockable(sh, bx2, bz, .55, null, 'bush');
} else {
for (let gj = 0; gj < 3; gj++) {
const grass = new THREE.Mesh(new THREE.ConeGeometry(.3, .9, 5), new THREE.MeshStandardMaterial({ color: 0x6d8a4e, roughness: .95 }));
@@@ -2683,7 +2685,7 @@ function streets(scene) {
sg.add(s, back);
sg.position.set(sx, 0, sz);
scene.add(sg);
-- knockable(sg, sx, sz, .6); // no collider — mow it down, it comes back
++ knockable(sg, sx, sz, .6, null, 'sign'); // no collider — mow it down, it comes back
}
// street lights along 172nd
const lampM = new THREE.MeshStandardMaterial({ color: 0xfff3c9, emissive: 0xfff3c9, emissiveIntensity: .9 });
@@@ -2702,7 +2704,7 @@ function streets(scene) {
scene.add(lamp);
const c = collide(132.2, lz, .3, .3);
c.kn = true; // knockable: cars plow through, the pole tips over
-- knockable(lamp, 132.2, lz, .7, c);
++ knockable(lamp, 132.2, lz, .7, c, 'lamp');
}
// ---- Longhouse Trail houses ----
const house = (hx, hz, ry, cBody, cRoof, isHome) => {
@@@ -2745,6 +2747,52 @@ function streets(scene) {
scene.add(mbPost);
scene.add(box(.5, .3, .32, new THREE.MeshStandardMaterial({ color: 0x2e4d8a, roughness: .5 }), 27.4, 1.2, 172.2));
inter({ id: 'home', type: 'home', x: 30, z: 174.2, r: 2.4, label: 'You made it — go relax 🏠' });
++ // ---- the wilds: a grove to chop and rocks to mine on the outskirts ----
++ const foliageM = new THREE.MeshStandardMaterial({ color: 0x4c7a3d, roughness: .9 });
++ const foliageM2 = new THREE.MeshStandardMaterial({ color: 0x5b8a42, roughness: .9 });
++ for (const [tx, tz, big] of [
++ [18, 122, 1], [26, 128, 0], [36, 124, 1], [47, 130, 0], [58, 126, 1],
++ [70, 132, 0], [82, 128, 1], [94, 134, 0], [106, 128, 1], [118, 134, 0],
++ [124, 96, 1], [126, 78, 0], [124, 22, 1], [126, -18, 0], [110, -32, 1],
++ [80, -36, 0], [50, -34, 1], [20, -30, 0],
++ ]) {
++ const tree = new THREE.Group();
++ tree.add(cyl(.1, .14, big ? 2.2 : 1.6, M.woodDark, 0, big ? 1.1 : .8, 0, 8));
++ const fol = new THREE.Mesh(new THREE.SphereGeometry(big ? 1.5 : 1.1, 10, 8), big ? foliageM : foliageM2);
++ fol.position.y = big ? 3.1 : 2.3;
++ fol.scale.y = 1.3;
++ tree.add(fol);
++ tree.position.set(tx, 0, tz);
++ scene.add(tree);
++ const c = collide(tx, tz, .5, .5);
++ c.kn = true;
++ knockable(tree, tx, tz, 1.2, c, 'tree');
++ }
++ // rock formations — mine them for stone
++ const rockM = new THREE.MeshStandardMaterial({ color: 0x8b8f94, roughness: .95, flatShading: true });
++ const rockM2 = new THREE.MeshStandardMaterial({ color: 0x777c82, roughness: .95, flatShading: true });
++ for (const [rx, rz, rs] of [
++ [142, -44, 1.2], [144, 30, .9], [143, 108, 1.3], [140, 160, 1],
++ [96, 180, 1.2], [40, 182, .9], [-30, 160, 1.1], [-60, 120, 1.3],
++ [-64, 40, 1], [12, 118, .8],
++ ]) {
++ const rock = new THREE.Group();
++ const r1 = new THREE.Mesh(new THREE.DodecahedronGeometry(rs, 0), rockM);
++ r1.position.y = rs * .55;
++ r1.scale.y = .72;
++ r1.rotation.y = rx * .7;
++ rock.add(r1);
++ const r2 = new THREE.Mesh(new THREE.DodecahedronGeometry(rs * .55, 0), rockM2);
++ r2.position.set(rs * .7, rs * .3, rs * .3);
++ r2.scale.y = .7;
++ rock.add(r2);
++ rock.position.set(rx, 0, rz);
++ scene.add(rock);
++ const c = collide(rx, rz, rs * 2, rs * 2);
++ c.kn = true; // cars smash through (and shatter the rock)
++ knockable(rock, rx, rz, rs + .6, c, 'rock');
++ }
++
// ---- burn barrel on the outskirts: the night-shift hangout. Toss junk in
// and it's gone forever (E with an item selected, or drop stuff into it) ----
const rustBarrelM = new THREE.MeshStandardMaterial({ color: 0x6e4530, roughness: .85, metalness: .3 });
@@@ -2867,7 +2915,7 @@ function streets(scene) {
scene.add(tree);
const c = collide(tx, tz, .4, .4);
c.kn = true;
-- knockable(tree, tx, tz, 1.1, c);
++ knockable(tree, tx, tz, 1.1, c, 'tree');
}
// ---- the secret: a rusty container behind the smoke cage, something golden inside ----
ddiff --git a/public/play.html b/public/play.html
iindex 82f3882..b82341b 100644
---- a/public/play.html
++++ b/public/play.html
@@@ -62,9 +62,18 @@
<button id="btn-chat" class="mbtn">💬</button>
</div>
-- <!-- health -->
++ <!-- health + combat feedback -->
<div id="hp-wrap"><div id="hp-fill"></div></div>
<div id="dmg-flash"></div>
++ <div id="hitmark"></div>
++ <div id="headshot-tag">HEADSHOT</div>
++
++ <!-- crafting (C) -->
++ <div id="craft-menu" class="hidden">
++ <h3>🔨 Crafting</h3>
++ <div class="have" id="craft-have"></div>
++ <div id="craft-list"></div>
++ </div>
<!-- hotbar -->
<div id="hotbar"></div>
ddiff --git a/server/server.js b/server/server.js
iindex 34ad488..3f792d1 100644
---- a/server/server.js
++++ b/server/server.js
@@@ -110,6 +110,10 @@ for (const [id, d] of drops) { // sweep anything that ever ended up outside the
if (!Number.isFinite(d?.x) || !Number.isFinite(d?.z) || !(d.y >= 0 && d.y <= 8)) drops.delete(id);
}
let nextDropId = [...drops.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(1)) || 0), 0) + 1;
++// base-building pieces: walls/floors with hp, wood→stone upgrades. Persisted.
++const builds = new Map(Object.entries(savedWorld.builds || {})); // id -> {id, kind, tier, p:[x,y,z], ry, hp, owner}
++let nextBuildId = [...builds.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(1)) || 0), 0) + 1;
++const BUILD_HP = { wood: 200, stone: 500 };
// permanent marker tags — the "I was here" layer. They outlive everyone.
const tags = Array.isArray(savedWorld.tags) ? savedWorld.tags : [];
let nextTagId = tags.reduce((m, t) => Math.max(m, Number(String(t.id).slice(1)) || 0), 0) + 1;
@@@ -131,7 +135,7 @@ let worldDirty = false;
function saveWorld() {
worldDirty = false;
const tmp = WORLD_FILE + '.tmp';
-- fs.writeFileSync(tmp, JSON.stringify({ sleepers: Object.fromEntries(sleepers), cars, props: Object.fromEntries(props), drops: Object.fromEntries(drops), tags, stats }));
++ fs.writeFileSync(tmp, JSON.stringify({ sleepers: Object.fromEntries(sleepers), cars, props: Object.fromEntries(props), drops: Object.fromEntries(drops), builds: Object.fromEntries(builds), tags, stats }));
fs.renameSync(tmp, WORLD_FILE); // atomic: a crash mid-write can't corrupt the state
}
setInterval(() => { if (worldDirty) saveWorld(); }, 10000);
@@@ -210,6 +214,7 @@ wss.on('connection', (ws, req) => {
hp: p.hp,
tags,
drops: [...drops.values()],
++ builds: [...builds.values()],
mapEdits,
players: [...players.values()].filter(q => q.id !== id).map(pubState),
c4: { a: c4.a.state(), b: c4.b.state() },
@@@ -465,6 +470,54 @@ function handle(p, m) {
}
break;
}
++ case 'shoot': { // tracer relay so everyone sees/hears pistol fire
++ const now = Date.now();
++ if (now - (p.lastShot || 0) < 280) return;
++ p.lastShot = now;
++ const to = Array.isArray(m.to) ? m.to.map(Number) : [];
++ if (to.length !== 3 || !to.every(Number.isFinite)) return;
++ broadcast({ t: 'shoot', id: p.id, from: [+p.x.toFixed(1), 1.5, +p.z.toFixed(1)], to: to.map(n => +n.toFixed(1)) }, p.id);
++ break;
++ }
++ case 'build': { // rust-lite base building
++ if (m.op === 'place') {
++ const now = Date.now();
++ 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;
++ 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,
++ };
++ builds.set(b.id, b);
++ worldDirty = true;
++ broadcast({ t: 'build', op: 'add', b });
++ } else if (m.op === 'hit') {
++ const b = builds.get(String(m.id));
++ if (!b) 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;
++ b.hp -= dmg;
++ worldDirty = true;
++ if (b.hp <= 0) {
++ 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 === 'upgrade') {
++ const b = builds.get(String(m.id));
++ if (!b || b.tier !== 'wood') return;
++ b.tier = 'stone';
++ b.hp = BUILD_HP.stone;
++ worldDirty = true;
++ broadcast({ t: 'build', op: 'upgrade', id: b.id, tier: 'stone', hp: b.hp });
++ }
++ break;
++ }
case 'burn': { // hall-of-records bookkeeping for the barrel
stats.burns += Math.max(1, Math.min(99, Math.floor(Number(m.n) || 1)));
worldDirty = true;
@@@ -474,16 +527,19 @@ function handle(p, m) {
const now = Date.now();
if (now - (p.lastHitAt || 0) < 350) return;
p.lastHitAt = now;
-- const DMG = { fists: 6, paddle: 10, broom: 12, tapegun: 8, tube: 8, wrench: 15, banana: 4 };
++ const DMG = { fists: 6, paddle: 10, broom: 12, tapegun: 8, tube: 8, wrench: 15, banana: 4, axe: 14, pickaxe: 12, stoneaxe: 18, stonepick: 15, pistol: 20 };
const weapon = m.item == null ? 'fists' : String(m.item);
-- const dmg = DMG[weapon];
++ let dmg = DMG[weapon];
if (!dmg) return;
++ const hs = weapon === 'pistol' && m.hs === true;
++ if (hs) dmg *= 2; // headshot
++ const maxRange = weapon === 'pistol' ? 55 : 3.4;
const label = weapon === 'fists' ? 'their fists' : `a ${weapon}`;
if (m.sleeper) { // sleepers are fair game — brutal, but this place remembers
const key = String(m.sleeper);
const s = sleepers.get(key);
if (!s) return;
-- if (Math.hypot(p.x - s.x, p.z - s.z) > 3.4) return;
++ if (Math.hypot(p.x - s.x, p.z - s.z) > maxRange) return;
s.hp = (s.hp ?? 40) - dmg; // sleepers are fragile
if (s.hp > 0) { worldDirty = true; return; }
sleepers.delete(key);
@@@ -496,10 +552,10 @@ function handle(p, m) {
}
const t = players.get(Number(m.target));
if (!t || t.id === p.id) return;
-- if (Math.hypot(p.x - t.x, p.z - t.z) > 3.4) return;
++ if (Math.hypot(p.x - t.x, p.z - t.z) > maxRange) return;
t.hp = (t.hp ?? 100) - dmg;
if (t.hp > 0) {
-- broadcast({ t: 'hp', id: t.id, hp: t.hp, by: p.id });
++ broadcast({ t: 'hp', id: t.id, hp: t.hp, by: p.id, hs });
return;
}
// death: everything they carried spills where they fell, then respawn
ddiff --git a/server/store.js b/server/store.js
iindex 1207cd5..ed52053 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -126,6 +126,7 @@ 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',
'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);
@@@ -134,7 +135,7 @@ export function setInventory(key, inv, hotbar) {
const cleanSlot = (s) => {
if (!s || typeof s !== 'object') return null;
if (!ITEM_IDS.includes(s.id)) return null;
-- return { id: s.id, n: Math.max(1, Math.min(99, Math.floor(Number(s.n) || 1))) };
++ return { id: s.id, n: Math.max(1, Math.min(999, Math.floor(Number(s.n) || 1))) };
};
u.inv = inv.map(cleanSlot);
u.hotbar = hotbar.map(cleanSlot);