openvibers/breakroomsimulator Commits
DarkRP building: walls/floors/doors are physgun-movable props with freeze
Changes
@@@ -242,6 +242,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {// (the localStorage user copy goes stale the moment anything is picked up)function restore(slots, hotbar) {if (!Array.isArray(slots) && !Array.isArray(hotbar)) return;++ inv.sel = -1; // stale selection after death/reload made re-equips no-opif (Array.isArray(slots)) inv.slots = inv.slots.map((_, i) => {const s = slots[i];return s && ITEMS[s.id] ? { id: s.id, n: s.n || 1 } : null;@@@ -1199,11 +1199,15 @@ function start(token, user) {return { x: +(my.x + fx * 2.9).toFixed(2), z: +(my.z + fz * 2.9).toFixed(2), ry: +ry.toFixed(3) };}function placeBuild() {++ // walls/floors/doors are PROPS now (darkrp): they spawn frozen at the++ // ghost pose, and you physgun-grab to reposition, right-click to refreezeconst kind = my.held;const g = ghostPose();const used = invApi.dropSelected(); // consumes one from the equipped stackif (used !== kind) return;-- net.send({ t: 'build', op: 'place', kind, p: [g.x, kind === 'wall' ? 1.5 : kind === 'door' ? 1.35 : .08, g.z], ry: g.ry });++ const y = kind === 'wall' ? 1.5 : kind === 'door' ? 1.35 : .1;++ const h = g.ry / 2;++ net.send({ t: 'prop', op: 'spawn', kind, p: [g.x, y, g.z], q: [0, +Math.sin(h).toFixed(3), 0, +Math.cos(h).toFixed(3)], frozen: true });beep(520, .07, 'sine', .1);}@@@ -1294,6 +1298,7 @@ function start(token, user) {if (!pgEquipped() || pgState.held) return;const hit = pgPick();if (!hit) return;++ if (hit.e.frozen) phys.setFrozen(hit.e, false); // thaw to move (server re-freezes on veto)pgState.held = hit.e;pgState.lastYaw = my.yaw; // view-follow rotation baseline// gmod grip: remember WHERE on the object you grabbed it@@@ -1331,6 +1336,21 @@ function start(token, user) {pgState.lmb = false;if (pgState.held) physgunRelease();});++ addEventListener('contextmenu', (e) => {++ if (!input.locked) return;++ e.preventDefault();++ if (pgEquipped() && pgState.held && !pgState.held.isCar) { // gmod: right-click freezes it right there++ const held = pgState.held;++ const b = held.body;++ const fp = [+b.position.x.toFixed(2), +b.position.y.toFixed(2), +b.position.z.toFixed(2)];++ const fq = [+b.quaternion.x.toFixed(3), +b.quaternion.y.toFixed(3), +b.quaternion.z.toFixed(3), +b.quaternion.w.toFixed(3)];++ physgunRelease(false);++ phys.setFrozen(held, true);++ net.send({ t: 'prop', op: 'freeze', id: held.id, frozen: true, p: fp, q: fq });++ toast('🧊 Frozen in place — physgun-grab it to move it again.');++ beep(1150, .07, 'sine', .1);++ }++ });// beams: one for me, plus one per prop another player is holdingfunction glowDot() {@@@ -1442,6 +1462,35 @@ function start(token, user) {e.grabbedBy = null;if (pgState.held === e) { physgunRelease(false); toast('🔒 Not yours — ask the owner to add you as a friend.'); }}++ } else if (m.op === 'freeze') {++ const e = phys.props.get(m.id);++ if (!e) return;++ if (m.frozen && Array.isArray(m.p)) {++ e.body.position.set(m.p[0], m.p[1], m.p[2]);++ e.body.quaternion.set(m.q[0], m.q[1], m.q[2], m.q[3]);++ e.body.interpolatedPosition.copy(e.body.position);++ e.body.interpolatedQuaternion.copy(e.body.quaternion);++ e.mesh.position.copy(e.body.position);++ e.mesh.quaternion.copy(e.body.quaternion);++ }++ phys.setFrozen(e, m.frozen === true);++ if (m.frozen && pgState.held === e) physgunRelease(false);++ } else if (m.op === 'fade') { // fading door: passable + see-through for 4s++ const e = phys.props.get(m.id);++ if (!e || e.fading) return;++ e.fading = true;++ e.mesh.traverse(o => { if (o.material?.transparent !== undefined && o.material.color) o.material.opacity = .22; });++ e.body.collisionResponse = false;++ if (e.walkCol) e.walkCol.off = true;++ beep(980, .08, 'sine', .1);++ setTimeout(() => {++ if (!phys.props.has(m.id)) return;++ e.fading = false;++ e.mesh.traverse(o => { if (o.material?.color) o.material.opacity = 1; });++ e.body.collisionResponse = true;++ if (e.walkCol && e.frozen) e.walkCol.off = false;++ beep(620, .08, 'sine', .09);++ }, 4000);}});@@@ -1675,6 +1724,9 @@ function start(token, user) {case 'dropitem':net.send({ t: 'drop', op: 'take', id: nearest.data.drop });return;++ case 'fadedoor':++ net.send({ t: 'prop', op: 'fade', id: nearest.data.prop });++ return;case 'build': {const be = buildMap.get(nearest.data.build);if (!be) return;@@@ -2126,7 +2178,7 @@ function start(token, user) {window.__brs = {my, mg, scene, W, phys, input, renderer, daylight, teleport: (x, z, yaw) => { my.x = x; my.z = z; if (yaw !== undefined) my.yaw = yaw; sendPos(true); },action: doAction, nearest: () => nearest?.id || nearestSeat?.id || null,-- spawnProp, physgunGrab, physgunRelease, pgState, sleepers: sleeperMap, inv: invApi,++ spawnProp, physgunGrab, physgunRelease, pgState, pgPick, sleepers: sleeperMap, inv: invApi,drops: worldDrops, dropItem: dropItemInWorld, knockIt,};@@@ -85,6 +85,45 @@ export const PHYS_KINDS = {return m;},},++ wall: {++ label: 'Wood wall', icon: '🧱', mass: 40,++ shape: () => new CANNON.Box(new CANNON.Vec3(1.5, 1.5, .11)),++ build() {++ const tex = ct(128, 128, (g, w, h) => {++ g.fillStyle = '#a8896a'; g.fillRect(0, 0, w, h);++ g.strokeStyle = 'rgba(80,56,34,.55)'; g.lineWidth = 3;++ for (let i = 1; i < 5; i++) { g.beginPath(); g.moveTo(i * 25.6, 0); g.lineTo(i * 25.6, h); g.stroke(); }++ g.strokeStyle = 'rgba(60,42,22,.35)';++ for (let i = 0; i < 14; i++) { const y = Math.random() * h; g.beginPath(); g.moveTo(Math.random() * w, y); g.lineTo(Math.random() * w, y + 8); g.stroke(); }++ });++ return new THREE.Mesh(new THREE.BoxGeometry(3, 3, .22), new THREE.MeshStandardMaterial({ map: tex, roughness: .85 }));++ },++ },++ floor: {++ label: 'Wood floor', icon: '🟫', mass: 40,++ shape: () => new CANNON.Box(new CANNON.Vec3(1.5, .09, 1.5)),++ build() {++ const tex = ct(128, 128, (g, w, h) => {++ g.fillStyle = '#9a7b58'; g.fillRect(0, 0, w, h);++ g.strokeStyle = 'rgba(70,50,30,.5)'; g.lineWidth = 3;++ for (let i = 1; i < 6; i++) { g.beginPath(); g.moveTo(0, i * 21.3); g.lineTo(w, i * 21.3); g.stroke(); }++ });++ return new THREE.Mesh(new THREE.BoxGeometry(3, .18, 3), new THREE.MeshStandardMaterial({ map: tex, roughness: .9 }));++ },++ },++ door: {++ label: 'Fading door', icon: '🚪', mass: 25,++ shape: () => new CANNON.Box(new CANNON.Vec3(.8, 1.35, .08)),++ build() {++ const g = new THREE.Group();++ const doorM = new THREE.MeshStandardMaterial({ color: 0x6b4a2e, roughness: .8, transparent: true }); // fades++ g.add(new THREE.Mesh(new THREE.BoxGeometry(1.6, 2.7, .16), doorM));++ const knob = new THREE.Mesh(new THREE.SphereGeometry(.06, 8, 6), new THREE.MeshStandardMaterial({ color: 0xc9a227, roughness: .3, metalness: .7 }));++ knob.position.set(.6, 0, .12);++ g.add(knob);++ return g;++ },++ },cone: {label: 'Traffic cone', icon: '🚧', mass: 1.4,shape: () => new CANNON.Cylinder(.05, .2, .55, 8),@@@ -266,8 +305,13 @@ export function initPhysics(scene) {mesh.position.copy(body.position);enableShadows(mesh);scene.add(mesh);-- const e = { id: prop.id, kind: prop.kind, body, mesh, owned: mine, grabbedBy: null };++ const e = { id: prop.id, kind: prop.kind, owner: prop.owner || null, body, mesh, owned: mine, grabbedBy: null, frozen: false };props.set(prop.id, e);++ if (prop.kind === 'door') {++ e.inter = { id: `pdoor-${prop.id}`, type: 'fadedoor', x: prop.p[0], z: prop.p[2], r: 2.2, label: 'Door — E opens (owner & friends)', data: { prop: prop.id } };++ W.interactables.push(e.inter);++ }++ if (prop.frozen) setFrozen(e, true);return e;}function remove(id) {@@@ -275,8 +319,34 @@ export function initPhysics(scene) {if (!e) return;world.removeBody(e.body);scene.remove(e.mesh);++ if (e.walkCol) { const i = W.colliders.indexOf(e.walkCol); if (i !== -1) W.colliders.splice(i, 1); }++ if (e.inter) { const i = W.interactables.indexOf(e.inter); if (i !== -1) W.interactables.splice(i, 1); }props.delete(id);}++++ // darkrp freeze: a frozen prop is a STATIC body (solid to cars/props) with++ // a walking collider (solid to feet). Unfreeze to physgun it around again.++ function setFrozen(e, on) {++ const b = e.body;++ e.frozen = on;++ if (on) {++ b.type = CANNON.Body.STATIC;++ b.velocity.setZero();++ b.angularVelocity.setZero();++ b.updateAABB();++ if (!e.walkCol) { e.walkCol = { x0: 0, x1: 0, z0: 0, z1: 0, off: false }; W.colliders.push(e.walkCol); }++ e.walkCol.x0 = b.aabb.lowerBound.x; e.walkCol.x1 = b.aabb.upperBound.x;++ e.walkCol.z0 = b.aabb.lowerBound.z; e.walkCol.z1 = b.aabb.upperBound.z;++ // don't block feet on things lying flat on the ground or floating overhead++ e.walkCol.off = b.aabb.upperBound.y < .45 || b.aabb.lowerBound.y > 1.6;++ if (e.inter) { e.inter.x = b.position.x; e.inter.z = b.position.z; }++ } else {++ b.type = CANNON.Body.DYNAMIC;++ b.updateMassProperties();++ if (e.walkCol) e.walkCol.off = true;++ b.wakeUp();++ }++ }function claim(e) {e.owned = true;e.remote = false;@@@ -287,7 +357,7 @@ export function initPhysics(scene) {// remote authority: someone else is driving this prop nowfunction applyState(m) {const e = props.get(m.id);-- if (!e) return;++ if (!e || e.frozen) return;e.owned = false;e.body.position.set(m.p[0], m.p[1], m.p[2]);e.body.quaternion.set(m.q[0], m.q[1], m.q[2], m.q[3]);@@@ -310,6 +380,7 @@ export function initPhysics(scene) {// shove props out of the way as you walk through them (and take ownership)function playerPush(px, py, pz, dt) {for (const e of props.values()) {++ if (e.frozen) continue;const b = e.body;const dx = b.position.x - px, dz = b.position.z - pz;const d = Math.hypot(dx, dz);@@@ -327,6 +398,7 @@ export function initPhysics(scene) {function smack(px, py, pz, dirX, dirZ) {let hitAny = false;for (const e of props.values()) {++ if (e.frozen) continue;const b = e.body;const dx = b.position.x - px, dz = b.position.z - pz;const d = Math.hypot(dx, dz);@@@ -413,7 +485,7 @@ export function initPhysics(scene) {if (sendT > .085) {sendT = 0;for (const e of props.values()) {-- if (e.owned && e.body.sleepState !== CANNON.Body.SLEEPING) sendState(e);++ if (e.owned && !e.frozen && e.body.sleepState !== CANNON.Body.SLEEPING) sendState(e);}for (const e of cars.values()) {if (e.owned && !e.car.driver && e.body.type === CANNON.Body.DYNAMIC && e.body.sleepState !== CANNON.Body.SLEEPING) sendCarState(e);@@@ -480,7 +552,7 @@ export function initPhysics(scene) {return {world, props, cars, add, remove, claim, applyState, applyCarState, sendState, sendCarState,-- drive, step, smack, raycast, yawBody, grabLocal, anchorWorld, addStatic, removeStatic, PHYS_KINDS,++ drive, step, smack, raycast, yawBody, grabLocal, anchorWorld, addStatic, removeStatic, setFrozen, PHYS_KINDS,setMyId(id) { myId = id; },};}@@@ -104,16 +104,19 @@ for (const [cid, c] of Object.entries(savedWorld.cars || {})) {}}let nextPhysId = [...props.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(2)) || 0), 0) + 1;++// legacy static builds become FROZEN props (darkrp-style: everything is a prop)++for (const b of Object.values(savedWorld.builds || {})) {++ if (!['wall', 'floor', 'door'].includes(b.kind) || !Array.isArray(b.p)) continue;++ const h = (b.ry || 0) / 2;++ const id = 'fp' + nextPhysId++;++ props.set(id, { id, kind: b.kind, p: b.p, q: [0, +Math.sin(h).toFixed(3), 0, +Math.cos(h).toFixed(3)], owner: b.owner || null, frozen: true });++}// dropped items lying on the floor — minecraft style, persisted like all elseconst drops = new Map(Object.entries(savedWorld.drops || {})); // id -> {id, item, n, x, y, z}for (const [id, d] of drops) { // sweep anything that ever ended up outside the worldif (!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;@@@ -135,7 +138,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), builds: Object.fromEntries(builds), tags, stats }));++ fs.writeFileSync(tmp, JSON.stringify({ sleepers: Object.fromEntries(sleepers), cars, props: Object.fromEntries(props), drops: Object.fromEntries(drops), tags, stats }));fs.renameSync(tmp, WORLD_FILE); // atomic: a crash mid-write can't corrupt the state}setInterval(() => { if (worldDirty) saveWorld(); }, 10000);@@@ -301,7 +304,6 @@ 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() },@@@ -497,18 +499,20 @@ function handle(p, m) {if (now - (p.lastSpawn || 0) < 250) return;p.lastSpawn = now;if (props.size >= 150) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '📦 Prop limit reached (150) — grab some with the physgun and press X to clear them.' })); return; }-- const KINDS = ['box', 'crate', 'ball', 'barrel', 'melon', 'cone'];++ const KINDS = ['box', 'crate', 'ball', 'barrel', 'melon', 'cone', 'wall', 'floor', 'door'];if (!KINDS.includes(m.kind)) return;const pos = Array.isArray(m.p) ? m.p.map(Number) : [];if (pos.length !== 3 || !pos.every(Number.isFinite)) return;-- const prop = { id: 'fp' + nextPhysId++, kind: m.kind, p: [clampX(pos[0]), Math.max(0, Math.min(30, pos[1])), clampZ(pos[2])], q: [0, 0, 0, 1], owner: p.key };++ let q = [0, 0, 0, 1];++ if (Array.isArray(m.q) && m.q.length === 4 && m.q.every(Number.isFinite)) q = m.q.map(n => +(+n).toFixed(3));++ const prop = { id: 'fp' + nextPhysId++, kind: m.kind, p: [clampX(pos[0]), Math.max(0, Math.min(30, pos[1])), clampZ(pos[2])], q, owner: p.key, frozen: m.frozen === true };props.set(prop.id, prop);stats.props++;worldDirty = true;broadcast({ t: 'prop', op: 'add', prop, owner: p.id });} else if (m.op === 'state') {const pr = props.get(String(m.id));-- if (!pr) return;++ if (!pr || pr.frozen) return;if (pr.owner && !store.isFriend(pr.owner, p.key)) return; // prop protectionconst pos = Array.isArray(m.p) ? m.p.map(Number) : [];const q = Array.isArray(m.q) ? m.q.map(Number) : [];@@@ -524,9 +528,34 @@ function handle(p, m) {if (m.op === 'grab' && pr.owner && !store.isFriend(pr.owner, p.key)) {if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 That prop belongs to someone else (they can add you as a friend).' }));broadcast({ t: 'prop', op: 'drop', id: pr.id }, null); // force-release on their client++ if (pr.frozen) broadcast({ t: 'prop', op: 'freeze', id: pr.id, frozen: true, p: pr.p, q: pr.q }); // resyncreturn;}++ if (m.op === 'grab' && pr.frozen) { // owner grabbing = unfreeze++ pr.frozen = false;++ worldDirty = true;++ broadcast({ t: 'prop', op: 'freeze', id: pr.id, frozen: false, p: pr.p, q: pr.q });++ }broadcast({ t: 'prop', op: m.op, id: pr.id, by: p.id }, p.id);++ } else if (m.op === 'freeze') {++ const pr = props.get(String(m.id));++ if (!pr) return;++ if (pr.owner && !store.isFriend(pr.owner, p.key)) return;++ pr.frozen = m.frozen === true;++ const pos = Array.isArray(m.p) ? m.p.map(Number) : null;++ const q = Array.isArray(m.q) ? m.q.map(Number) : null;++ if (pos?.length === 3 && pos.every(Number.isFinite)) pr.p = [clampX(+pos[0].toFixed(2)), Math.max(0, Math.min(30, +pos[1].toFixed(2))), clampZ(+pos[2].toFixed(2))];++ if (q?.length === 4 && q.every(Number.isFinite)) pr.q = q.map(n => +n.toFixed(3));++ worldDirty = true;++ broadcast({ t: 'prop', op: 'freeze', id: pr.id, frozen: pr.frozen, p: pr.p, q: pr.q });++ } else if (m.op === 'fade') { // gmod fading door (prop edition)++ const pr = props.get(String(m.id));++ if (!pr || pr.kind !== 'door') return;++ if (pr.owner && !store.isFriend(pr.owner, p.key)) {++ if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 Locked — this door only opens for its owner and their friends.' }));++ return;++ }++ broadcast({ t: 'prop', op: 'fade', id: pr.id });} else if (m.op === 'del') {const pr = props.get(String(m.id));if (!pr) return;@@@ -575,58 +604,6 @@ function handle(p, m) {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', 'door'].includes(m.kind)) return;-- const pos = Array.isArray(m.p) ? m.p.map(Number) : [];-- if (pos.length !== 3 || !pos.every(Number.isFinite) || !Number.isFinite(+m.ry)) return;-- const b = {-- id: 'b' + nextBuildId++, kind: m.kind, tier: 'wood',-- p: [clampX(pos[0]), Math.max(0, Math.min(12, pos[1])), clampZ(pos[2])],-- ry: +(+m.ry).toFixed(3), hp: m.kind === 'door' ? 150 : 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;-- if (b.owner && !store.isFriend(b.owner, p.key)) { // base protection-- if (p.ws.readyState === 1 && Math.random() < .3) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 Protected base — only the owner and their friends can touch it.' }));-- return;-- }-- const BDMG = { axe: 10, stoneaxe: 14, pickaxe: 12, stonepick: 16, pistol: 15, wrench: 8 };-- const dmg = BDMG[String(m.item)] ?? 4;-- if (Math.hypot(p.x - b.p[0], p.z - b.p[2]) > (m.item === 'pistol' ? 50 : 4.5)) return;-- 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 === 'fade') { // gmod fading door-- const b = builds.get(String(m.id));-- if (!b || b.kind !== 'door') return;-- if (b.owner && !store.isFriend(b.owner, p.key)) {-- if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 Locked — this door only opens for its owner and their friends.' }));-- return;-- }-- broadcast({ t: 'build', op: 'fade', id: b.id });-- } else if (m.op === 'upgrade') {-- const b = builds.get(String(m.id));-- if (!b || b.tier !== 'wood' || b.kind === 'door') return;-- if (b.owner && !store.isFriend(b.owner, p.key)) 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 'friend': { // build/prop protection whitelistlet list = null;if (m.op === 'add') list = store.addFriend(p.key, m.name);
@@ -242,6 +242,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
@@ -242,6 +242,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
// (the localStorage user copy goes stale the moment anything is picked up)
// (the localStorage user copy goes stale the moment anything is picked up)
function restore(slots, hotbar) {
function restore(slots, hotbar) {
if (!Array.isArray(slots) && !Array.isArray(hotbar)) return;
if (!Array.isArray(slots) && !Array.isArray(hotbar)) return;
+ inv.sel = -1; // stale selection after death/reload made re-equips no-op
if (Array.isArray(slots)) inv.slots = inv.slots.map((_, i) => {
if (Array.isArray(slots)) inv.slots = inv.slots.map((_, i) => {
const s = slots[i];
const s = slots[i];
return s && ITEMS[s.id] ? { id: s.id, n: s.n || 1 } : null;
return s && ITEMS[s.id] ? { id: s.id, n: s.n || 1 } : null;
@@ -1199,11 +1199,15 @@ function start(token, user) {
@@ -1199,11 +1199,15 @@ function start(token, user) {
return { x: +(my.x + fx * 2.9).toFixed(2), z: +(my.z + fz * 2.9).toFixed(2), ry: +ry.toFixed(3) };
return { x: +(my.x + fx * 2.9).toFixed(2), z: +(my.z + fz * 2.9).toFixed(2), ry: +ry.toFixed(3) };
}
}
function placeBuild() {
function placeBuild() {
+ // walls/floors/doors are PROPS now (darkrp): they spawn frozen at the
+ // ghost pose, and you physgun-grab to reposition, right-click to refreeze
const kind = my.held;
const kind = my.held;
const g = ghostPose();
const g = ghostPose();
const used = invApi.dropSelected(); // consumes one from the equipped stack
const used = invApi.dropSelected(); // consumes one from the equipped stack
if (used !== kind) return;
if (used !== kind) return;
- net.send({ t: 'build', op: 'place', kind, p: [g.x, kind === 'wall' ? 1.5 : kind === 'door' ? 1.35 : .08, g.z], ry: g.ry });
+ const y = kind === 'wall' ? 1.5 : kind === 'door' ? 1.35 : .1;
+ const h = g.ry / 2;
+ net.send({ t: 'prop', op: 'spawn', kind, p: [g.x, y, g.z], q: [0, +Math.sin(h).toFixed(3), 0, +Math.cos(h).toFixed(3)], frozen: true });
beep(520, .07, 'sine', .1);
beep(520, .07, 'sine', .1);
}
}
@@ -1294,6 +1298,7 @@ function start(token, user) {
@@ -1294,6 +1298,7 @@ function start(token, user) {
if (!pgEquipped() || pgState.held) return;
if (!pgEquipped() || pgState.held) return;
const hit = pgPick();
const hit = pgPick();
if (!hit) return;
if (!hit) return;
+ if (hit.e.frozen) phys.setFrozen(hit.e, false); // thaw to move (server re-freezes on veto)
pgState.held = hit.e;
pgState.held = hit.e;
pgState.lastYaw = my.yaw; // view-follow rotation baseline
pgState.lastYaw = my.yaw; // view-follow rotation baseline
// gmod grip: remember WHERE on the object you grabbed it
// gmod grip: remember WHERE on the object you grabbed it
@@ -1331,6 +1336,21 @@ function start(token, user) {
@@ -1331,6 +1336,21 @@ function start(token, user) {
pgState.lmb = false;
pgState.lmb = false;
if (pgState.held) physgunRelease();
if (pgState.held) physgunRelease();
});
});
+ addEventListener('contextmenu', (e) => {
+ if (!input.locked) return;
+ e.preventDefault();
+ if (pgEquipped() && pgState.held && !pgState.held.isCar) { // gmod: right-click freezes it right there
+ const held = pgState.held;
+ const b = held.body;
+ const fp = [+b.position.x.toFixed(2), +b.position.y.toFixed(2), +b.position.z.toFixed(2)];
+ const fq = [+b.quaternion.x.toFixed(3), +b.quaternion.y.toFixed(3), +b.quaternion.z.toFixed(3), +b.quaternion.w.toFixed(3)];
+ physgunRelease(false);
+ phys.setFrozen(held, true);
+ net.send({ t: 'prop', op: 'freeze', id: held.id, frozen: true, p: fp, q: fq });
+ toast('🧊 Frozen in place — physgun-grab it to move it again.');
+ beep(1150, .07, 'sine', .1);
+ }
+ });
// beams: one for me, plus one per prop another player is holding
// beams: one for me, plus one per prop another player is holding
function glowDot() {
function glowDot() {
@@ -1442,6 +1462,35 @@ function start(token, user) {
@@ -1442,6 +1462,35 @@ function start(token, user) {
e.grabbedBy = null;
e.grabbedBy = null;
if (pgState.held === e) { physgunRelease(false); toast('🔒 Not yours — ask the owner to add you as a friend.'); }
if (pgState.held === e) { physgunRelease(false); toast('🔒 Not yours — ask the owner to add you as a friend.'); }
}
}
+ } else if (m.op === 'freeze') {
+ const e = phys.props.get(m.id);
+ if (!e) return;
+ if (m.frozen && Array.isArray(m.p)) {
+ e.body.position.set(m.p[0], m.p[1], m.p[2]);
+ e.body.quaternion.set(m.q[0], m.q[1], m.q[2], m.q[3]);
+ e.body.interpolatedPosition.copy(e.body.position);
+ e.body.interpolatedQuaternion.copy(e.body.quaternion);
+ e.mesh.position.copy(e.body.position);
+ e.mesh.quaternion.copy(e.body.quaternion);
+ }
+ phys.setFrozen(e, m.frozen === true);
+ if (m.frozen && pgState.held === e) physgunRelease(false);
+ } else if (m.op === 'fade') { // fading door: passable + see-through for 4s
+ const e = phys.props.get(m.id);
+ if (!e || e.fading) return;
+ e.fading = true;
+ e.mesh.traverse(o => { if (o.material?.transparent !== undefined && o.material.color) o.material.opacity = .22; });
+ e.body.collisionResponse = false;
+ if (e.walkCol) e.walkCol.off = true;
+ beep(980, .08, 'sine', .1);
+ setTimeout(() => {
+ if (!phys.props.has(m.id)) return;
+ e.fading = false;
+ e.mesh.traverse(o => { if (o.material?.color) o.material.opacity = 1; });
+ e.body.collisionResponse = true;
+ if (e.walkCol && e.frozen) e.walkCol.off = false;
+ beep(620, .08, 'sine', .09);
+ }, 4000);
}
}
});
});
@@ -1675,6 +1724,9 @@ function start(token, user) {
@@ -1675,6 +1724,9 @@ 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 'fadedoor':
+ net.send({ t: 'prop', op: 'fade', id: nearest.data.prop });
+ return;
case 'build': {
case 'build': {
const be = buildMap.get(nearest.data.build);
const be = buildMap.get(nearest.data.build);
if (!be) return;
if (!be) return;
@@ -2126,7 +2178,7 @@ function start(token, user) {
@@ -2126,7 +2178,7 @@ function start(token, user) {
window.__brs = {
window.__brs = {
my, mg, scene, W, phys, input, renderer, daylight, teleport: (x, z, yaw) => { my.x = x; my.z = z; if (yaw !== undefined) my.yaw = yaw; sendPos(true); },
my, mg, scene, W, phys, input, renderer, daylight, teleport: (x, z, yaw) => { my.x = x; my.z = z; if (yaw !== undefined) my.yaw = yaw; sendPos(true); },
action: doAction, nearest: () => nearest?.id || nearestSeat?.id || null,
action: doAction, nearest: () => nearest?.id || nearestSeat?.id || null,
- spawnProp, physgunGrab, physgunRelease, pgState, sleepers: sleeperMap, inv: invApi,
+ spawnProp, physgunGrab, physgunRelease, pgState, pgPick, sleepers: sleeperMap, inv: invApi,
drops: worldDrops, dropItem: dropItemInWorld, knockIt,
drops: worldDrops, dropItem: dropItemInWorld, knockIt,
};
};
@@ -85,6 +85,45 @@ export const PHYS_KINDS = {
@@ -85,6 +85,45 @@ export const PHYS_KINDS = {
return m;
return m;
},
},
},
},
+ wall: {
+ label: 'Wood wall', icon: '🧱', mass: 40,
+ shape: () => new CANNON.Box(new CANNON.Vec3(1.5, 1.5, .11)),
+ build() {
+ const tex = ct(128, 128, (g, w, h) => {
+ g.fillStyle = '#a8896a'; g.fillRect(0, 0, w, h);
+ g.strokeStyle = 'rgba(80,56,34,.55)'; g.lineWidth = 3;
+ for (let i = 1; i < 5; i++) { g.beginPath(); g.moveTo(i * 25.6, 0); g.lineTo(i * 25.6, h); g.stroke(); }
+ g.strokeStyle = 'rgba(60,42,22,.35)';
+ for (let i = 0; i < 14; i++) { const y = Math.random() * h; g.beginPath(); g.moveTo(Math.random() * w, y); g.lineTo(Math.random() * w, y + 8); g.stroke(); }
+ });
+ return new THREE.Mesh(new THREE.BoxGeometry(3, 3, .22), new THREE.MeshStandardMaterial({ map: tex, roughness: .85 }));
+ },
+ },
+ floor: {
+ label: 'Wood floor', icon: '🟫', mass: 40,
+ shape: () => new CANNON.Box(new CANNON.Vec3(1.5, .09, 1.5)),
+ build() {
+ const tex = ct(128, 128, (g, w, h) => {
+ g.fillStyle = '#9a7b58'; g.fillRect(0, 0, w, h);
+ g.strokeStyle = 'rgba(70,50,30,.5)'; g.lineWidth = 3;
+ for (let i = 1; i < 6; i++) { g.beginPath(); g.moveTo(0, i * 21.3); g.lineTo(w, i * 21.3); g.stroke(); }
+ });
+ return new THREE.Mesh(new THREE.BoxGeometry(3, .18, 3), new THREE.MeshStandardMaterial({ map: tex, roughness: .9 }));
+ },
+ },
+ door: {
+ label: 'Fading door', icon: '🚪', mass: 25,
+ shape: () => new CANNON.Box(new CANNON.Vec3(.8, 1.35, .08)),
+ build() {
+ const g = new THREE.Group();
+ const doorM = new THREE.MeshStandardMaterial({ color: 0x6b4a2e, roughness: .8, transparent: true }); // fades
+ g.add(new THREE.Mesh(new THREE.BoxGeometry(1.6, 2.7, .16), doorM));
+ const knob = new THREE.Mesh(new THREE.SphereGeometry(.06, 8, 6), new THREE.MeshStandardMaterial({ color: 0xc9a227, roughness: .3, metalness: .7 }));
+ knob.position.set(.6, 0, .12);
+ g.add(knob);
+ return g;
+ },
+ },
cone: {
cone: {
label: 'Traffic cone', icon: '🚧', mass: 1.4,
label: 'Traffic cone', icon: '🚧', mass: 1.4,
shape: () => new CANNON.Cylinder(.05, .2, .55, 8),
shape: () => new CANNON.Cylinder(.05, .2, .55, 8),
@@ -266,8 +305,13 @@ export function initPhysics(scene) {
@@ -266,8 +305,13 @@ export function initPhysics(scene) {
mesh.position.copy(body.position);
mesh.position.copy(body.position);
enableShadows(mesh);
enableShadows(mesh);
scene.add(mesh);
scene.add(mesh);
- const e = { id: prop.id, kind: prop.kind, body, mesh, owned: mine, grabbedBy: null };
+ const e = { id: prop.id, kind: prop.kind, owner: prop.owner || null, body, mesh, owned: mine, grabbedBy: null, frozen: false };
props.set(prop.id, e);
props.set(prop.id, e);
+ if (prop.kind === 'door') {
+ e.inter = { id: `pdoor-${prop.id}`, type: 'fadedoor', x: prop.p[0], z: prop.p[2], r: 2.2, label: 'Door — E opens (owner & friends)', data: { prop: prop.id } };
+ W.interactables.push(e.inter);
+ }
+ if (prop.frozen) setFrozen(e, true);
return e;
return e;
}
}
function remove(id) {
function remove(id) {
@@ -275,8 +319,34 @@ export function initPhysics(scene) {
@@ -275,8 +319,34 @@ export function initPhysics(scene) {
if (!e) return;
if (!e) return;
world.removeBody(e.body);
world.removeBody(e.body);
scene.remove(e.mesh);
scene.remove(e.mesh);
+ if (e.walkCol) { const i = W.colliders.indexOf(e.walkCol); if (i !== -1) W.colliders.splice(i, 1); }
+ if (e.inter) { const i = W.interactables.indexOf(e.inter); if (i !== -1) W.interactables.splice(i, 1); }
props.delete(id);
props.delete(id);
}
}
+
+ // darkrp freeze: a frozen prop is a STATIC body (solid to cars/props) with
+ // a walking collider (solid to feet). Unfreeze to physgun it around again.
+ function setFrozen(e, on) {
+ const b = e.body;
+ e.frozen = on;
+ if (on) {
+ b.type = CANNON.Body.STATIC;
+ b.velocity.setZero();
+ b.angularVelocity.setZero();
+ b.updateAABB();
+ if (!e.walkCol) { e.walkCol = { x0: 0, x1: 0, z0: 0, z1: 0, off: false }; W.colliders.push(e.walkCol); }
+ e.walkCol.x0 = b.aabb.lowerBound.x; e.walkCol.x1 = b.aabb.upperBound.x;
+ e.walkCol.z0 = b.aabb.lowerBound.z; e.walkCol.z1 = b.aabb.upperBound.z;
+ // don't block feet on things lying flat on the ground or floating overhead
+ e.walkCol.off = b.aabb.upperBound.y < .45 || b.aabb.lowerBound.y > 1.6;
+ if (e.inter) { e.inter.x = b.position.x; e.inter.z = b.position.z; }
+ } else {
+ b.type = CANNON.Body.DYNAMIC;
+ b.updateMassProperties();
+ if (e.walkCol) e.walkCol.off = true;
+ b.wakeUp();
+ }
+ }
function claim(e) {
function claim(e) {
e.owned = true;
e.owned = true;
e.remote = false;
e.remote = false;
@@ -287,7 +357,7 @@ export function initPhysics(scene) {
@@ -287,7 +357,7 @@ export function initPhysics(scene) {
// remote authority: someone else is driving this prop now
// remote authority: someone else is driving this prop now
function applyState(m) {
function applyState(m) {
const e = props.get(m.id);
const e = props.get(m.id);
- if (!e) return;
+ if (!e || e.frozen) return;
e.owned = false;
e.owned = false;
e.body.position.set(m.p[0], m.p[1], m.p[2]);
e.body.position.set(m.p[0], m.p[1], m.p[2]);
e.body.quaternion.set(m.q[0], m.q[1], m.q[2], m.q[3]);
e.body.quaternion.set(m.q[0], m.q[1], m.q[2], m.q[3]);
@@ -310,6 +380,7 @@ export function initPhysics(scene) {
@@ -310,6 +380,7 @@ export function initPhysics(scene) {
// shove props out of the way as you walk through them (and take ownership)
// shove props out of the way as you walk through them (and take ownership)
function playerPush(px, py, pz, dt) {
function playerPush(px, py, pz, dt) {
for (const e of props.values()) {
for (const e of props.values()) {
+ if (e.frozen) continue;
const b = e.body;
const b = e.body;
const dx = b.position.x - px, dz = b.position.z - pz;
const dx = b.position.x - px, dz = b.position.z - pz;
const d = Math.hypot(dx, dz);
const d = Math.hypot(dx, dz);
@@ -327,6 +398,7 @@ export function initPhysics(scene) {
@@ -327,6 +398,7 @@ export function initPhysics(scene) {
function smack(px, py, pz, dirX, dirZ) {
function smack(px, py, pz, dirX, dirZ) {
let hitAny = false;
let hitAny = false;
for (const e of props.values()) {
for (const e of props.values()) {
+ if (e.frozen) continue;
const b = e.body;
const b = e.body;
const dx = b.position.x - px, dz = b.position.z - pz;
const dx = b.position.x - px, dz = b.position.z - pz;
const d = Math.hypot(dx, dz);
const d = Math.hypot(dx, dz);
@@ -413,7 +485,7 @@ export function initPhysics(scene) {
@@ -413,7 +485,7 @@ export function initPhysics(scene) {
if (sendT > .085) {
if (sendT > .085) {
sendT = 0;
sendT = 0;
for (const e of props.values()) {
for (const e of props.values()) {
- if (e.owned && e.body.sleepState !== CANNON.Body.SLEEPING) sendState(e);
+ if (e.owned && !e.frozen && e.body.sleepState !== CANNON.Body.SLEEPING) sendState(e);
}
}
for (const e of cars.values()) {
for (const e of cars.values()) {
if (e.owned && !e.car.driver && e.body.type === CANNON.Body.DYNAMIC && e.body.sleepState !== CANNON.Body.SLEEPING) sendCarState(e);
if (e.owned && !e.car.driver && e.body.type === CANNON.Body.DYNAMIC && e.body.sleepState !== CANNON.Body.SLEEPING) sendCarState(e);
@@ -480,7 +552,7 @@ export function initPhysics(scene) {
@@ -480,7 +552,7 @@ export function initPhysics(scene) {
return {
return {
world, props, cars, add, remove, claim, applyState, applyCarState, sendState, sendCarState,
world, props, cars, add, remove, claim, applyState, applyCarState, sendState, sendCarState,
- drive, step, smack, raycast, yawBody, grabLocal, anchorWorld, addStatic, removeStatic, PHYS_KINDS,
+ drive, step, smack, raycast, yawBody, grabLocal, anchorWorld, addStatic, removeStatic, setFrozen, PHYS_KINDS,
setMyId(id) { myId = id; },
setMyId(id) { myId = id; },
};
};
}
}
@@ -104,16 +104,19 @@ for (const [cid, c] of Object.entries(savedWorld.cars || {})) {
@@ -104,16 +104,19 @@ for (const [cid, c] of Object.entries(savedWorld.cars || {})) {
}
}
}
}
let nextPhysId = [...props.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(2)) || 0), 0) + 1;
let nextPhysId = [...props.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(2)) || 0), 0) + 1;
+// legacy static builds become FROZEN props (darkrp-style: everything is a prop)
+for (const b of Object.values(savedWorld.builds || {})) {
+ if (!['wall', 'floor', 'door'].includes(b.kind) || !Array.isArray(b.p)) continue;
+ const h = (b.ry || 0) / 2;
+ const id = 'fp' + nextPhysId++;
+ props.set(id, { id, kind: b.kind, p: b.p, q: [0, +Math.sin(h).toFixed(3), 0, +Math.cos(h).toFixed(3)], owner: b.owner || null, frozen: true });
+}
// dropped items lying on the floor — minecraft style, persisted like all else
// dropped items lying on the floor — minecraft style, persisted like all else
const drops = new Map(Object.entries(savedWorld.drops || {})); // id -> {id, item, n, x, y, z}
const drops = new Map(Object.entries(savedWorld.drops || {})); // id -> {id, item, n, x, y, z}
for (const [id, d] of drops) { // sweep anything that ever ended up outside the world
for (const [id, d] of drops) { // sweep anything that ever ended up outside the world
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;
@@ -135,7 +138,7 @@ let worldDirty = false;
@@ -135,7 +138,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), builds: Object.fromEntries(builds), tags, stats }));
+ fs.writeFileSync(tmp, JSON.stringify({ sleepers: Object.fromEntries(sleepers), cars, props: Object.fromEntries(props), drops: Object.fromEntries(drops), 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);
@@ -301,7 +304,6 @@ wss.on('connection', (ws, req) => {
@@ -301,7 +304,6 @@ 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() },
@@ -497,18 +499,20 @@ function handle(p, m) {
@@ -497,18 +499,20 @@ function handle(p, m) {
if (now - (p.lastSpawn || 0) < 250) return;
if (now - (p.lastSpawn || 0) < 250) return;
p.lastSpawn = now;
p.lastSpawn = now;
if (props.size >= 150) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '📦 Prop limit reached (150) — grab some with the physgun and press X to clear them.' })); return; }
if (props.size >= 150) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '📦 Prop limit reached (150) — grab some with the physgun and press X to clear them.' })); return; }
- const KINDS = ['box', 'crate', 'ball', 'barrel', 'melon', 'cone'];
+ const KINDS = ['box', 'crate', 'ball', 'barrel', 'melon', 'cone', 'wall', 'floor', 'door'];
if (!KINDS.includes(m.kind)) return;
if (!KINDS.includes(m.kind)) return;
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
if (pos.length !== 3 || !pos.every(Number.isFinite)) return;
if (pos.length !== 3 || !pos.every(Number.isFinite)) return;
- const prop = { id: 'fp' + nextPhysId++, kind: m.kind, p: [clampX(pos[0]), Math.max(0, Math.min(30, pos[1])), clampZ(pos[2])], q: [0, 0, 0, 1], owner: p.key };
+ let q = [0, 0, 0, 1];
+ if (Array.isArray(m.q) && m.q.length === 4 && m.q.every(Number.isFinite)) q = m.q.map(n => +(+n).toFixed(3));
+ const prop = { id: 'fp' + nextPhysId++, kind: m.kind, p: [clampX(pos[0]), Math.max(0, Math.min(30, pos[1])), clampZ(pos[2])], q, owner: p.key, frozen: m.frozen === true };
props.set(prop.id, prop);
props.set(prop.id, prop);
stats.props++;
stats.props++;
worldDirty = true;
worldDirty = true;
broadcast({ t: 'prop', op: 'add', prop, owner: p.id });
broadcast({ t: 'prop', op: 'add', prop, owner: p.id });
} else if (m.op === 'state') {
} else if (m.op === 'state') {
const pr = props.get(String(m.id));
const pr = props.get(String(m.id));
- if (!pr) return;
+ if (!pr || pr.frozen) return;
if (pr.owner && !store.isFriend(pr.owner, p.key)) return; // prop protection
if (pr.owner && !store.isFriend(pr.owner, p.key)) return; // prop protection
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
const q = Array.isArray(m.q) ? m.q.map(Number) : [];
const q = Array.isArray(m.q) ? m.q.map(Number) : [];
@@ -524,9 +528,34 @@ function handle(p, m) {
@@ -524,9 +528,34 @@ function handle(p, m) {
if (m.op === 'grab' && pr.owner && !store.isFriend(pr.owner, p.key)) {
if (m.op === 'grab' && pr.owner && !store.isFriend(pr.owner, p.key)) {
if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 That prop belongs to someone else (they can add you as a friend).' }));
if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 That prop belongs to someone else (they can add you as a friend).' }));
broadcast({ t: 'prop', op: 'drop', id: pr.id }, null); // force-release on their client
broadcast({ t: 'prop', op: 'drop', id: pr.id }, null); // force-release on their client
+ if (pr.frozen) broadcast({ t: 'prop', op: 'freeze', id: pr.id, frozen: true, p: pr.p, q: pr.q }); // resync
return;
return;
}
}
+ if (m.op === 'grab' && pr.frozen) { // owner grabbing = unfreeze
+ pr.frozen = false;
+ worldDirty = true;
+ broadcast({ t: 'prop', op: 'freeze', id: pr.id, frozen: false, p: pr.p, q: pr.q });
+ }
broadcast({ t: 'prop', op: m.op, id: pr.id, by: p.id }, p.id);
broadcast({ t: 'prop', op: m.op, id: pr.id, by: p.id }, p.id);
+ } else if (m.op === 'freeze') {
+ const pr = props.get(String(m.id));
+ if (!pr) return;
+ if (pr.owner && !store.isFriend(pr.owner, p.key)) return;
+ pr.frozen = m.frozen === true;
+ const pos = Array.isArray(m.p) ? m.p.map(Number) : null;
+ const q = Array.isArray(m.q) ? m.q.map(Number) : null;
+ if (pos?.length === 3 && pos.every(Number.isFinite)) pr.p = [clampX(+pos[0].toFixed(2)), Math.max(0, Math.min(30, +pos[1].toFixed(2))), clampZ(+pos[2].toFixed(2))];
+ if (q?.length === 4 && q.every(Number.isFinite)) pr.q = q.map(n => +n.toFixed(3));
+ worldDirty = true;
+ broadcast({ t: 'prop', op: 'freeze', id: pr.id, frozen: pr.frozen, p: pr.p, q: pr.q });
+ } else if (m.op === 'fade') { // gmod fading door (prop edition)
+ const pr = props.get(String(m.id));
+ if (!pr || pr.kind !== 'door') return;
+ if (pr.owner && !store.isFriend(pr.owner, p.key)) {
+ if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 Locked — this door only opens for its owner and their friends.' }));
+ return;
+ }
+ broadcast({ t: 'prop', op: 'fade', id: pr.id });
} else if (m.op === 'del') {
} else if (m.op === 'del') {
const pr = props.get(String(m.id));
const pr = props.get(String(m.id));
if (!pr) return;
if (!pr) return;
@@ -575,58 +604,6 @@ function handle(p, m) {
@@ -575,58 +604,6 @@ function handle(p, m) {
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);
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;
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', 'door'].includes(m.kind)) return;
- const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
- if (pos.length !== 3 || !pos.every(Number.isFinite) || !Number.isFinite(+m.ry)) return;
- const b = {
- id: 'b' + nextBuildId++, kind: m.kind, tier: 'wood',
- p: [clampX(pos[0]), Math.max(0, Math.min(12, pos[1])), clampZ(pos[2])],
- ry: +(+m.ry).toFixed(3), hp: m.kind === 'door' ? 150 : 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;
- if (b.owner && !store.isFriend(b.owner, p.key)) { // base protection
- if (p.ws.readyState === 1 && Math.random() < .3) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 Protected base — only the owner and their friends can touch it.' }));
- return;
- }
- const BDMG = { axe: 10, stoneaxe: 14, pickaxe: 12, stonepick: 16, pistol: 15, wrench: 8 };
- const dmg = BDMG[String(m.item)] ?? 4;
- if (Math.hypot(p.x - b.p[0], p.z - b.p[2]) > (m.item === 'pistol' ? 50 : 4.5)) return;
- 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 === 'fade') { // gmod fading door
- const b = builds.get(String(m.id));
- if (!b || b.kind !== 'door') return;
- if (b.owner && !store.isFriend(b.owner, p.key)) {
- if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 Locked — this door only opens for its owner and their friends.' }));
- return;
- }
- broadcast({ t: 'build', op: 'fade', id: b.id });
- } else if (m.op === 'upgrade') {
- const b = builds.get(String(m.id));
- if (!b || b.tier !== 'wood' || b.kind === 'door') return;
- if (b.owner && !store.isFriend(b.owner, p.key)) 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 'friend': { // build/prop protection whitelist
case 'friend': { // build/prop protection whitelist
let list = null;
let list = null;
if (m.op === 'add') list = store.addFriend(p.key, m.name);
if (m.op === 'add') list = store.addFriend(p.key, m.name);