openvibers/breakroomsimulator Commits

Night interior lights, headlights, flashlight, knockable scenery, arcade car feel, market wall fixes

OP OpenVibers committed 28 Jul 2026 · b50984b00c · 8 files, +206 −37

- Bug44: interior fluorescents (8 always-on PointLights over cafeteria,
  security, lockers, hallway) so the facility never goes pitch black; street
  lamps cast real light pools after dark (W.nightLights); driven cars get a
  headlight SpotLight that ramps with darkness; new starter flashlight item
  (with paddle) whose beam follows the camera.
- Bug43: snack slat-walls flush on the market wall (floated .5m into the
  room), passage doors hinged at the jambs and swung flat against the wall,
  east clock centered on the blue band instead of under a flag.
- Bug45 + fun: spawn-walkway bed colliders removed; trees, shrubs, street
  lamps, and lot-exit stop signs are knockables — drive through them and
  they topple with a wobble (relayed to everyone via t:'knock'), then grow
  back ~25-30s later. Knockable colliders don't stop cars (marked kn and
  excluded from the cannon mirror) but still block walkers while upright.
- Visitor sign now leads with TOTAL BADGE-INS, ALL TIME.
- Car feel: earlier/snappier steering, slightly more slide, and body
  language — lean out of corners, nose dip on brakes, squat on throttle
  via angular-velocity springs.

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

Changes

ddiff --git a/public/js/avatar.js b/public/js/avatar.js
iindex 360d8a0..77820c8 100644
---- a/public/js/avatar.js
++++ b/public/js/avatar.js
@@@ -47,6 +47,7 @@ export const GRIPS = {
tapegun: { p: [0, .02, .05], r: [-1.3, .3, 0] },
banana: { p: [0, .04, .05], r: [-1.9, .3, .6] },
physgun: { p: [0, .03, .06], r: [-1.45, 0, 0] },
++ flashlight: { p: [0, .03, .05], r: [-1.4, 0, 0] },
chips: { p: [0, .04, .05], r: [-.5, 0, 0] },
food: { p: [0, .02, .06], r: [-.4, 0, 0] },
candy: { p: [0, .03, .05], r: [-.5, 0, 0] },
@@@ -97,6 +98,14 @@ export function buildHeldMesh(item) {
const b = new THREE.Mesh(new THREE.TorusGeometry(.09, .026, 8, 12, Math.PI * .9), new THREE.MeshStandardMaterial({ color: 0xf2d21f, roughness: .6 }));
b.rotation.z = .6;
g.add(b);
++ } else if (item === 'flashlight') {
++ const bodyC = new THREE.Mesh(new THREE.CylinderGeometry(.035, .042, .22, 10), dark);
++ bodyC.rotation.x = Math.PI / 2;
++ g.add(bodyC);
++ const lens = new THREE.Mesh(new THREE.CylinderGeometry(.048, .04, .05, 10), new THREE.MeshStandardMaterial({ color: 0xfff6d8, emissive: 0xfff6d8, emissiveIntensity: 1.3 }));
++ lens.rotation.x = Math.PI / 2;
++ lens.position.z = -.13;
++ g.add(lens);
} else if (item === 'physgun') {
const glowM = new THREE.MeshStandardMaterial({ color: 0x35e0ff, emissive: 0x35e0ff, emissiveIntensity: 1.5, roughness: .3 });
const bodyM = new THREE.Mesh(new THREE.BoxGeometry(.09, .1, .34), dark);
ddiff --git a/public/js/inventory.js b/public/js/inventory.js
iindex 431d9dd..df478ee 100644
---- a/public/js/inventory.js
++++ b/public/js/inventory.js
@@@ -19,6 +19,7 @@ export const ITEMS = {
wrench: { name: 'Wrench', icon: '🔧', type: 'melee' },
banana: { name: 'Banana', icon: '🍌', type: 'melee' },
physgun: { name: 'Physgun', icon: '🧲', type: 'tool' },
++ flashlight: { name: 'Flashlight', icon: '🔦', type: 'tool' },
'hat-cap': { name: 'Cap', icon: '🧢', type: 'clothes', ap: { hat: 1 } },
'hat-beanie': { name: 'Beanie', icon: '👒', type: 'clothes', ap: { hat: 2 } },
'hat-hardhat': { name: 'Hard hat', icon: '⛑️', type: 'clothes', ap: { hat: 3 } },
@@@ -37,7 +38,10 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
// load persisted
if (Array.isArray(me.inv)) me.inv.forEach((s, i) => { if (i < 24 && s && ITEMS[s.id]) inv.slots[i] = { id: s.id, n: s.n || 1 }; });
if (Array.isArray(me.hotbar)) me.hotbar.forEach((s, i) => { if (i < 6 && s && ITEMS[s.id]) inv.hotbar[i] = { id: s.id, n: s.n || 1 }; });
-- if (!me.inv && !me.hotbar) inv.hotbar[0] = { id: 'paddle', n: 1 }; // starter
++ if (!me.inv && !me.hotbar) { // starter kit: a light for the long nights, plus the classic
++ inv.hotbar[0] = { id: 'flashlight', n: 1 };
++ inv.hotbar[1] = { id: 'paddle', n: 1 };
++ }
let saveT = null;
const save = () => {
ddiff --git a/public/js/lighting.js b/public/js/lighting.js
iindex ebf537e..aee2388 100644
---- a/public/js/lighting.js
++++ b/public/js/lighting.js
@@@ -40,6 +40,18 @@ export function initLighting(scene, renderer, shadows = true) {
sun.shadow.bias = -.0006;
sun.shadow.normalBias = .35;
scene.add(hemi, amb, sun, sun.target, moon, moon.target);
++ // interior fluorescents — the lights stay on all night (Bug44: the facility
++ // went pitch black after dark). World coords: cafeteria, security, lockers,
++ // service hallway.
++ for (const [x, y, z, i2, dist] of [
++ [67, 4.6, 55, .75, 30], [67, 4.6, 83, .75, 30], [83, 4.6, 55, .75, 30], [83, 4.6, 83, .75, 30],
++ [77, 4.4, -5, .65, 26], [75, 4.2, 24, .65, 26],
++ [55.2, 2.8, 55, .55, 22], [55.2, 2.8, 83, .55, 22],
++ ]) {
++ const pl = new THREE.PointLight(0xf6f4ea, i2, dist, 1.2);
++ pl.position.set(x, y, z);
++ scene.add(pl);
++ }
enableShadows(scene); // everything built so far
// stars, on a huge dome, visible only at night
@@@ -98,6 +110,10 @@ export function initLighting(scene, renderer, shadows = true) {
for (const g of W.nightGlow || []) {
g.m.emissiveIntensity = g.day + (g.night - g.day) * (1 - d);
}
++ for (const nl of W.nightLights || []) {
++ nl.light.intensity = nl.night * (1 - d);
++ }
++ W.dayFactor = d; // headlights/flashlight read this
return t;
}
ddiff --git a/public/js/main.js b/public/js/main.js
iindex 6dfe1be..ed6ecf9 100644
---- a/public/js/main.js
++++ b/public/js/main.js
@@@ -217,6 +217,11 @@ function start(token, user) {
viewmodel.rotation.set(-.2, -.35, .15);
camera.add(viewmodel);
scene.add(camera);
++ // flashlight beam rides the camera so it points where you look
++ const torch = new THREE.SpotLight(0xfff6d8, 0, 30, .5, .45, 1.2);
++ torch.position.set(.25, -.2, .1);
++ torch.target.position.set(0, -.4, -12);
++ camera.add(torch, torch.target);
let vmSwing = 0;
// per-item viewmodel pose so props read correctly in first person (Bug10)
const VM_POSE = {
@@@ -336,6 +341,7 @@ function start(token, user) {
const r = invApi.useSelected();
if (!r) return;
if (r.melee === 'physgun') { toast('🧲 Hold left-click to grab · wheel push/pull · R spin · X delete · Q spawns props'); return; }
++ if (r.melee === 'flashlight') { toast('🔦 Lights wherever you look — best after dark.'); return; }
if (r.melee) { doSwing(); return; }
if (r.def) {
toast(`😋 ${r.def.icon} ${r.def.name} — delicious.`);
@@@ -428,14 +434,16 @@ function start(token, user) {
g.fillStyle = '#101418'; g.fillRect(0, 0, w, h);
g.strokeStyle = '#2c343e'; g.lineWidth = 8; g.strokeRect(4, 4, w - 8, h - 8);
g.textAlign = 'center';
-- g.fillStyle = '#8fa0b3'; g.font = '700 30px "Segoe UI", sans-serif';
-- g.fillText('YOU ARE ASSOCIATE', w / 2, 52);
-- g.fillStyle = '#ffb52e'; g.font = '700 84px "Consolas", monospace';
++ g.fillStyle = '#8fa0b3'; g.font = '700 26px "Segoe UI", sans-serif';
++ g.fillText(`YOU ARE ASSOCIATE Nº ${m.visitorNum}`, w / 2, 44);
++ g.fillStyle = '#5d6b7a'; g.font = '700 22px "Segoe UI", sans-serif';
++ g.fillText('TOTAL BADGE-INS, ALL TIME', w / 2, 84);
++ g.fillStyle = '#ffb52e'; g.font = '700 76px "Consolas", monospace';
g.shadowColor = '#ffb52e'; g.shadowBlur = 18;
-- g.fillText('Nº ' + pad6(m.visitorNum), w / 2, 138);
++ g.fillText(pad6(m.stats?.joins ?? m.visitorNum), w / 2, 154);
g.shadowBlur = 0;
-- g.fillStyle = '#5d6b7a'; g.font = '24px "Consolas", monospace';
-- g.fillText(`naps: ${m.stats?.naps ?? 0} · marks: ${m.stats?.marks ?? 0} · the lights stay on`, w / 2, 190);
++ g.fillStyle = '#5d6b7a'; g.font = '22px "Consolas", monospace';
++ g.fillText(`naps: ${m.stats?.naps ?? 0} · marks: ${m.stats?.marks ?? 0} · the lights stay on`, w / 2, 196);
});
const sg = new THREE.Group();
const post = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, 2.0, 8), new THREE.MeshStandardMaterial({ color: 0x2a2f36, roughness: .6 }));
@@@ -646,6 +654,23 @@ function start(token, user) {
}
net.on('tag', (m) => { if (m.op === 'add') addMark(m.tag); });
++ // ---------- knockables: mow down the landscaping, it grows back ----------
++ const knockAxis = new THREE.Vector3();
++ function knockIt(k, dx, dz, mine) {
++ if (k.down) return;
++ k.down = true;
++ k.t = 0;
++ k.dx = dx; k.dz = dz;
++ k.respawn = 24 + Math.random() * 8;
++ if (k.col) k.col.off = true;
++ if (mine) net.send({ t: 'knock', id: k.id, dx: +dx.toFixed(2), dz: +dz.toFixed(2) });
++ beep(150, .12, 'square', .1);
++ }
++ net.on('knock', (m) => {
++ const k = W.knockables.find(x => x.id === m.id);
++ if (k && Number.isFinite(m.dx) && Number.isFinite(m.dz)) knockIt(k, m.dx, m.dz, false);
++ });
++
const tagForm = $('tag-form'), tagInput = $('tag-input');
let tagOpen = false;
function openTag() {
@@@ -1300,6 +1325,65 @@ function start(token, user) {
e.mesh.position.y = e.baseY + Math.sin(e.t * 2.2) * .06;
}
++ // plow through knockables while driving fast enough
++ if (carState.driving) {
++ const ce = phys.cars.get(carState.driving.id);
++ const cv = ce.body.velocity;
++ const spd = Math.hypot(cv.x, cv.z);
++ if (spd > 3.5) {
++ for (const k of W.knockables) {
++ if (k.down) continue;
++ const dx = k.x - my.x, dz = k.z - my.z;
++ if (dx * dx + dz * dz < (k.r + 1.9) ** 2) knockIt(k, cv.x / spd, cv.z / spd, true);
++ }
++ }
++ }
++ for (const k of W.knockables) {
++ if (!k.down) continue;
++ k.t += dt;
++ const o = k.obj;
++ if (k.t < 1.1) { // topple with a wobble at the end
++ const p = Math.min(1, k.t / 1.1);
++ const fall = 1 - (1 - p) ** 2;
++ const wob = p > .75 ? Math.sin((p - .75) * 26) * .18 * (1 - p) : 0;
++ knockAxis.set(k.dz, 0, -k.dx).normalize();
++ o.quaternion.setFromAxisAngle(knockAxis, (Math.PI / 2 - .1) * fall + wob);
++ o.position.set(
++ o.userData.homePos.x + k.dx * fall * .6,
++ o.userData.homePos.y,
++ o.userData.homePos.z + k.dz * fall * .6,
++ );
++ } else if (k.t > k.respawn) { // grow back
++ const gr = (k.t - k.respawn) / .5;
++ o.quaternion.copy(o.userData.homeQuat);
++ o.position.copy(o.userData.homePos);
++ if (gr >= 1) {
++ k.down = false;
++ o.scale.setScalar(1);
++ if (k.col) k.col.off = false;
++ } else o.scale.setScalar(Math.max(.05, gr));
++ }
++ }
++
++ // headlights on driven cars, brighter after dark
++ for (const e2 of phys.cars.values()) {
++ const car = e2.car;
++ if (car.driver != null) {
++ if (!e2.hlLight) {
++ const sp = new THREE.SpotLight(0xfff2cf, 0, 36, .52, .5, 1.1);
++ sp.position.set(0, .85, 2.0);
++ const tgt = new THREE.Object3D();
++ tgt.position.set(0, .25, 15);
++ car.group.add(sp, tgt);
++ sp.target = tgt;
++ e2.hlLight = sp;
++ }
++ e2.hlLight.intensity = .25 + (1 - (W.dayFactor ?? 1)) * 3.4;
++ } else if (e2.hlLight) e2.hlLight.intensity = 0;
++ }
++ // flashlight follows your view
++ torch.intensity = my.held === 'flashlight' ? .5 + (1 - (W.dayFactor ?? 1)) * 3.6 : 0;
++
daylight.update(my.x, my.z);
W.dynamic.fires?.forEach(f => f.update(dt));
@@@ -1319,7 +1403,7 @@ function start(token, user) {
my, mg, scene, W, phys, input, renderer, daylight, teleport: (x, z, yaw) => { my.x = x; my.z = z; if (yaw !== undefined) my.yaw = yaw; sendPos(true); },
action: doAction, nearest: () => nearest?.id || nearestSeat?.id || null,
spawnProp, toggleSpawn, physgunGrab, physgunRelease, pgState, sleepers: sleeperMap, inv: invApi,
-- drops: worldDrops, dropItem: dropItemInWorld,
++ drops: worldDrops, dropItem: dropItemInWorld, knockIt,
};
addEventListener('wheel', (e) => {
ddiff --git a/public/js/physics.js b/public/js/physics.js
iindex 4814e70..5b1acfe 100644
---- a/public/js/physics.js
++++ b/public/js/physics.js
@@@ -121,7 +121,7 @@ export function initPhysics(scene) {
// car colliders are skipped here and tracked as kinematic bodies instead
const carCols = new Set((W.cars || []).map(c => c.col));
for (const c of W.colliders) {
-- if (carCols.has(c) || !Number.isFinite(c.x0)) continue;
++ if (carCols.has(c) || c.kn || !Number.isFinite(c.x0)) continue; // knockables don't stop cars
const hw = (c.x1 - c.x0) / 2, hd = (c.z1 - c.z0) / 2;
if (hw <= 0 || hd <= 0) continue;
const b = new CANNON.Body({ type: CANNON.Body.STATIC, material: groundMat, shape: new CANNON.Box(new CANNON.Vec3(hw, 1.3, hd)) });
@@@ -182,14 +182,22 @@ export function initPhysics(scene) {
else if (throttle < 0) vFwd += (vFwd > .3 ? 14 : 5) * throttle * dt;
vFwd -= vFwd * (handbrake ? 2.2 : .45) * dt; // drag
vFwd = Math.max(-6, Math.min(top, vFwd));
-- vLat *= Math.pow(handbrake ? .6 : .02, dt); // grip (handbrake = drift)
++ vLat *= Math.pow(handbrake ? .55 : .05, dt); // grip (a touch more slide)
b.velocity.set(
fwd.x * vFwd + right.x * vLat,
b.velocity.y,
fwd.z * vFwd + right.z * vLat,
);
-- const sf = Math.min(Math.abs(vFwd) / 7, 1) * 1.9 * (vFwd < -.3 ? -1 : 1);
-- b.angularVelocity.y = steer * (handbrake ? 1.7 : 1) * sf;
++ // snappier steering that comes in earlier
++ const sf = Math.min(Math.abs(vFwd) / 5, 1) * 2.15 * (vFwd < -.3 ? -1 : 1);
++ b.angularVelocity.y = steer * (handbrake ? 1.8 : 1) * sf;
++ // arcade body language: lean out of corners, nose dip on the brakes,
++ // a little squat under throttle — springs on the angular velocity
++ const spdK = Math.min(Math.abs(vFwd) / (e.car.top || 17), 1);
++ const rollErr = steer * spdK * .17 - right.y;
++ const pitchErr = (throttle < -0.05 && vFwd > 1 ? -.06 : throttle > .05 ? .035 : 0) * spdK - fwd.y;
++ b.angularVelocity.x += (fwd.x * rollErr + right.x * pitchErr) * 26 * dt;
++ b.angularVelocity.z += (fwd.z * rollErr + right.z * pitchErr) * 26 * dt;
}
if (up.y < .95) { // self-righting: torque toward world-up
const axis = up.cross(V(0, 1, 0));
ddiff --git a/public/js/world.js b/public/js/world.js
iindex 122e058..0c47a11 100644
---- a/public/js/world.js
++++ b/public/js/world.js
@@@ -15,7 +15,15 @@ export const W = { // world registry
dynamic: {}, // tv/clock updaters
camBlockers: [], // meshes the 3rd-person camera must not clip through
nightGlow: [], // {m, day, night} emissive materials that wake up after dark
++ nightLights: [], // {light, night} real lights that switch on after dark
++ knockables: [], // trees/lamps/bushes cars can flatten (they respawn)
};
++// register a crashable decoration: world-space object, hit circle, optional collider
++function knockable(obj, x, z, r, col = null) {
++ obj.userData.homePos = obj.position.clone();
++ obj.userData.homeQuat = obj.quaternion.clone();
++ W.knockables.push({ id: 'k' + W.knockables.length, obj, x, z, r, col, down: false });
++}
const blocker = (m) => { W.camBlockers.push(m); return m; };
const M = {}; // shared materials
@@@ -51,7 +59,11 @@ const cyl = (rt, rb, h, mat, x = 0, y = 0, z = 0, seg = 14) => {
m.position.set(x, y, z);
return m;
};
--const collide = (x, z, w, d, pad = 0) => W.colliders.push({ x0: x - w / 2 - pad, x1: x + w / 2 + pad, z0: z - d / 2 - pad, z1: z + d / 2 + pad });
++const collide = (x, z, w, d, pad = 0) => {
++ const c = { x0: x - w / 2 - pad, x1: x + w / 2 + pad, z0: z - d / 2 - pad, z1: z + d / 2 + pad };
++ W.colliders.push(c);
++ return c;
++};
const inter = (o) => W.interactables.push(o);
// ============================================================ ROOM SHELL
@@@ -156,14 +168,16 @@ function shell(scene) {
collide(38.1, -9.75, .6, 14.5); collide(38.1, 10.6, .6, 12.8);
// close the height step between cafeteria (6.2) and the taller hall (7.2)
scene.add(blocker(box(.3, 1.1, 34, M.white, 38, 6.75, 0)));
-- // double doors propped open at the passage (from trips) + flags + clock above
-- for (const [dz, ry] of [[-2.7, .95], [4.4, -.95]]) {
++ // double doors hinged at the opening jambs (z -2.5 / 4.2), swung open flat
++ // against the cafeteria side of the wall — they used to float mid-opening
++ // at odd angles (Bug43)
++ for (const [dz, ry] of [[-2.5, -1.89], [4.2, -1.25]]) {
const leaf = box(.08, 2.3, 1.5, new THREE.MeshStandardMaterial({ color: 0xf2f1ec, roughness: .7 }), 0, 1.15, 0);
leaf.add(box(.09, .5, .5, new THREE.MeshStandardMaterial({ color: 0x9aa4ae, roughness: .3, metalness: .5 }), 0, -.7, 0)); // kick plate
const lg = new THREE.Group();
lg.add(leaf);
-- leaf.position.set(0, 1.15, .75); // stand on the floor (0 sank it halfway in)
-- lg.position.set(38.2, 0, dz); lg.rotation.y = ry;
++ leaf.position.set(0, 1.15, .75); // hinge at the group origin
++ lg.position.set(37.85, 0, dz); lg.rotation.y = ry;
scene.add(lg);
}
for (const [i, f] of ['fiji', 'puertorico', 'kyrgyzstan', 'spain'].entries()) {
@@@ -1848,11 +1862,13 @@ function cooler(scene, x, z, kind) {
inter({ id: `cooler-${z.toFixed(1)}`, type: 'vend', x: x - .9, z, r: 1.0, label: kind === 'food' ? 'Grab fresh food' : kind === 'redbull' ? 'Grab an energy drink' : 'Grab a drink', data: { kind } });
}
function snackWall(scene, x, z) {
++ // slat wall sits flush on the x=38 wall face (it used to float half a
++ // meter into the room — Bug43)
const s = new THREE.Mesh(new THREE.PlaneGeometry(3.4, 2.4), new THREE.MeshStandardMaterial({ map: TX.snackWallTexture(), roughness: .8 }));
-- s.rotation.y = -Math.PI / 2; s.position.set(x - .1, 1.55, z);
++ s.rotation.y = -Math.PI / 2; s.position.set(37.82, 1.55, z);
scene.add(s);
-- collide(x, z, .5, 3.5);
-- inter({ id: `snack-${z}`, type: 'vend', x: x - .8, z, r: 1.2, label: 'Grab a snack', data: { kind: 'snack' } });
++ collide(38, z, .4, 3.5);
++ inter({ id: `snack-${z}`, type: 'vend', x: 37.1, z, r: 1.2, label: 'Grab a snack', data: { kind: 'snack' } });
}
function kiosk(scene, x, z, ry) {
const g = new THREE.Group();
@@@ -1979,7 +1995,9 @@ function decor(scene) {
}
// red LED clocks (from trips)
W.dynamic.clocks = [];
-- 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 }]) {
++ // east clock centered on the blue accent band over the locker passage
++ // (it used to hang half over the opening under a flag — Bug43)
++ 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.82, y: 3.65, z: .85, 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 }));
@@@ -2327,8 +2345,12 @@ function exteriorEast(scene) {
t.add(fol);
t.position.set(bx2, 0, bz);
scene.add(t);
++ knockable(t, bx2, bz, .7);
} else if (i % 3 === 1) {
-- scene.add(new THREE.Mesh(new THREE.SphereGeometry(.42, 8, 6), new THREE.MeshStandardMaterial({ color: 0x3f6f35, roughness: .95 })).translateX(bx2).translateY(.3).translateZ(bz));
++ const sh = new THREE.Mesh(new THREE.SphereGeometry(.42, 8, 6), new THREE.MeshStandardMaterial({ color: 0x3f6f35, roughness: .95 }));
++ sh.position.set(bx2, .3, bz);
++ scene.add(sh);
++ knockable(sh, bx2, bz, .55);
} else {
for (let gj = 0; gj < 3; gj++) {
const grass = new THREE.Mesh(new THREE.ConeGeometry(.3, .9, 5), new THREE.MeshStandardMaterial({ color: 0x6d8a4e, roughness: .95 }));
@@@ -2337,7 +2359,8 @@ function exteriorEast(scene) {
}
}
}
-- collide(109, s * 3.4, 22, 3.0); // stay on the path like a good visitor
++ // no bed colliders — drive through the landscaping if you must, the
++ // greenery just gets flattened and grows back (Bug45)
}
// trash barrel mid-path + Amazon Tours A-frames
scene.add(cyl(.35, .3, .85, new THREE.MeshStandardMaterial({ color: 0x1d2126, roughness: .8 }), 104.5, .43, 1.1, 12));
@@@ -2651,21 +2674,35 @@ function streets(scene) {
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 sg = new THREE.Group();
++ sg.add(cyl(.04, .05, 2.6, M.chrome, 0, 1.3, 0, 8));
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(0, 2.3, 0); s.rotation.y = Math.PI / 2;
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);
++ back.position.set(-.01, 2.3, 0); back.rotation.y = -Math.PI / 2;
++ sg.add(s, back);
++ sg.position.set(sx, 0, sz);
++ scene.add(sg);
++ knockable(sg, sx, sz, .6); // no collider — mow it down, it comes back
}
// street lights along 172nd
const lampM = new THREE.MeshStandardMaterial({ color: 0xfff3c9, emissive: 0xfff3c9, emissiveIntensity: .9 });
W.nightGlow.push({ m: lampM, day: .25, night: 2.4 });
for (const lz of [-30, 10, 50, 90, 126]) {
-- scene.add(cyl(.07, .09, 6.4, M.blackMetal, 132.2, 3.2, lz, 8));
-- scene.add(box(1.6, .08, .12, M.blackMetal, 133, 6.35, lz));
-- scene.add(box(.5, .1, .22, lampM, 133.7, 6.28, lz));
-- collide(132.2, lz, .3, .3);
++ const lamp = new THREE.Group();
++ lamp.add(cyl(.07, .09, 6.4, M.blackMetal, 0, 3.2, 0, 8));
++ lamp.add(box(1.6, .08, .12, M.blackMetal, .8, 6.35, 0));
++ lamp.add(box(.5, .1, .22, lampM, 1.5, 6.28, 0));
++ // real pool of light on the road after dark
++ const glow = new THREE.PointLight(0xffe9b0, 0, 16, 1.4);
++ glow.position.set(1.5, 6.1, 0);
++ lamp.add(glow);
++ W.nightLights.push({ light: glow, night: 1.5 });
++ lamp.position.set(132.2, 0, lz);
++ scene.add(lamp);
++ const c = collide(132.2, lz, .3, .3);
++ c.kn = true; // knockable: cars plow through, the pole tips over
++ knockable(lamp, 132.2, lz, .7, c);
}
// ---- Longhouse Trail houses ----
const house = (hx, hz, ry, cBody, cRoof, isHome) => {
@@@ -2819,13 +2856,18 @@ function streets(scene) {
scene.add(pgG);
inter({ id: 'pk-physgun', type: 'pickup', x: 130.9, z: 41.5, r: 2.0, label: 'Whoa… some kind of gravity tool 🧲', data: { item: 'physgun' } });
-- // a few yard trees
++ // a few yard trees — crashable, they grow back
for (const [tx, tz] of [[14, 146], [46, 158], [14, 168], [44, 174], [120, 150]]) {
-- scene.add(cyl(.09, .12, 1.7, M.woodDark, tx, .85, tz, 8));
++ const tree = new THREE.Group();
++ tree.add(cyl(.09, .12, 1.7, M.woodDark, 0, .85, 0, 8));
const fol = new THREE.Mesh(new THREE.SphereGeometry(1.25, 10, 8), new THREE.MeshStandardMaterial({ color: 0x4c7a3d, roughness: .9 }));
-- fol.position.set(tx, 2.6, tz); fol.scale.y = 1.25;
-- scene.add(fol);
-- collide(tx, tz, .4, .4);
++ fol.position.set(0, 2.6, 0); fol.scale.y = 1.25;
++ tree.add(fol);
++ tree.position.set(tx, 0, tz);
++ scene.add(tree);
++ const c = collide(tx, tz, .4, .4);
++ c.kn = true;
++ knockable(tree, tx, tz, 1.1, c);
}
// ---- the secret: a rusty container behind the smoke cage, something golden inside ----
ddiff --git a/server/server.js b/server/server.js
iindex 5d713dc..4b0f9ab 100644
---- a/server/server.js
++++ b/server/server.js
@@@ -436,6 +436,12 @@ function handle(p, m) {
}
break;
}
++ case 'knock': { // decorative crash relay — no state, knockables respawn
++ const kid = String(m.id || '').slice(0, 8);
++ if (!/^k\d+$/.test(kid) || ![m.dx, m.dz].every(Number.isFinite)) return;
++ broadcast({ t: 'knock', id: kid, dx: +m.dx.toFixed(2), dz: +m.dz.toFixed(2) }, p.id);
++ 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 9c3c211..44044f0 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -125,7 +125,7 @@ export function setAppearance(key, ap) {
}
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','flashlight',
'hat-cap','hat-beanie','hat-hardhat','vest-yellow','vest-orange','vest-green','vest-blue','vest-pink'];
export function setInventory(key, inv, hotbar) {
const u = users[key] || guests.get(key);