openvibers/breakroomsimulator Commits
Fix chess/C4 instant-kick, arcade mouse, mirrored signs, rotated props, z-fighting, hallway orientation; add item dropping
Changes
@@@ -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 };}@@@ -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 remembersif (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 seatsetInterval(() => {++ // 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);@@@ -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 cursorarcade = {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); });@@@ -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 backg.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 };@@@ -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 facescene.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 corridorfor (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 172ndconst 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) {@@@ -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 openbroadcast({ 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 foreverconst now = Date.now();if (now - (p.lastTag || 0) < 20000) {@@@ -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) {
@@ -27,7 +27,7 @@ export const ITEMS = {
@@ -27,7 +27,7 @@ export const ITEMS = {
'vest-blue': { name: 'Blue vest', icon: '🟦', type: 'clothes', vest: 'blue' },
'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 = {
const inv = {
slots: new Array(24).fill(null), // {id, n}
slots: new Array(24).fill(null), // {id, n}
hotbar: new Array(6).fill(null),
hotbar: new Array(6).fill(null),
@@ -121,6 +121,35 @@ export function initInventory({ me, onEquip, onWear, toast }) {
@@ -121,6 +121,35 @@ export function initInventory({ me, onEquip, onWear, toast }) {
addEventListener('pointermove', moveGhost);
addEventListener('pointermove', moveGhost);
addEventListener('pointerup', onUp);
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 ----------
// ---------- API ----------
function findRoom(id) {
function findRoom(id) {
const stack = ITEMS[id].type === 'food';
const stack = ITEMS[id].type === 'food';
@@ -201,5 +230,5 @@ export function initInventory({ me, onEquip, onWear, toast }) {
@@ -201,5 +230,5 @@ export function initInventory({ me, onEquip, onWear, toast }) {
if (el?.dataset?.bar !== undefined) select(+el.dataset.bar);
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 };
}
}
@@ -189,6 +189,7 @@ function start(token, user) {
@@ -189,6 +189,7 @@ function start(token, user) {
// ---------- inventory / equipment ----------
// ---------- inventory / equipment ----------
const invApi = initInventory({
const invApi = initInventory({
me,
me,
+ onDropItem: (id) => dropItemInWorld(id),
onEquip: (item) => {
onEquip: (item) => {
my.held = item;
my.held = item;
myAvatar.setHeld(item);
myAvatar.setHeld(item);
@@ -349,6 +350,7 @@ function start(token, user) {
@@ -349,6 +350,7 @@ function start(token, user) {
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 === '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 === '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 });
});
});
@@ -413,6 +415,7 @@ function start(token, user) {
@@ -413,6 +415,7 @@ function start(token, user) {
(m.sleepers || []).forEach(addSleeper);
(m.sleepers || []).forEach(addSleeper);
(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);
// retro associate counter by the front walkway — proof the place remembers
// retro associate counter by the front walkway — proof the place remembers
if (m.visitorNum) {
if (m.visitorNum) {
const pad6 = (v) => String(Math.min(v, 999999)).padStart(6, '0');
const pad6 = (v) => String(Math.min(v, 999999)).padStart(6, '0');
@@ -441,7 +444,7 @@ function start(token, user) {
@@ -441,7 +444,7 @@ function start(token, user) {
scene.add(sg);
scene.add(sg);
W.colliders.push({ x0: 102.7, x1: 103.3, z0: 6.1, z1: 6.7 });
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(`📟 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 });
if (me.admin && !editor) editor = initEditor({ scene, camera, props, toast });
$('online-count').textContent = m.online;
$('online-count').textContent = m.online;
@@ -557,6 +560,47 @@ function start(token, user) {
@@ -557,6 +560,47 @@ function start(token, user) {
net.on('sleep', (m) => addSleeper(m.s));
net.on('sleep', (m) => addSleeper(m.s));
net.on('wake', (m) => removeSleeper(m.key));
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 ----------
// ---------- permanent marks (G): sharpie on the floor, forever ----------
const tagGroup = new THREE.Group();
const tagGroup = new THREE.Group();
scene.add(tagGroup);
scene.add(tagGroup);
@@ -926,6 +970,9 @@ function start(token, user) {
@@ -926,6 +970,9 @@ function start(token, user) {
beep(600, .06, 'sine', .1);
beep(600, .06, 'sine', .1);
}
}
return;
return;
+ case 'dropitem':
+ net.send({ t: 'drop', op: 'take', id: nearest.data.drop });
+ return;
case 'micro':
case 'micro':
toast('🍜 *microwave hums for 90 seconds*', 3200);
toast('🍜 *microwave hums for 90 seconds*', 3200);
beep(980, .5, 'sine', .06);
beep(980, .5, 'sine', .06);
@@ -1210,6 +1257,12 @@ function start(token, user) {
@@ -1210,6 +1257,12 @@ function start(token, user) {
s.zz.position.y = .7 + Math.sin(s.t * 1.6) * .07;
s.zz.position.y = .7 + Math.sin(s.t * 1.6) * .07;
s.zz.material.opacity = .65 + Math.sin(s.t * 1.6) * .3;
s.zz.material.opacity = .65 + Math.sin(s.t * 1.6) * .3;
}
}
+ // 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();
scanInteractables();
mg.update(dt);
mg.update(dt);
@@ -1227,6 +1280,7 @@ function start(token, user) {
@@ -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); },
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,
action: doAction, nearest: () => nearest?.id || nearestSeat?.id || null,
spawnProp, toggleSpawn, physgunGrab, physgunRelease, pgState, sleepers: sleeperMap, inv: invApi,
spawnProp, toggleSpawn, physgunGrab, physgunRelease, pgState, sleepers: sleeperMap, inv: invApi,
+ drops: worldDrops, dropItem: dropItemInWorld,
};
};
addEventListener('wheel', (e) => {
addEventListener('wheel', (e) => {
@@ -1255,13 +1309,15 @@ function start(token, user) {
@@ -1255,13 +1309,15 @@ function start(token, user) {
// ---------- ambient updates ----------
// ---------- ambient updates ----------
// walking away abandons a board seat
// walking away abandons a board seat
setInterval(() => {
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']) {
for (const id of ['a', 'b']) {
const s = mg.c4[id], a = W.anchors[`c4-${id}`];
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' });
net.send({ t: 'c4', id, op: 'leave' });
}
}
const cs = mg.chess, ca = W.anchors.chess;
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' });
net.send({ t: 'chess', op: 'leave' });
}, 1500);
}, 1500);
@@ -3,6 +3,7 @@
@@ -3,6 +3,7 @@
import * as THREE from 'three';
import * as THREE from 'three';
import { net } from './net.js';
import { net } from './net.js';
import { chessMoves } from './chess-rules.js';
import { chessMoves } from './chess-rules.js';
+import { uiFocus } from './input.js';
// ---------- tiny synth ----------
// ---------- tiny synth ----------
let actx = null;
let actx = null;
@@ -268,6 +269,7 @@ export function initMinigames(W, me, toast) {
@@ -268,6 +269,7 @@ export function initMinigames(W, me, toast) {
let arcade = null;
let arcade = null;
function openArcade() {
function openArcade() {
arcadeUI.classList.remove('hidden');
arcadeUI.classList.remove('hidden');
+ uiFocus('arcade', true); // free the mouse — the paddle tracks the cursor
arcade = {
arcade = {
px: 150, pw: 64, bx: 180, by: 380, vx: 2.6, vy: -3.6,
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,
bricks: [], score: 0, lives: 3, over: false, started: false, t: 0,
@@ -281,6 +283,7 @@ export function initMinigames(W, me, toast) {
@@ -281,6 +283,7 @@ export function initMinigames(W, me, toast) {
if (arcade && submit && arcade.score > 0) net.send({ t: 'arcade', score: arcade.score });
if (arcade && submit && arcade.score > 0) net.send({ t: 'arcade', score: arcade.score });
arcade = null;
arcade = null;
arcadeUI.classList.add('hidden');
arcadeUI.classList.add('hidden');
+ uiFocus('arcade', false);
}
}
document.getElementById('btn-arcade-quit').addEventListener('click', () => closeArcade(true));
document.getElementById('btn-arcade-quit').addEventListener('click', () => closeArcade(true));
addEventListener('keydown', (e) => { if (arcade && e.code === 'Escape') closeArcade(true); });
addEventListener('keydown', (e) => { if (arcade && e.code === 'Escape') closeArcade(true); });
@@ -68,8 +68,8 @@ export const PROP_KINDS = {
@@ -68,8 +68,8 @@ export const PROP_KINDS = {
'locker': { label: '🗄️ Locker bank', build() {
'locker': { label: '🗄️ Locker bank', build() {
const g = new THREE.Group();
const g = new THREE.Group();
g.add(bx(2.0, 1.9, .5, mat(0xd6cfc0), 0, .95, 0));
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);
g.add(crown);
for (let i = 0; i < 4; i++) g.add(bx(.02, 1.7, .02, mat(0x9a927e), -.75 + i * .5, .95, .26));
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 };
return { g, cw: 2.05, cd: .6 };
@@ -673,7 +673,9 @@ function securityLobby(scene) {
@@ -673,7 +673,9 @@ function securityLobby(scene) {
const cs = ['#e40303', '#ff8c00', '#ffed00', '#008026', '#24408e', '#732982'];
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(); });
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) => {
const badgeTex = TX.ct(160, 200, (g, w, h) => {
g.fillStyle = '#fff'; g.fillRect(0, 0, 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';
g.fillStyle = '#5a2a82'; g.font = '700 15px "Segoe UI", sans-serif'; g.textAlign = 'center';
@@ -682,7 +684,7 @@ function securityLobby(scene) {
@@ -682,7 +684,7 @@ function securityLobby(scene) {
g.fillStyle = '#d8d8d8'; g.fillRect(30, 130, 100, 50);
g.fillStyle = '#d8d8d8'; g.fillRect(30, 130, 100, 50);
});
});
const badgeP = new THREE.Mesh(new THREE.PlaneGeometry(.85, .9), new THREE.MeshBasicMaterial({ map: badgeTex }));
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);
scene.add(badgeP);
const cap = new THREE.Mesh(new THREE.CylinderGeometry(.62, .62, 1.02, 16, 1, false, 0, Math.PI), muralM);
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);
cap.position.set(71.75, .51, -2.85);
@@ -1217,9 +1219,15 @@ function entrance(scene) {
@@ -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 }));
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);
mat.rotation.x = -Math.PI / 2; mat.position.set(-36.5, .012, 0);
scene.add(mat);
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));
scene.add(box(.9, .06, .3, M.blackMetal, -36.2, .03, 2.9));
collide(-36.2, 2.9, .8, .4);
collide(-36.2, 2.9, .8, .4);
}
}
@@ -1461,10 +1469,11 @@ function gamesCorner(scene) {
@@ -1461,10 +1469,11 @@ function gamesCorner(scene) {
scene.add(c);
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) });
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);
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);
scene.add(ch, hole);
}
}
@@ -1901,9 +1910,10 @@ function lockers(scene, x, z, ry = 0) {
@@ -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);
const body = box(2.0, 1.9, .5, new THREE.MeshStandardMaterial({ color: 0xd6cfc0, roughness: .7 }), 0, .95, 0);
g.add(body);
g.add(body);
W.camBlockers.push(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);
g.add(crown);
const face = new THREE.Mesh(new THREE.PlaneGeometry(1.96, 1.86), new THREE.MeshStandardMaterial({ map: lockerTexShared, roughness: .7 }));
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);
face.position.set(0, .95, .26);
@@ -1968,8 +1978,9 @@ function decor(scene) {
@@ -1968,8 +1978,9 @@ function decor(scene) {
}
}
// red LED clocks (from trips)
// red LED clocks (from trips)
W.dynamic.clocks = [];
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();
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 }));
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;
m.position.set(s.x, s.y, s.z); m.rotation.y = s.ry;
scene.add(m);
scene.add(m);
@@ -2034,43 +2045,45 @@ function serviceHallway(scene) {
@@ -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));
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) ----
// ---- 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();
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, .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, .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);
scene.add(counter);
- collide(-14, -17.85, 24, .85);
+ collide(-14, -17.7, 24, .85);
// microwaves on two tiers (instanced)
// microwaves on two tiers (instanced)
const mwGeo = new THREE.BoxGeometry(.5, .32, .42);
const mwGeo = new THREE.BoxGeometry(.5, .32, .42);
const mwM = new THREE.MeshStandardMaterial({ color: 0x1b1e22, roughness: .4 });
const mwM = new THREE.MeshStandardMaterial({ color: 0x1b1e22, roughness: .4 });
const mws = new THREE.InstancedMesh(mwGeo, mwM, 16);
const mws = new THREE.InstancedMesh(mwGeo, mwM, 16);
const o = new THREE.Object3D();
const o = new THREE.Object3D();
for (let i = 0; i < 16; i++) {
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);
o.updateMatrix(); mws.setMatrixAt(i, o.matrix);
}
}
scene.add(mws);
scene.add(mws);
inter({ id: 'hall-micro', type: 'micro', x: -14, z: -19, r: 2.6, label: 'Heat up lunch' });
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' });
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)
// 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' });
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);
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]) {
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 ----
// ---- north side: racks, boards, bins, storage door, bathrooms ----
@@ -2333,14 +2346,21 @@ function exteriorEast(scene) {
@@ -2333,14 +2346,21 @@ function exteriorEast(scene) {
g.fillStyle = '#fff'; g.font = '700 26px "Segoe UI", sans-serif'; g.textAlign = 'center';
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);
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]]) {
for (const [ax, az, ry] of [[99, 2.2, .3], [113.5, -2.2, -.2]]) {
const a = new THREE.Group();
const a = new THREE.Group();
for (const s2 of [-1, 1]) {
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.z = s2 * .12;
p.position.y = .5;
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;
a.position.set(ax, 0, az); a.rotation.y = ry;
scene.add(a);
scene.add(a);
@@ -2606,9 +2626,15 @@ function streets(scene) {
@@ -2606,9 +2626,15 @@ function streets(scene) {
const signPost = (x, z) => { scene.add(cyl(.04, .05, 3, M.chrome, x, 1.5, z, 8)); };
const signPost = (x, z) => { scene.add(cyl(.04, .05, 3, M.chrome, x, 1.5, z, 8)); };
signPost(131.4, 131.2);
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]]) {
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) => {
const stopTex = TX.ct(128, 128, (g, w, h) => {
g.fillStyle = '#c22127'; g.beginPath();
g.fillStyle = '#c22127'; g.beginPath();
@@ -2616,11 +2642,20 @@ function streets(scene) {
@@ -2616,11 +2642,20 @@ function streets(scene) {
g.closePath(); g.fill();
g.closePath(); g.fill();
g.fillStyle = '#fff'; g.font = '900 40px Arial'; g.textAlign = 'center'; g.fillText('STOP', w / 2, h / 2 + 14);
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]]) {
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));
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;
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
// 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 });
@@ -2750,9 +2785,13 @@ function streets(scene) {
@@ -2750,9 +2785,13 @@ function streets(scene) {
g.fillText('the good car is', w / 2, 54);
g.fillText('the good car is', w / 2, 54);
g.fillText('behind the cage 🔑', w / 2, 74);
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) {
export function buildWorld(scene) {
@@ -104,6 +104,9 @@ for (const [cid, c] of Object.entries(savedWorld.cars || {})) {
@@ -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;
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.
// 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;
@@ -122,7 +125,7 @@ let worldDirty = false;
@@ -122,7 +125,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), 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);
@@ -182,6 +185,7 @@ wss.on('connection', (ws, req) => {
@@ -182,6 +185,7 @@ wss.on('connection', (ws, req) => {
inv: user.inv, hotbar: user.hotbar,
inv: user.inv, hotbar: user.hotbar,
visitorNum: stats.joins, stats: { joins: stats.joins, naps: stats.naps, marks: tags.length },
visitorNum: stats.joins, stats: { joins: stats.joins, naps: stats.naps, marks: tags.length },
tags,
tags,
+ drops: [...drops.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() },
@@ -407,6 +411,31 @@ function handle(p, m) {
@@ -407,6 +411,31 @@ function handle(p, m) {
case 'gate': // badge-gate animation relay so everyone sees paddles open
case 'gate': // badge-gate animation relay so everyone sees paddles open
broadcast({ t: 'gate', id: String(m.id || '').slice(0, 20) });
broadcast({ t: 'gate', id: String(m.id || '').slice(0, 20) });
break;
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
case 'tag': { // permanent marker on the floor — your mark stays forever
const now = Date.now();
const now = Date.now();
if (now - (p.lastTag || 0) < 20000) {
if (now - (p.lastTag || 0) < 20000) {
@@ -124,7 +124,7 @@ export function setAppearance(key, ap) {
@@ -124,7 +124,7 @@ export function setAppearance(key, ap) {
return u.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',
'paddle','broom','tapegun','tube','wrench','banana','physgun',
'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) {