openvibers/breakroomsimulator Commits
Rust-lite: harvesting, crafting, base building, pistol PvP, combat feedback
Changes
@@@ -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; }@@@ -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';@@@ -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)) { // stackb.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 };}@@@ -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 flyingconst fx = -Math.sin(my.yaw), fz = -Math.cos(my.yaw);if (phys.smack(my.x, my.y, my.z, fx, fz)) beep(160, .08, 'square', .09);++ // 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 conelet 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 handsif (r.melee === 'physgun') { toast('🧲 Hold left-click to grab · wheel push/pull · R spin · X delete · Q spawns props'); return; }if (r.melee === 'flashlight') { toast('🔦 Lights wherever you look — best after dark.'); return; }++ if (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 growif (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 backconst 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 rocko.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 darkfor (const e2 of phys.cars.values()) {const car = e2.car;@@@ -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; },};}@@@ -18,11 +18,13 @@ export const W = { // world registrynightLights: [], // {light, night} real lights that switch on after darkknockables: [], // 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 172ndconst 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 ----@@@ -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>@@@ -110,6 +110,10 @@ for (const [id, d] of drops) { // sweep anything that ever ended up outside theif (!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 barrelstats.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 remembersconst 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 fragileif (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@@@ -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);
@@ -145,6 +145,27 @@ body.touch #hotbar { bottom: calc(96px + env(safe-area-inset-bottom)); }
@@ -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); }
#tag-form input::placeholder { color: rgba(255,211,77,.45); }
/* ---------- health ---------- */
/* ---------- 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; }
#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; }
#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; }
@@ -48,6 +48,13 @@ export const GRIPS = {
@@ -48,6 +48,13 @@ export const GRIPS = {
banana: { p: [0, .04, .05], r: [-1.9, .3, .6] },
banana: { p: [0, .04, .05], r: [-1.9, .3, .6] },
physgun: { p: [0, .03, .06], r: [-1.45, 0, 0] },
physgun: { p: [0, .03, .06], r: [-1.45, 0, 0] },
flashlight: { p: [0, .03, .05], r: [-1.4, 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] },
chips: { p: [0, .04, .05], r: [-.5, 0, 0] },
food: { p: [0, .02, .06], r: [-.4, 0, 0] },
food: { p: [0, .02, .06], r: [-.4, 0, 0] },
candy: { p: [0, .03, .05], r: [-.5, 0, 0] },
candy: { p: [0, .03, .05], r: [-.5, 0, 0] },
@@ -98,6 +105,43 @@ export function buildHeldMesh(item) {
@@ -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 }));
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;
b.rotation.z = .6;
g.add(b);
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') {
} else if (item === 'flashlight') {
const bodyC = new THREE.Mesh(new THREE.CylinderGeometry(.035, .042, .22, 10), dark);
const bodyC = new THREE.Mesh(new THREE.CylinderGeometry(.035, .042, .22, 10), dark);
bodyC.rotation.x = Math.PI / 2;
bodyC.rotation.x = Math.PI / 2;
@@ -272,6 +316,19 @@ export function makeAvatar(name, vest = 'yellow', guest = false, ap = null) {
@@ -272,6 +316,19 @@ export function makeAvatar(name, vest = 'yellow', guest = false, ap = null) {
av.heldMesh = m;
av.heldMesh = m;
},
},
swing() { av.swingT = 1; },
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) {
say(text) {
const tex = ct(512, 160, (gg, w, h) => {
const tex = ct(512, 160, (gg, w, h) => {
gg.font = '400 26px "Segoe UI", sans-serif';
gg.font = '400 26px "Segoe UI", sans-serif';
@@ -20,6 +20,15 @@ export const ITEMS = {
@@ -20,6 +20,15 @@ export const ITEMS = {
banana: { name: 'Banana', icon: '🍌', type: 'melee' },
banana: { name: 'Banana', icon: '🍌', type: 'melee' },
physgun: { name: 'Physgun', icon: '🧲', type: 'tool' },
physgun: { name: 'Physgun', icon: '🧲', type: 'tool' },
flashlight: { name: 'Flashlight', 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-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 } },
@@ -125,7 +134,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
@@ -125,7 +134,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 && 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;
b.n += a.n; from.arr[from.i] = null;
} else {
} else {
from.arr[from.i] = b || null;
from.arr[from.i] = b || null;
@@ -170,7 +179,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
@@ -170,7 +179,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
// ---------- API ----------
// ---------- API ----------
function findRoom(id) {
function findRoom(id) {
- const stack = ITEMS[id].type === 'food';
+ const stack = ['food', 'mat', 'build'].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;
@@ -233,6 +242,30 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
@@ -233,6 +242,30 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
renderHotbar(); renderInv();
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) {
function toggle(open) {
inv.open = open ?? !inv.open;
inv.open = open ?? !inv.open;
invEl.classList.toggle('hidden', !inv.open);
invEl.classList.toggle('hidden', !inv.open);
@@ -248,5 +281,5 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
@@ -248,5 +281,5 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
if (el?.dataset?.bar !== undefined) select(+el.dataset.bar);
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 };
}
}
@@ -10,7 +10,7 @@ import { initProps } from './props.js';
@@ -10,7 +10,7 @@ import { initProps } from './props.js';
import { initEditor } from './editor.js';
import { initEditor } from './editor.js';
import { initPhysics, PHYS_KINDS } from './physics.js';
import { initPhysics, PHYS_KINDS } from './physics.js';
import { initLighting, enableShadows } from './lighting.js';
import { initLighting, enableShadows } from './lighting.js';
-import { ct } from './textures.js';
+import { ct, woodTexture } from './textures.js';
const $ = (id) => document.getElementById(id);
const $ = (id) => document.getElementById(id);
@@ -324,6 +324,9 @@ function start(token, user) {
@@ -324,6 +324,9 @@ function start(token, user) {
function doSwing() {
function doSwing() {
if (my.held && ITEMS[my.held]?.type !== 'melee') return; // empty hand = fists
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();
myAvatar.swing();
vmSwing = 1;
vmSwing = 1;
net.send({ t: 'swing' });
net.send({ t: 'swing' });
@@ -331,6 +334,40 @@ function start(token, user) {
@@ -331,6 +334,40 @@ function start(token, user) {
// send nearby physics props flying
// send nearby physics props flying
const fx = -Math.sin(my.yaw), fz = -Math.cos(my.yaw);
const fx = -Math.sin(my.yaw), fz = -Math.cos(my.yaw);
if (phys.smack(my.x, my.y, my.z, fx, fz)) beep(160, .08, 'square', .09);
if (phys.smack(my.x, my.y, my.z, fx, fz)) beep(160, .08, 'square', .09);
+ // 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
// did we clock somebody? nearest target in a front cone
let victim = null, bestD = 1.9;
let victim = null, bestD = 1.9;
for (const [id, o] of others) {
for (const [id, o] of others) {
@@ -365,6 +402,9 @@ function start(token, user) {
@@ -365,6 +402,9 @@ function start(token, user) {
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 · Q spawns props'); return; }
if (r.melee === 'flashlight') { toast('🔦 Lights wherever you look — best after dark.'); return; }
if (r.melee === 'flashlight') { toast('🔦 Lights wherever you look — best after dark.'); return; }
+ if (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.melee) { doSwing(); return; }
if (r.def) {
if (r.def) {
toast(`😋 ${r.def.icon} ${r.def.name} — delicious.`);
toast(`😋 ${r.def.icon} ${r.def.name} — delicious.`);
@@ -383,6 +423,7 @@ function start(token, user) {
@@ -383,6 +423,7 @@ function start(token, user) {
} 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') toggleSpawn();
+ 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); } }
else if (e.code === 'KeyX' && pgState.held && !pgState.held.isCar) net.send({ t: 'prop', op: 'del', id: pgState.held.id });
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) {
@@ -422,8 +463,9 @@ function start(token, user) {
if (e.code === 'Enter' && !input.chatOpen && !mg.inArcade() && started) { openChat(); e.preventDefault(); }
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' && input.chatOpen) { if (tagOpen) closeTag(); else closeChat(); }
else if (e.code === 'Escape' && started) {
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 (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); }
}
}
@@ -450,6 +492,7 @@ function start(token, user) {
@@ -450,6 +492,7 @@ function start(token, user) {
(m.props || []).forEach(pr => phys.add(pr));
(m.props || []).forEach(pr => phys.add(pr));
(m.tags || []).forEach(addMark);
(m.tags || []).forEach(addMark);
(m.drops || []).forEach(addWorldDrop);
(m.drops || []).forEach(addWorldDrop);
+ (m.builds || []).forEach(addBuild);
// 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');
@@ -504,6 +547,16 @@ function start(token, user) {
@@ -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 }));
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);
back.position.set(0, 2.6, .012);
sg.add(back);
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.position.set(103, 0, 6.6);
sg.rotation.y = Math.PI;
sg.rotation.y = Math.PI;
scene.add(sg);
scene.add(sg);
@@ -557,7 +610,24 @@ function start(token, user) {
@@ -557,7 +610,24 @@ function start(token, user) {
beep(170, .1, 'sawtooth', .13);
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) => {
net.on('died', (m) => {
setHp(100, false);
setHp(100, false);
my.x = m.x; my.z = m.z; my.y = 0; my.vy = 0;
my.x = m.x; my.z = m.z; my.y = 0; my.vy = 0;
@@ -811,6 +881,185 @@ function start(token, user) {
@@ -811,6 +881,185 @@ function start(token, user) {
toast(`${PHYS_KINDS[kind].icon} ${PHYS_KINDS[kind].label} incoming!`);
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 ----------
// ---------- physgun: hold LMB to grab · wheel push/pull · R spin · X delete ----------
const pgState = { held: null, dist: 0, lmb: false, missT: 0 };
const pgState = { held: null, dist: 0, lmb: false, missT: 0 };
const pgRay = new THREE.Raycaster();
const pgRay = new THREE.Raycaster();
@@ -854,7 +1103,8 @@ function start(token, user) {
@@ -854,7 +1103,8 @@ function start(token, user) {
pgState.lmb = true;
pgState.lmb = true;
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 === 'wall' || my.held === 'floor') placeBuild();
});
});
addEventListener('mouseup', (e) => {
addEventListener('mouseup', (e) => {
if (e.button !== 0) return;
if (e.button !== 0) return;
@@ -1126,6 +1376,15 @@ function start(token, user) {
@@ -1126,6 +1376,15 @@ function start(token, user) {
case 'dropitem':
case 'dropitem':
net.send({ t: 'drop', op: 'take', id: nearest.data.drop });
net.send({ t: 'drop', op: 'take', id: nearest.data.drop });
return;
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': {
case 'fire': {
const sel = invApi.selectedItem();
const sel = invApi.selectedItem();
if (!sel) { toast('🔥 Warm. Select an item (1–6) and press E to burn it.'); return; }
if (!sel) { toast('🔥 Warm. Select an item (1–6) and press E to burn it.'); return; }
@@ -1457,29 +1716,53 @@ function start(token, user) {
@@ -1457,29 +1716,53 @@ function start(token, user) {
if (!k.down) continue;
if (!k.down) continue;
k.t += dt;
k.t += dt;
const o = k.obj;
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
} else if (k.t > k.respawn) { // grow back
const gr = (k.t - k.respawn) / .5;
const gr = (k.t - k.respawn) / .5;
o.quaternion.copy(o.userData.homeQuat);
o.quaternion.copy(o.userData.homeQuat);
o.position.copy(o.userData.homePos);
o.position.copy(o.userData.homePos);
if (gr >= 1) {
if (gr >= 1) {
k.down = false;
k.down = false;
+ k.hp = k.maxHp; // fresh tree, fresh rock
o.scale.setScalar(1);
o.scale.setScalar(1);
if (k.col) k.col.off = false;
if (k.col) k.col.off = false;
} else o.scale.setScalar(Math.max(.05, gr));
} 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
// headlights on driven cars, brighter after dark
for (const e2 of phys.cars.values()) {
for (const e2 of phys.cars.values()) {
const car = e2.car;
const car = e2.car;
@@ -435,9 +435,22 @@ export function initPhysics(scene) {
@@ -435,9 +435,22 @@ export function initPhysics(scene) {
yawQ.mult(e.body.quaternion, e.body.quaternion);
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 {
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, PHYS_KINDS,
+ drive, step, smack, raycast, yawBody, addStatic, removeStatic, PHYS_KINDS,
setMyId(id) { myId = id; },
setMyId(id) { myId = id; },
};
};
}
}
@@ -18,11 +18,13 @@ export const W = { // world registry
@@ -18,11 +18,13 @@ export const W = { // world registry
nightLights: [], // {light, night} real lights that switch on after dark
nightLights: [], // {light, night} real lights that switch on after dark
knockables: [], // trees/lamps/bushes cars can flatten (they respawn)
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.homePos = obj.position.clone();
obj.userData.homeQuat = obj.quaternion.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; };
const blocker = (m) => { W.camBlockers.push(m); return m; };
@@ -2345,12 +2347,12 @@ function exteriorEast(scene) {
@@ -2345,12 +2347,12 @@ function exteriorEast(scene) {
t.add(fol);
t.add(fol);
t.position.set(bx2, 0, bz);
t.position.set(bx2, 0, bz);
scene.add(t);
scene.add(t);
- knockable(t, bx2, bz, .7);
+ knockable(t, bx2, bz, .7, null, 'tree');
} else if (i % 3 === 1) {
} else if (i % 3 === 1) {
const sh = new THREE.Mesh(new THREE.SphereGeometry(.42, 8, 6), new THREE.MeshStandardMaterial({ color: 0x3f6f35, roughness: .95 }));
const sh = new THREE.Mesh(new THREE.SphereGeometry(.42, 8, 6), new THREE.MeshStandardMaterial({ color: 0x3f6f35, roughness: .95 }));
sh.position.set(bx2, .3, bz);
sh.position.set(bx2, .3, bz);
scene.add(sh);
scene.add(sh);
- knockable(sh, bx2, bz, .55);
+ knockable(sh, bx2, bz, .55, null, 'bush');
} else {
} else {
for (let gj = 0; gj < 3; gj++) {
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 }));
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) {
@@ -2683,7 +2685,7 @@ function streets(scene) {
sg.add(s, back);
sg.add(s, back);
sg.position.set(sx, 0, sz);
sg.position.set(sx, 0, sz);
scene.add(sg);
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
// street lights along 172nd
const lampM = new THREE.MeshStandardMaterial({ color: 0xfff3c9, emissive: 0xfff3c9, emissiveIntensity: .9 });
const lampM = new THREE.MeshStandardMaterial({ color: 0xfff3c9, emissive: 0xfff3c9, emissiveIntensity: .9 });
@@ -2702,7 +2704,7 @@ function streets(scene) {
@@ -2702,7 +2704,7 @@ function streets(scene) {
scene.add(lamp);
scene.add(lamp);
const c = collide(132.2, lz, .3, .3);
const c = collide(132.2, lz, .3, .3);
c.kn = true; // knockable: cars plow through, the pole tips over
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 ----
// ---- Longhouse Trail houses ----
const house = (hx, hz, ry, cBody, cRoof, isHome) => {
const house = (hx, hz, ry, cBody, cRoof, isHome) => {
@@ -2745,6 +2747,52 @@ function streets(scene) {
@@ -2745,6 +2747,52 @@ function streets(scene) {
scene.add(mbPost);
scene.add(mbPost);
scene.add(box(.5, .3, .32, new THREE.MeshStandardMaterial({ color: 0x2e4d8a, roughness: .5 }), 27.4, 1.2, 172.2));
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 🏠' });
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
// ---- 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) ----
// 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 });
const rustBarrelM = new THREE.MeshStandardMaterial({ color: 0x6e4530, roughness: .85, metalness: .3 });
@@ -2867,7 +2915,7 @@ function streets(scene) {
@@ -2867,7 +2915,7 @@ function streets(scene) {
scene.add(tree);
scene.add(tree);
const c = collide(tx, tz, .4, .4);
const c = collide(tx, tz, .4, .4);
c.kn = true;
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 ----
// ---- the secret: a rusty container behind the smoke cage, something golden inside ----
@@ -62,9 +62,18 @@
@@ -62,9 +62,18 @@
<button id="btn-chat" class="mbtn">💬</button>
<button id="btn-chat" class="mbtn">💬</button>
</div>
</div>
- <!-- health -->
+ <!-- health + combat feedback -->
<div id="hp-wrap"><div id="hp-fill"></div></div>
<div id="hp-wrap"><div id="hp-fill"></div></div>
<div id="dmg-flash"></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 -->
<!-- hotbar -->
<div id="hotbar"></div>
<div id="hotbar"></div>
@@ -110,6 +110,10 @@ for (const [id, d] of drops) { // sweep anything that ever ended up outside the
@@ -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);
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;
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.
// permanent marker tags — the "I was here" layer. They outlive everyone.
const tags = Array.isArray(savedWorld.tags) ? savedWorld.tags : [];
const tags = Array.isArray(savedWorld.tags) ? savedWorld.tags : [];
let nextTagId = tags.reduce((m, t) => Math.max(m, Number(String(t.id).slice(1)) || 0), 0) + 1;
let nextTagId = tags.reduce((m, t) => Math.max(m, Number(String(t.id).slice(1)) || 0), 0) + 1;
@@ -131,7 +135,7 @@ let worldDirty = false;
@@ -131,7 +135,7 @@ let worldDirty = false;
function saveWorld() {
function saveWorld() {
worldDirty = false;
worldDirty = false;
const tmp = WORLD_FILE + '.tmp';
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
fs.renameSync(tmp, WORLD_FILE); // atomic: a crash mid-write can't corrupt the state
}
}
setInterval(() => { if (worldDirty) saveWorld(); }, 10000);
setInterval(() => { if (worldDirty) saveWorld(); }, 10000);
@@ -210,6 +214,7 @@ wss.on('connection', (ws, req) => {
@@ -210,6 +214,7 @@ wss.on('connection', (ws, req) => {
hp: p.hp,
hp: p.hp,
tags,
tags,
drops: [...drops.values()],
drops: [...drops.values()],
+ builds: [...builds.values()],
mapEdits,
mapEdits,
players: [...players.values()].filter(q => q.id !== id).map(pubState),
players: [...players.values()].filter(q => q.id !== id).map(pubState),
c4: { a: c4.a.state(), b: c4.b.state() },
c4: { a: c4.a.state(), b: c4.b.state() },
@@ -465,6 +470,54 @@ function handle(p, m) {
@@ -465,6 +470,54 @@ function handle(p, m) {
}
}
break;
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
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;
@@ -474,16 +527,19 @@ function handle(p, m) {
@@ -474,16 +527,19 @@ function handle(p, m) {
const now = Date.now();
const now = Date.now();
if (now - (p.lastHitAt || 0) < 350) return;
if (now - (p.lastHitAt || 0) < 350) return;
p.lastHitAt = now;
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 weapon = m.item == null ? 'fists' : String(m.item);
- const dmg = DMG[weapon];
+ let dmg = DMG[weapon];
if (!dmg) return;
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}`;
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);
const s = sleepers.get(key);
const s = sleepers.get(key);
if (!s) return;
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
s.hp = (s.hp ?? 40) - dmg; // sleepers are fragile
if (s.hp > 0) { worldDirty = true; return; }
if (s.hp > 0) { worldDirty = true; return; }
sleepers.delete(key);
sleepers.delete(key);
@@ -496,10 +552,10 @@ function handle(p, m) {
@@ -496,10 +552,10 @@ function handle(p, m) {
}
}
const t = players.get(Number(m.target));
const t = players.get(Number(m.target));
if (!t || t.id === p.id) return;
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;
t.hp = (t.hp ?? 100) - dmg;
if (t.hp > 0) {
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;
return;
}
}
// death: everything they carried spills where they fell, then respawn
// death: everything they carried spills where they fell, then respawn
@@ -126,6 +126,7 @@ export function setAppearance(key, ap) {
@@ -126,6 +126,7 @@ 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',
'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);
@@ -134,7 +135,7 @@ export function setInventory(key, inv, hotbar) {
@@ -134,7 +135,7 @@ export function setInventory(key, inv, hotbar) {
const cleanSlot = (s) => {
const cleanSlot = (s) => {
if (!s || typeof s !== 'object') return null;
if (!s || typeof s !== 'object') return null;
if (!ITEM_IDS.includes(s.id)) 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.inv = inv.map(cleanSlot);
u.hotbar = hotbar.map(cleanSlot);
u.hotbar = hotbar.map(cleanSlot);