openvibers/breakroomsimulator Commits

Fix chess/C4 instant-kick, arcade mouse, mirrored signs, rotated props, z-fighting, hallway orientation; add item dropping

OP OpenVibers committed 28 Jul 2026 · 33c9dc2f6e · 7 files, +206 −50

- Chess & Connect 4 ejected players instantly: the walk-away check compared
  world position against group-local anchor coords (same bug that once broke
  pong) — now uses the anchors' wx/wz. Both games were already fully 3D and
  networked for spectators; they're playable again.
- PRIME BREAKER releases the pointer on open (uiFocus) so the paddle can
  track the mouse, and re-locks on close.
- Mirrored text: street STOP signs got real gray backs, street name blades /
  Together banner / smoke-cage hint render as paired front-facing planes,
  Amazon Tours A-frames got dark BackSide inner panels.
- Rotated blocks: locker crowns now slope down over the doors (were tilted
  backwards, Bug38, also in the editor prop), cornhole board leans top-first
  against the wall instead of backwards into the floor (Bug41).
- Z-fighting: security-desk chevron sat coplanar with the mural face (rainbow
  moire), badge poster nudged out; two wall clocks were exactly coplanar with
  their walls + anisotropy for the LED shimmer.
- Service-hallway counter/coffee/ice/vending fronts all pointed into the
  break-room wall — flipped to face the corridor, units snugged to the wall.
- Minecraft-style drops: H drops the selected item, right-click any inventory
  slot drops one; items spin/bob on the floor, E picks up, full-inventory
  grants fall back to the floor; capped at 200 and persisted in world-state.

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

Changes

ddiff --git a/public/js/inventory.js b/public/js/inventory.js
iindex 70830c4..b0d123b 100644
---- a/public/js/inventory.js
++++ b/public/js/inventory.js
@@@ -27,7 +27,7 @@ export const ITEMS = {
'vest-blue': { name: 'Blue vest', icon: '🟦', type: 'clothes', vest: 'blue' },
};
--export function initInventory({ me, onEquip, onWear, toast }) {
++export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
const inv = {
slots: new Array(24).fill(null), // {id, n}
hotbar: new Array(6).fill(null),
@@@ -121,6 +121,35 @@ export function initInventory({ me, onEquip, onWear, toast }) {
addEventListener('pointermove', moveGhost);
addEventListener('pointerup', onUp);
++ // ---------- dropping (minecraft style) ----------
++ // right-click a slot to toss one on the floor; H (wired in main) drops from
++ // the selected hotbar slot
++ function removeOne(ref) {
++ const s = ref.arr[ref.i];
++ if (!s) return null;
++ const id = s.id;
++ s.n--;
++ if (s.n <= 0) {
++ ref.arr[ref.i] = null;
++ if (ref.arr === inv.hotbar && ref.i === inv.sel) { onEquip(null); inv.sel = -1; }
++ }
++ renderHotbar(); renderInv(); save();
++ return id;
++ }
++ function dropSelected() {
++ if (inv.sel === -1) return null;
++ return removeOne({ arr: inv.hotbar, i: inv.sel });
++ }
++ const onCtx = (e) => {
++ const ref = getRef(e.target.closest('.slot'));
++ if (!ref || !ref.arr[ref.i]) return;
++ e.preventDefault();
++ const id = removeOne(ref);
++ if (id) { onDropItem?.(id); beep(340, .05, 'sine', .07); }
++ };
++ invEl.addEventListener('contextmenu', onCtx);
++ hotbarEl.addEventListener('contextmenu', onCtx);
++
// ---------- API ----------
function findRoom(id) {
const stack = ITEMS[id].type === 'food';
@@@ -201,5 +230,5 @@ export function initInventory({ me, onEquip, onWear, toast }) {
if (el?.dataset?.bar !== undefined) select(+el.dataset.bar);
});
-- return { add, select, useSelected, toggle, selectedItem, restore, state: inv };
++ return { add, select, useSelected, dropSelected, toggle, selectedItem, restore, state: inv };
}
ddiff --git a/public/js/main.js b/public/js/main.js
iindex 306b836..4d51762 100644
---- a/public/js/main.js
++++ b/public/js/main.js
@@@ -189,6 +189,7 @@ function start(token, user) {
// ---------- inventory / equipment ----------
const invApi = initInventory({
me,
++ onDropItem: (id) => dropItemInWorld(id),
onEquip: (item) => {
my.held = item;
myAvatar.setHeld(item);
@@@ -349,6 +350,7 @@ function start(token, user) {
else if (e.code === 'KeyF') useKey();
else if (e.code === 'KeyQ') toggleSpawn();
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 });
});
@@@ -413,6 +415,7 @@ function start(token, user) {
(m.sleepers || []).forEach(addSleeper);
(m.props || []).forEach(pr => phys.add(pr));
(m.tags || []).forEach(addMark);
++ (m.drops || []).forEach(addWorldDrop);
// retro associate counter by the front walkway — proof the place remembers
if (m.visitorNum) {
const pad6 = (v) => String(Math.min(v, 999999)).padStart(6, '0');
@@@ -441,7 +444,7 @@ function start(token, user) {
scene.add(sg);
W.colliders.push({ x0: 102.7, x1: 103.3, z0: 6.1, z1: 6.7 });
addChat(`📟 You are associate №${m.visitorNum}. The break room remembers everyone.`, 'sys');
-- addChat('🖊️ Press G to leave a permanent mark · Q to spawn props · the 🧲 is out on 172nd St.', 'sys');
++ addChat('🖊️ G leaves a permanent mark · Q spawns props · H (or right-click a slot) drops items · the 🧲 is out on 172nd St.', 'sys');
}
if (me.admin && !editor) editor = initEditor({ scene, camera, props, toast });
$('online-count').textContent = m.online;
@@@ -557,6 +560,47 @@ function start(token, user) {
net.on('sleep', (m) => addSleeper(m.s));
net.on('wake', (m) => removeSleeper(m.key));
++ // ---------- dropped items on the floor (minecraft style) ----------
++ const worldDrops = new Map(); // id -> {group, mesh, inter, t}
++ function dropItemInWorld(id) {
++ const fx = -Math.sin(my.yaw), fz = -Math.cos(my.yaw);
++ net.send({ t: 'drop', op: 'add', item: id, n: 1, x: +(my.x + fx * 1.3).toFixed(2), y: .35, z: +(my.z + fz * 1.3).toFixed(2) });
++ }
++ function addWorldDrop(d) {
++ removeWorldDrop(d.id);
++ const mesh = buildHeldMesh(d.item);
++ if (!mesh) return;
++ const group = new THREE.Group();
++ mesh.scale.setScalar(1.4);
++ group.add(mesh);
++ const sh = new THREE.Mesh(new THREE.CircleGeometry(.16, 12), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: .2, depthWrite: false }));
++ sh.rotation.x = -Math.PI / 2;
++ sh.position.y = .012;
++ group.add(sh);
++ group.position.set(d.x, 0, d.z);
++ scene.add(group);
++ const def = ITEMS[d.item];
++ const it = { id: `drop-${d.id}`, type: 'dropitem', x: d.x, z: d.z, r: 1.2, label: `Pick up ${def.icon} ${def.name}${d.n > 1 ? ' ×' + d.n : ''}`, data: { drop: d.id } };
++ W.interactables.push(it);
++ worldDrops.set(d.id, { group, mesh, inter: it, t: Math.random() * 6, baseY: Math.max(.3, d.y || .35) });
++ }
++ function removeWorldDrop(id) {
++ const e = worldDrops.get(id);
++ if (!e) return;
++ scene.remove(e.group);
++ const i = W.interactables.indexOf(e.inter);
++ if (i !== -1) W.interactables.splice(i, 1);
++ worldDrops.delete(id);
++ }
++ net.on('drop', (m) => {
++ if (m.op === 'add') addWorldDrop(m.d);
++ else if (m.op === 'del') { removeWorldDrop(m.id); if (m.taker === myId) beep(600, .06, 'sine', .1); }
++ else if (m.op === 'grant') {
++ if (invApi.add(m.item, m.n)) toast(`${ITEMS[m.item].icon} ${ITEMS[m.item].name} → inventory`);
++ else dropItemInWorld(m.item); // full backpack: put it back on the floor
++ }
++ });
++
// ---------- permanent marks (G): sharpie on the floor, forever ----------
const tagGroup = new THREE.Group();
scene.add(tagGroup);
@@@ -926,6 +970,9 @@ function start(token, user) {
beep(600, .06, 'sine', .1);
}
return;
++ case 'dropitem':
++ net.send({ t: 'drop', op: 'take', id: nearest.data.drop });
++ return;
case 'micro':
toast('🍜 *microwave hums for 90 seconds*', 3200);
beep(980, .5, 'sine', .06);
@@@ -1210,6 +1257,12 @@ function start(token, user) {
s.zz.position.y = .7 + Math.sin(s.t * 1.6) * .07;
s.zz.material.opacity = .65 + Math.sin(s.t * 1.6) * .3;
}
++ // dropped items spin and bob, minecraft style
++ for (const e of worldDrops.values()) {
++ e.t += dt;
++ e.mesh.rotation.y += dt * 1.6;
++ e.mesh.position.y = e.baseY + Math.sin(e.t * 2.2) * .06;
++ }
scanInteractables();
mg.update(dt);
@@@ -1227,6 +1280,7 @@ function start(token, user) {
my, mg, scene, W, phys, input, teleport: (x, z, yaw) => { my.x = x; my.z = z; if (yaw !== undefined) my.yaw = yaw; sendPos(true); },
action: doAction, nearest: () => nearest?.id || nearestSeat?.id || null,
spawnProp, toggleSpawn, physgunGrab, physgunRelease, pgState, sleepers: sleeperMap, inv: invApi,
++ drops: worldDrops, dropItem: dropItemInWorld,
};
addEventListener('wheel', (e) => {
@@@ -1255,13 +1309,15 @@ function start(token, user) {
// ---------- ambient updates ----------
// walking away abandons a board seat
setInterval(() => {
++ // rotated-cafeteria anchors keep group-local x/z — world coords live in
++ // wx/wz (comparing against local coords ejected players instantly)
for (const id of ['a', 'b']) {
const s = mg.c4[id], a = W.anchors[`c4-${id}`];
-- if (s && a && s.seats.includes(me.name) && Math.hypot(my.x - a.x, my.z - a.z) > 4.5)
++ if (s && a && s.seats.includes(me.name) && Math.hypot(my.x - (a.wx ?? a.x), my.z - (a.wz ?? a.z)) > 4.5)
net.send({ t: 'c4', id, op: 'leave' });
}
const cs = mg.chess, ca = W.anchors.chess;
-- if (cs && ca && cs.seats.includes(me.name) && Math.hypot(my.x - ca.x, my.z - ca.z) > 4.5)
++ if (cs && ca && cs.seats.includes(me.name) && Math.hypot(my.x - (ca.wx ?? ca.x), my.z - (ca.wz ?? ca.z)) > 4.5)
net.send({ t: 'chess', op: 'leave' });
}, 1500);
ddiff --git a/public/js/minigames.js b/public/js/minigames.js
iindex 075a648..3ae3432 100644
---- a/public/js/minigames.js
++++ b/public/js/minigames.js
@@@ -3,6 +3,7 @@
import * as THREE from 'three';
import { net } from './net.js';
import { chessMoves } from './chess-rules.js';
++import { uiFocus } from './input.js';
// ---------- tiny synth ----------
let actx = null;
@@@ -268,6 +269,7 @@ export function initMinigames(W, me, toast) {
let arcade = null;
function openArcade() {
arcadeUI.classList.remove('hidden');
++ uiFocus('arcade', true); // free the mouse — the paddle tracks the cursor
arcade = {
px: 150, pw: 64, bx: 180, by: 380, vx: 2.6, vy: -3.6,
bricks: [], score: 0, lives: 3, over: false, started: false, t: 0,
@@@ -281,6 +283,7 @@ export function initMinigames(W, me, toast) {
if (arcade && submit && arcade.score > 0) net.send({ t: 'arcade', score: arcade.score });
arcade = null;
arcadeUI.classList.add('hidden');
++ uiFocus('arcade', false);
}
document.getElementById('btn-arcade-quit').addEventListener('click', () => closeArcade(true));
addEventListener('keydown', (e) => { if (arcade && e.code === 'Escape') closeArcade(true); });
ddiff --git a/public/js/props.js b/public/js/props.js
iindex 0305d5e..dfcc09d 100644
---- a/public/js/props.js
++++ b/public/js/props.js
@@@ -68,8 +68,8 @@ export const PROP_KINDS = {
'locker': { label: '🗄️ Locker bank', build() {
const g = new THREE.Group();
g.add(bx(2.0, 1.9, .5, mat(0xd6cfc0), 0, .95, 0));
-- const crown = bx(2.0, .06, .6, mat(0xc7bfae), 0, 2.0, -.04);
-- crown.rotation.x = -.4;
++ const crown = bx(2.0, .06, .6, mat(0xc7bfae), 0, 2.02, .04);
++ crown.rotation.x = .4; // slope down over the doors, high at the back
g.add(crown);
for (let i = 0; i < 4; i++) g.add(bx(.02, 1.7, .02, mat(0x9a927e), -.75 + i * .5, .95, .26));
return { g, cw: 2.05, cd: .6 };
ddiff --git a/public/js/world.js b/public/js/world.js
iindex 4e92df3..48f98b1 100644
---- a/public/js/world.js
++++ b/public/js/world.js
@@@ -673,7 +673,9 @@ function securityLobby(scene) {
const cs = ['#e40303', '#ff8c00', '#ffed00', '#008026', '#24408e', '#732982'];
cs.forEach((c, i) => { g.fillStyle = c; g.beginPath(); g.moveTo(i * 40, 0); g.lineTo(i * 40 + 26, h / 2); g.lineTo(i * 40, h); g.lineTo(i * 40 + 40, h); g.lineTo(i * 40 + 66, h / 2); g.lineTo(i * 40 + 40, 0); g.fill(); });
});
-- scene.add(box(.56, 1.02, 1.4, new THREE.MeshStandardMaterial({ map: chevTex, roughness: .7 }), 71.755, .51, -7.7));
++ // proud of the mural face by 4cm — coplanar with the desk front it z-fought
++ // into a rainbow moiré (Bug39)
++ scene.add(box(.64, 1.02, 1.4, new THREE.MeshStandardMaterial({ map: chevTex, roughness: .7 }), 71.75, .51, -7.7));
const badgeTex = TX.ct(160, 200, (g, w, h) => {
g.fillStyle = '#fff'; g.fillRect(0, 0, w, h);
g.fillStyle = '#5a2a82'; g.font = '700 15px "Segoe UI", sans-serif'; g.textAlign = 'center';
@@@ -682,7 +684,7 @@ function securityLobby(scene) {
g.fillStyle = '#d8d8d8'; g.fillRect(30, 130, 100, 50);
});
const badgeP = new THREE.Mesh(new THREE.PlaneGeometry(.85, .9), new THREE.MeshBasicMaterial({ map: badgeTex }));
-- badgeP.rotation.y = Math.PI / 2; badgeP.position.set(72.04, .52, -4.4);
++ badgeP.rotation.y = Math.PI / 2; badgeP.position.set(72.09, .52, -4.4); // clear of the mural face
scene.add(badgeP);
const cap = new THREE.Mesh(new THREE.CylinderGeometry(.62, .62, 1.02, 16, 1, false, 0, Math.PI), muralM);
cap.position.set(71.75, .51, -2.85);
@@@ -1217,9 +1219,15 @@ function entrance(scene) {
const mat = new THREE.Mesh(new THREE.PlaneGeometry(3.4, 2), new THREE.MeshStandardMaterial({ color: 0x3a3d42, roughness: 1 }));
mat.rotation.x = -Math.PI / 2; mat.position.set(-36.5, .012, 0);
scene.add(mat);
-- const ban = new THREE.Mesh(new THREE.PlaneGeometry(.9, 2.1), new THREE.MeshStandardMaterial({ map: TX.togetherBannerTexture(), side: THREE.DoubleSide }));
-- ban.position.set(-36.2, 1.15, 2.9); ban.rotation.y = Math.PI / 4;
-- scene.add(ban);
++ // two front-facing planes so the lettering never mirrors
++ const banM = new THREE.MeshStandardMaterial({ map: TX.togetherBannerTexture() });
++ for (const flip of [0, Math.PI]) {
++ const ban = new THREE.Mesh(new THREE.PlaneGeometry(.9, 2.1), banM);
++ const a2 = Math.PI / 4 + flip;
++ ban.position.set(-36.2 + Math.sin(a2) * .005, 1.15, 2.9 + Math.cos(a2) * .005);
++ ban.rotation.y = a2;
++ scene.add(ban);
++ }
scene.add(box(.9, .06, .3, M.blackMetal, -36.2, .03, 2.9));
collide(-36.2, 2.9, .8, .4);
}
@@@ -1461,10 +1469,11 @@ function gamesCorner(scene) {
scene.add(c);
W.seats.push({ id: `loose-${x.toFixed(0)}`, x, z, y: .5, ry: ry + Math.PI, type: 'chair', exitX: x + Math.sin(ry), exitZ: z + Math.cos(ry) });
}
-- // cornhole board leaning on wall (from trips)
++ // cornhole board leaning on wall (from trips) — top edge against the wall,
++ // feet in the room (it used to lean backwards, half-buried — Bug41)
const ch = box(.62, .04, 1.2, M.wood, 0, 0, 0);
-- ch.position.set(-17.8, .6, -16.55); ch.rotation.x = -1.15;
-- const hole = cyl(.09, .09, .05, M.blackMetal, -17.8, 1.0, -16.42); hole.rotation.x = -1.15;
++ ch.position.set(-17.8, .55, -16.6); ch.rotation.x = 1.15;
++ const hole = cyl(.09, .09, .05, M.blackMetal, -17.8, .93, -16.79); hole.rotation.x = 1.15;
scene.add(ch, hole);
}
@@@ -1901,9 +1910,10 @@ function lockers(scene, x, z, ry = 0) {
const body = box(2.0, 1.9, .5, new THREE.MeshStandardMaterial({ color: 0xd6cfc0, roughness: .7 }), 0, .95, 0);
g.add(body);
W.camBlockers.push(body);
-- // sloped crown like the real locker banks
-- const crown = box(2.0, .06, .6, new THREE.MeshStandardMaterial({ color: 0xc7bfae, roughness: .7 }), 0, 2.0, -.04);
-- crown.rotation.x = -.4;
++ // sloped crown like the real locker banks: high at the back, sloping down
++ // over the doors (the old tilt pointed the slope the wrong way — Bug38)
++ const crown = box(2.0, .06, .6, new THREE.MeshStandardMaterial({ color: 0xc7bfae, roughness: .7 }), 0, 2.02, .04);
++ crown.rotation.x = .4;
g.add(crown);
const face = new THREE.Mesh(new THREE.PlaneGeometry(1.96, 1.86), new THREE.MeshStandardMaterial({ map: lockerTexShared, roughness: .7 }));
face.position.set(0, .95, .26);
@@@ -1968,8 +1978,9 @@ function decor(scene) {
}
// red LED clocks (from trips)
W.dynamic.clocks = [];
-- for (const s of [{ x: -25, y: 3.9, z: -16.85, ry: 0 }, { x: 30, y: 3.4, z: 16.85, ry: Math.PI }, { x: 37.8, y: 3.9, z: 2.5, ry: -Math.PI / 2 }]) {
++ for (const s of [{ x: -25, y: 3.9, z: -16.8, ry: 0 }, { x: 30, y: 3.4, z: 16.8, ry: Math.PI }, { x: 37.75, y: 3.9, z: 2.5, ry: -Math.PI / 2 }]) {
const ck = TX.makeClock();
++ ck.tex.anisotropy = 8; // the LED digits shimmered into moiré at oblique angles (Bug40)
const m = new THREE.Mesh(new THREE.PlaneGeometry(.85, .32), new THREE.MeshBasicMaterial({ map: ck.tex }));
m.position.set(s.x, s.y, s.z); m.rotation.y = s.ry;
scene.add(m);
@@@ -2034,43 +2045,45 @@ function serviceHallway(scene) {
for (let i = -4; i <= 4; i++) scene.add(box(4.2, .05, .28, lin, i * 6.2, 3.08, -19.8));
// ---- south side: microwave/coffee counter run (from trips) ----
++ // the corridor runs on the -z side; everything on this counter faces it
++ // (fronts used to point +z, straight into the break-room wall — Bug42)
const counter = new THREE.Group();
counter.add(box(24, .9, .7, new THREE.MeshStandardMaterial({ color: 0x6a6e73, roughness: .6, metalness: .3 }), 0, .45, 0));
counter.add(box(24, .05, .78, new THREE.MeshStandardMaterial({ color: 0xd8d4c8, roughness: .4 }), 0, .93, 0));
-- counter.add(box(24, .05, .55, new THREE.MeshStandardMaterial({ color: 0x3d4045, roughness: .6 }), 0, 1.5, -.1)); // upper shelf
-- counter.position.set(-14, 0, -17.85);
++ counter.add(box(24, .05, .55, new THREE.MeshStandardMaterial({ color: 0x3d4045, roughness: .6 }), 0, 1.5, .1)); // upper shelf hugs the wall side
++ counter.position.set(-14, 0, -17.7);
scene.add(counter);
-- collide(-14, -17.85, 24, .85);
++ collide(-14, -17.7, 24, .85);
// microwaves on two tiers (instanced)
const mwGeo = new THREE.BoxGeometry(.5, .32, .42);
const mwM = new THREE.MeshStandardMaterial({ color: 0x1b1e22, roughness: .4 });
const mws = new THREE.InstancedMesh(mwGeo, mwM, 16);
const o = new THREE.Object3D();
for (let i = 0; i < 16; i++) {
-- o.position.set(-25 + (i % 8) * 2.1, i < 8 ? 1.12 : 1.7, -17.9);
++ o.position.set(-25 + (i % 8) * 2.1, i < 8 ? 1.12 : 1.7, -17.75);
o.updateMatrix(); mws.setMatrixAt(i, o.matrix);
}
scene.add(mws);
inter({ id: 'hall-micro', type: 'micro', x: -14, z: -19, r: 2.6, label: 'Heat up lunch' });
-- // coffee machine + cup rack (from trips)
-- scene.add(box(.55, .85, .5, new THREE.MeshStandardMaterial({ color: 0x17181c, roughness: .35 }), -27.3, 1.35, -17.9));
-- scene.add(box(.3, .2, .02, new THREE.MeshStandardMaterial({ color: 0x2a72b8, roughness: .2, emissive: 0x1a4a80, emissiveIntensity: .4 }), -27.35, 1.55, -17.62));
-- for (let i = 0; i < 3; i++) scene.add(cyl(.055, .04, .3, new THREE.MeshStandardMaterial({ color: 0xf2f0ea, roughness: .5 }), -26.6 + i * .18, 1.85, -17.9, 8));
++ // coffee machine + cup rack (from trips) — display toward the corridor
++ scene.add(box(.55, .85, .5, new THREE.MeshStandardMaterial({ color: 0x17181c, roughness: .35 }), -27.3, 1.35, -17.75));
++ scene.add(box(.3, .2, .02, new THREE.MeshStandardMaterial({ color: 0x2a72b8, roughness: .2, emissive: 0x1a4a80, emissiveIntensity: .4 }), -27.35, 1.55, -18.03));
++ for (let i = 0; i < 3; i++) scene.add(cyl(.055, .04, .3, new THREE.MeshStandardMaterial({ color: 0xf2f0ea, roughness: .5 }), -26.6 + i * .18, 1.85, -17.75, 8));
inter({ id: 'hall-coffee', type: 'coffee', x: -27, z: -19, r: 1.6, label: 'Brew a coffee' });
// ice/water machine + chest freezer + caution cone (from trips)
-- scene.add(box(1.0, 1.7, .8, new THREE.MeshStandardMaterial({ color: 0xb9bdc2, roughness: .3, metalness: .5 }), .8, .85, -17.9));
-- scene.add(box(.5, .3, .1, new THREE.MeshStandardMaterial({ color: 0x14181d }), .8, .8, -17.44));
-- collide(.8, -17.9, 1.1, .9);
++ scene.add(box(1.0, 1.7, .8, new THREE.MeshStandardMaterial({ color: 0xb9bdc2, roughness: .3, metalness: .5 }), .8, .85, -17.75));
++ scene.add(box(.5, .3, .1, new THREE.MeshStandardMaterial({ color: 0x14181d }), .8, .8, -18.21)); // dispenser on the corridor side
++ collide(.8, -17.75, 1.1, .9);
inter({ id: 'hall-water', type: 'water', x: .8, z: -19, r: 1.4, label: 'Get ice water' });
-- scene.add(box(1.5, .85, .7, new THREE.MeshStandardMaterial({ color: 0xf2f1ec, roughness: .5 }), 2.6, .43, -17.9));
-- collide(2.6, -17.9, 1.6, .8);
-- const cone = cyl(.03, .16, .5, new THREE.MeshStandardMaterial({ color: 0xf2c521, roughness: .6 }), 3.9, .25, -18.6, 8);
++ scene.add(box(1.5, .85, .7, new THREE.MeshStandardMaterial({ color: 0xf2f1ec, roughness: .5 }), 2.6, .43, -17.75));
++ collide(2.6, -17.75, 1.6, .8);
++ const cone = cyl(.03, .16, .5, new THREE.MeshStandardMaterial({ color: 0xf2c521, roughness: .6 }), 3.9, .25, -18.7, 8);
scene.add(cone);
-- // dark vending pair with glowing dots further along
++ // dark vending pair, snug to the wall, glowing fronts toward the corridor
for (const vx of [6.5, 7.7]) {
-- scene.add(blocker(box(1.1, 1.9, .8, new THREE.MeshStandardMaterial({ color: 0x14181d, roughness: .5 }), vx, .95, -17.95)));
-- scene.add(box(.7, 1.2, .02, new THREE.MeshStandardMaterial({ color: 0x1c62d1, emissive: 0x1c62d1, emissiveIntensity: .7, roughness: .4 }), vx, 1.05, -17.53));
-- collide(vx, -17.95, 1.15, .85);
++ scene.add(blocker(box(1.1, 1.9, .8, new THREE.MeshStandardMaterial({ color: 0x14181d, roughness: .5 }), vx, .95, -17.7)));
++ scene.add(box(.7, 1.2, .02, new THREE.MeshStandardMaterial({ color: 0x1c62d1, emissive: 0x1c62d1, emissiveIntensity: .7, roughness: .4 }), vx, 1.05, -18.12));
++ collide(vx, -17.7, 1.15, .85);
}
// ---- north side: racks, boards, bins, storage door, bathrooms ----
@@@ -2333,14 +2346,21 @@ function exteriorEast(scene) {
g.fillStyle = '#fff'; g.font = '700 26px "Segoe UI", sans-serif'; g.textAlign = 'center';
g.fillText('Amazon', w / 2, 90); g.fillText('Tours', w / 2, 122);
});
++ const toursBackM = new THREE.MeshStandardMaterial({ color: 0x1a1c20, roughness: .8, side: THREE.BackSide });
for (const [ax, az, ry] of [[99, 2.2, .3], [113.5, -2.2, -.2]]) {
const a = new THREE.Group();
for (const s2 of [-1, 1]) {
-- const p = new THREE.Mesh(new THREE.PlaneGeometry(.85, 1.0), new THREE.MeshStandardMaterial({ map: toursTex, side: THREE.DoubleSide }));
-- p.rotation.x = -s2 * .22; // tops lean together into the A
++ // text plane faces OUTWARD only; a plain dark plane backs the inside
++ // (a DoubleSide plane mirrored the lettering from behind — Bug37)
++ const p = new THREE.Mesh(new THREE.PlaneGeometry(.85, 1.0), new THREE.MeshStandardMaterial({ map: toursTex }));
++ p.rotation.x = -s2 * .22;
++ p.rotation.y = s2 === 1 ? 0 : Math.PI;
p.position.z = s2 * .12;
p.position.y = .5;
-- a.add(p);
++ const inner = new THREE.Mesh(new THREE.PlaneGeometry(.85, 1.0), toursBackM);
++ inner.rotation.copy(p.rotation); // same plane, back face only
++ inner.position.set(0, .5, s2 * .118);
++ a.add(p, inner);
}
a.position.set(ax, 0, az); a.rotation.y = ry;
scene.add(a);
@@@ -2606,9 +2626,15 @@ function streets(scene) {
const signPost = (x, z) => { scene.add(cyl(.04, .05, 3, M.chrome, x, 1.5, z, 8)); };
signPost(131.4, 131.2);
for (const [t, y, ry] of [['172nd St NE', 2.8, 0], ['51st Ave NE', 2.45, Math.PI / 2]]) {
-- const b = new THREE.Mesh(new THREE.PlaneGeometry(1.7, .32), new THREE.MeshBasicMaterial({ map: bladeTex(t), side: THREE.DoubleSide }));
-- b.position.set(131.4, y, 131.2); b.rotation.y = ry;
-- scene.add(b);
++ // readable from both sides: two front-facing planes, never mirrored
++ const bm = new THREE.MeshBasicMaterial({ map: bladeTex(t) });
++ for (const flip of [0, Math.PI]) {
++ const b = new THREE.Mesh(new THREE.PlaneGeometry(1.7, .32), bm);
++ const a2 = ry + flip; // nudge each face along its own normal (no self z-fight)
++ b.position.set(131.4 + Math.sin(a2) * .006, y, 131.2 + Math.cos(a2) * .006);
++ b.rotation.y = a2;
++ scene.add(b);
++ }
}
const stopTex = TX.ct(128, 128, (g, w, h) => {
g.fillStyle = '#c22127'; g.beginPath();
@@@ -2616,11 +2642,20 @@ function streets(scene) {
g.closePath(); g.fill();
g.fillStyle = '#fff'; g.font = '900 40px Arial'; g.textAlign = 'center'; g.fillText('STOP', w / 2, h / 2 + 14);
});
++ // plain gray octagon for the sign backs — real signs aren't double-sided,
++ // and a DoubleSide plane mirrored the STOP text (Bug36)
++ const stopBackTex = TX.ct(128, 128, (g, w, h) => {
++ g.fillStyle = '#8d9298'; g.beginPath();
++ for (let i = 0; i < 8; i++) { const a = Math.PI / 8 + i * Math.PI / 4; const px = w / 2 + Math.cos(a) * 58, py = h / 2 + Math.sin(a) * 58; i ? g.lineTo(px, py) : g.moveTo(px, py); }
++ g.closePath(); g.fill();
++ });
for (const [sx, sz] of [[130.5, 5.5], [130.5, 75.5]]) {
scene.add(cyl(.04, .05, 2.6, M.chrome, sx, 1.3, sz, 8));
-- const s = new THREE.Mesh(new THREE.PlaneGeometry(.7, .7), new THREE.MeshBasicMaterial({ map: stopTex, transparent: true, side: THREE.DoubleSide }));
++ const s = new THREE.Mesh(new THREE.PlaneGeometry(.7, .7), new THREE.MeshBasicMaterial({ map: stopTex, transparent: true }));
s.position.set(sx, 2.3, sz); s.rotation.y = Math.PI / 2;
-- scene.add(s);
++ const back = new THREE.Mesh(new THREE.PlaneGeometry(.7, .7), new THREE.MeshBasicMaterial({ map: stopBackTex, transparent: true }));
++ back.position.set(sx - .01, 2.3, sz); back.rotation.y = -Math.PI / 2;
++ scene.add(s, back);
}
// street lights along 172nd
const lampM = new THREE.MeshStandardMaterial({ color: 0xfff3c9, emissive: 0xfff3c9, emissiveIntensity: .9 });
@@@ -2750,9 +2785,13 @@ function streets(scene) {
g.fillText('the good car is', w / 2, 54);
g.fillText('behind the cage 🔑', w / 2, 74);
});
-- const hint = new THREE.Mesh(new THREE.PlaneGeometry(.8, .4), new THREE.MeshBasicMaterial({ map: hintTex, side: THREE.DoubleSide }));
-- hint.rotation.y = Math.PI / 2; hint.position.set(45.85, 1.35, 95);
-- scene.add(hint);
++ const hintM = new THREE.MeshBasicMaterial({ map: hintTex });
++ for (const flip of [0, Math.PI]) { // readable from both sides, never mirrored
++ const hint = new THREE.Mesh(new THREE.PlaneGeometry(.8, .4), hintM);
++ hint.rotation.y = Math.PI / 2 + flip;
++ hint.position.set(45.85 + (flip ? -.005 : .005), 1.35, 95);
++ scene.add(hint);
++ }
}
export function buildWorld(scene) {
ddiff --git a/server/server.js b/server/server.js
iindex 12264df..5d713dc 100644
---- a/server/server.js
++++ b/server/server.js
@@@ -104,6 +104,9 @@ 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;
++// 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}
++let nextDropId = [...drops.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(1)) || 0), 0) + 1;
// 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;
@@@ -122,7 +125,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), 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);
@@@ -182,6 +185,7 @@ wss.on('connection', (ws, req) => {
inv: user.inv, hotbar: user.hotbar,
visitorNum: stats.joins, stats: { joins: stats.joins, naps: stats.naps, marks: tags.length },
tags,
++ drops: [...drops.values()],
mapEdits,
players: [...players.values()].filter(q => q.id !== id).map(pubState),
c4: { a: c4.a.state(), b: c4.b.state() },
@@@ -407,6 +411,31 @@ function handle(p, m) {
case 'gate': // badge-gate animation relay so everyone sees paddles open
broadcast({ t: 'gate', id: String(m.id || '').slice(0, 20) });
break;
++ case 'drop': { // items tossed on the floor, minecraft style
++ if (m.op === 'add') {
++ const now = Date.now();
++ if (now - (p.lastDrop || 0) < 250) return;
++ p.lastDrop = now;
++ if (!store.ITEM_IDS.includes(m.item)) return;
++ if (drops.size >= 200) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🎒 The floor is covered in stuff already — pick something up first.' })); return; }
++ const n = Math.max(1, Math.min(99, Math.floor(Number(m.n) || 1)));
++ const d = {
++ id: 'd' + nextDropId++, item: m.item, n,
++ x: clampX(+(+m.x || 0).toFixed(2)), y: Math.max(.05, Math.min(8, +(+m.y || 0).toFixed(2))), z: clampZ(+(+m.z || 0).toFixed(2)),
++ };
++ drops.set(d.id, d);
++ worldDirty = true;
++ broadcast({ t: 'drop', op: 'add', d });
++ } else if (m.op === 'take') {
++ const d = drops.get(String(m.id));
++ if (!d) return;
++ drops.delete(d.id);
++ worldDirty = true;
++ broadcast({ t: 'drop', op: 'del', id: d.id, taker: p.id });
++ if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'drop', op: 'grant', item: d.item, n: d.n }));
++ }
++ break;
++ }
case 'tag': { // permanent marker on the floor — your mark stays forever
const now = Date.now();
if (now - (p.lastTag || 0) < 20000) {
ddiff --git a/server/store.js b/server/store.js
iindex 9147a9c..9c3c211 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -124,7 +124,7 @@ export function setAppearance(key, ap) {
return u.ap;
}
--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',
'hat-cap','hat-beanie','hat-hardhat','vest-yellow','vest-orange','vest-green','vest-blue','vest-pink'];
export function setInventory(key, inv, hotbar) {