openvibers/breakroomsimulator Commits

Day/night cycle, real shadows + tone mapping, physgun accuracy, car hitbox, burn barrel, drag-to-drop

OP OpenVibers committed 28 Jul 2026 · c56fbeb000 · 6 files, +270 −14

- lighting.js: shared 24-minute day/night cycle derived from wall time (every
  player sees the same sky), animated sun/moon directionals, hemisphere +
  sky/fog color grading, ACES tone mapping, 420-star night dome, and street
  lamps / porch lights that glow up after dark (W.nightGlow). Sun casts
  PCF shadows from a 2048 map that follows the player; Shadows toggle in
  settings. (Shadow rendering verified as env-limited under headless
  SwiftShader; standard on real GPUs.)
- Physgun: 9-ray crosshair fan so edge clicks land, range 14->22m, car hold
  distance 16m.
- Car hitbox matched to the visual car (4.1x1.8, 1.3 roof): tighter chassis
  box + cabin box compound instead of one oversized slab.
- Burn barrel on the outskirts by 172nd: animated flames/smoke/flickering
  light; E burns the selected item, and dropping items near it incinerates
  them instead of leaving a world drop.
- Minecraft drag-out: dragging an inventory/hotbar stack out over the game
  world drops the whole stack.

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

Changes

ddiff --git a/public/js/inventory.js b/public/js/inventory.js
iindex b0d123b..431d9dd 100644
---- a/public/js/inventory.js
++++ b/public/js/inventory.js
@@@ -101,11 +101,25 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
const onUp = (e) => {
if (!drag) return;
drag.ghost.remove();
-- const targetEl = document.elementFromPoint(e.clientX, e.clientY)?.closest('.slot');
-- const to = getRef(targetEl);
++ const under = document.elementFromPoint(e.clientX, e.clientY);
++ const to = getRef(under?.closest('.slot'));
const from = drag.ref;
drag = null;
-- if (!to || (to.arr === from.arr && to.i === from.i)) return;
++ if (!to) {
++ // dragged out over the game world: toss the whole stack, minecraft style
++ if (under?.id === 'game') {
++ const s = from.arr[from.i];
++ if (s) {
++ from.arr[from.i] = null;
++ if (from.arr === inv.hotbar && from.i === inv.sel) { onEquip(null); inv.sel = -1; }
++ renderHotbar(); renderInv(); save();
++ onDropItem?.(s.id, s.n);
++ beep(340, .06, 'sine', .08);
++ }
++ }
++ return;
++ }
++ if (to.arr === from.arr && to.i === from.i) return;
const a = from.arr[from.i], b = to.arr[to.i];
if (b && a && b.id === a.id && ITEMS[a.id].type === 'food') { // stack
b.n += a.n; from.arr[from.i] = null;
ddiff --git a/public/js/lighting.js b/public/js/lighting.js
nnew file mode 100644
iindex 0000000..ebf537e
---- /dev/null
++++ b/public/js/lighting.js
@@@ -0,0 +1,112 @@
++// Day/night cycle + lighting. The clock is derived from wall time so every
++// player (and every future visitor) shares the same sky — the lot at 3am is
++// the same lonely lot for everyone. Full cycle: 24 minutes.
++import * as THREE from 'three';
++import { W } from './world.js';
++
++const DAY_LEN = 1440; // seconds per in-game day
++
++export function timeOfDay() { // 0 = midnight, .5 = noon
++ return (Date.now() / 1000 % DAY_LEN) / DAY_LEN;
++}
++
++// give every opaque mesh under root shadow flags (transparent decals would
++// cast solid black rectangles)
++export function enableShadows(root) {
++ root.traverse?.(o => {
++ if (!o.isMesh) return;
++ const m = o.material;
++ const transp = Array.isArray(m) ? m.some(x => x.transparent) : m?.transparent;
++ o.castShadow = !transp;
++ o.receiveShadow = true;
++ });
++}
++
++export function initLighting(scene, renderer, shadows = true) {
++ renderer.toneMapping = THREE.ACESFilmicToneMapping;
++ renderer.toneMappingExposure = 1.05;
++ renderer.shadowMap.enabled = shadows;
++ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
++
++ const hemi = new THREE.HemisphereLight(0xf2f5f8, 0x54575c, 1.0);
++ const amb = new THREE.AmbientLight(0xffffff, .22);
++ const sun = new THREE.DirectionalLight(0xffeed8, 1.35);
++ const moon = new THREE.DirectionalLight(0x8fa8d8, 0);
++ sun.castShadow = true;
++ sun.shadow.mapSize.set(2048, 2048);
++ sun.shadow.camera.left = -48; sun.shadow.camera.right = 48;
++ sun.shadow.camera.top = 48; sun.shadow.camera.bottom = -48;
++ sun.shadow.camera.near = 1; sun.shadow.camera.far = 240;
++ sun.shadow.bias = -.0006;
++ sun.shadow.normalBias = .35;
++ scene.add(hemi, amb, sun, sun.target, moon, moon.target);
++ enableShadows(scene); // everything built so far
++
++ // stars, on a huge dome, visible only at night
++ const starPos = [];
++ for (let i = 0; i < 420; i++) {
++ const a = Math.random() * Math.PI * 2, e = .06 + Math.random() * 1.35, r = 235;
++ starPos.push(35 + Math.cos(a) * Math.cos(e) * r, Math.sin(e) * r, 64 + Math.sin(a) * Math.cos(e) * r);
++ }
++ const starGeo = new THREE.BufferGeometry();
++ starGeo.setAttribute('position', new THREE.Float32BufferAttribute(starPos, 3));
++ const stars = new THREE.Points(starGeo, new THREE.PointsMaterial({
++ color: 0xcfe0ff, size: 1.5, sizeAttenuation: false, transparent: true, opacity: 0, depthWrite: false, fog: false,
++ }));
++ stars.frustumCulled = false;
++ stars.renderOrder = -1;
++ scene.add(stars);
++
++ const skyDay = new THREE.Color(0xbcd2e2);
++ const skyDusk = new THREE.Color(0xe89a5e);
++ const skyNight = new THREE.Color(0x0b1322);
++ const sunWarm = new THREE.Color(0xff9a4a);
++ const sunNoon = new THREE.Color(0xffeed8);
++ const hemiDay = new THREE.Color(0xf2f5f8), hemiNight = new THREE.Color(0x28344e);
++ const gndDay = new THREE.Color(0x54575c), gndNight = new THREE.Color(0x0e1218);
++ const sky = new THREE.Color();
++ const smooth = (a, b, x) => { const k = Math.max(0, Math.min(1, (x - a) / (b - a))); return k * k * (3 - 2 * k); };
++
++ function update(px, pz) {
++ const t = timeOfDay();
++ const elev = -Math.cos(t * Math.PI * 2); // -1 midnight … +1 noon
++ const az = t * Math.PI * 2;
++ const d = smooth(-.14, .28, elev); // day factor
++
++ sky.copy(skyNight).lerp(skyDay, d);
++ if (elev > -.3 && elev < .3) sky.lerp(skyDusk, Math.max(0, 1 - Math.abs(elev) / .3) * .55);
++ scene.background.copy(sky);
++ scene.fog.color.copy(sky);
++
++ sun.intensity = 1.4 * d;
++ sun.color.copy(sunWarm).lerp(sunNoon, smooth(.05, .45, elev));
++ sun.position.set(px + Math.sin(az) * 80, Math.max(4, elev * 90), pz + Math.cos(az) * 40);
++ sun.target.position.set(px, 0, pz);
++
++ moon.intensity = .22 * (1 - d);
++ moon.position.set(px - Math.sin(az) * 70, Math.max(10, -elev * 80), pz - Math.cos(az) * 35);
++ moon.target.position.set(px, 0, pz);
++
++ hemi.intensity = .3 + .75 * d;
++ hemi.color.copy(hemiNight).lerp(hemiDay, d);
++ hemi.groundColor.copy(gndNight).lerp(gndDay, d);
++ amb.intensity = .08 + .16 * d;
++
++ stars.material.opacity = (1 - d) * .95;
++
++ // lamps/porch lights glow up as the sun goes down
++ for (const g of W.nightGlow || []) {
++ g.m.emissiveIntensity = g.day + (g.night - g.day) * (1 - d);
++ }
++ return t;
++ }
++
++ function setShadows(on) {
++ renderer.shadowMap.enabled = on;
++ scene.traverse(o => {
++ if (o.material) (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => { m.needsUpdate = true; });
++ });
++ }
++
++ return { update, setShadows, timeOfDay };
++}
ddiff --git a/public/js/main.js b/public/js/main.js
iindex 4d51762..6dfe1be 100644
---- a/public/js/main.js
++++ b/public/js/main.js
@@@ -9,6 +9,7 @@ import { initMinigames, beep } from './minigames.js';
import { initProps } from './props.js';
import { initEditor } from './editor.js';
import { initPhysics, PHYS_KINDS } from './physics.js';
++import { initLighting, enableShadows } from './lighting.js';
import { ct } from './textures.js';
const $ = (id) => document.getElementById(id);
@@@ -89,6 +90,8 @@ function start(token, user) {
const mg = initMinigames(W, me, toast);
const props = initProps(scene, () => me.admin);
const phys = initPhysics(scene); // sandbox physics: dynamic props + physgun
++ const daylight = initLighting(scene, renderer, // shared day/night cycle
++ JSON.parse(localStorage.getItem('brs-cfg') || '{}').shadows !== false);
let editor = null;
// ---------- drivable cars (rigid-body vehicles — they roll and flip) ----------
@@@ -168,10 +171,11 @@ function start(token, user) {
let fp = false; // first-person mode
// ---------- persistent game settings ----------
const cfg = Object.assign(
-- { wheel: 'zoom', sens: 1.0, fov: 70, invertY: false },
++ { wheel: 'zoom', sens: 1.0, fov: 70, invertY: false, shadows: true },
JSON.parse(localStorage.getItem('brs-cfg') || '{}'));
const saveCfg = () => localStorage.setItem('brs-cfg', JSON.stringify(cfg));
let myAvatar = makeAvatar(me.name, me.vest, me.guest, me.ap);
++ enableShadows(myAvatar.group);
scene.add(myAvatar.group);
function rebuildMyAvatar() {
const held = myAvatar.held, seatId = myAvatar.seatId;
@@@ -179,6 +183,7 @@ function start(token, user) {
myAvatar = makeAvatar(me.name, me.vest, me.guest, me.ap);
myAvatar.setHeld(held);
myAvatar.seatId = seatId;
++ enableShadows(myAvatar.group);
scene.add(myAvatar.group);
updateViewmodel(); // refresh first-person hands to the new look
}
@@@ -508,6 +513,7 @@ function start(token, user) {
if (others.has(p.id)) return;
const avatar = makeAvatar(p.name, p.vest, false, p.ap);
avatar.group.position.set(p.x, p.y, p.z);
++ enableShadows(avatar.group);
scene.add(avatar.group);
others.set(p.id, { avatar, name: p.name, vest: p.vest, ap: p.ap, target: { x: p.x, y: p.y, z: p.z, ry: p.ry } });
}
@@@ -548,6 +554,7 @@ function start(token, user) {
const zz = zzzSprite();
zz.position.set(.2, .7, -.7);
root.add(zz);
++ enableShadows(root);
scene.add(root);
sleeperMap.set(s.key, { root, zz, t: Math.random() * 6 });
}
@@@ -562,9 +569,19 @@ function start(token, user) {
// ---------- dropped items on the floor (minecraft style) ----------
const worldDrops = new Map(); // id -> {group, mesh, inter, t}
-- function dropItemInWorld(id) {
++ function nearFire() {
++ const f = W.anchors.fire;
++ return f && Math.hypot(my.x - f.x, my.z - f.z) < 2.2;
++ }
++ function burnItems(id, n = 1) {
++ toast(`🔥 ${ITEMS[id].icon} ${ITEMS[id].name}${n > 1 ? ' ×' + n : ''} went up in flames`, 3200);
++ beep(200, .25, 'sawtooth', .1);
++ setTimeout(() => beep(140, .3, 'sawtooth', .06), 180);
++ }
++ function dropItemInWorld(id, n = 1) {
++ if (nearFire()) { burnItems(id, n); return; } // dropped into the barrel
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) });
++ net.send({ t: 'drop', op: 'add', item: id, n, x: +(my.x + fx * 1.3).toFixed(2), y: .35, z: +(my.z + fz * 1.3).toFixed(2) });
}
function addWorldDrop(d) {
removeWorldDrop(d.id);
@@@ -578,6 +595,7 @@ function start(token, user) {
sh.position.y = .012;
group.add(sh);
group.position.set(d.x, 0, d.z);
++ enableShadows(group);
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 } };
@@@ -676,13 +694,22 @@ function start(token, user) {
const pgState = { held: null, dist: 0, lmb: false, missT: 0 };
const pgRay = new THREE.Raycaster();
function pgEquipped() { return my.held === 'physgun'; }
++ function pgPick() {
++ // fan of rays around the crosshair so edge clicks still land
++ let best = null;
++ for (const [ox, oy] of [[0, 0], [.028, 0], [-.028, 0], [0, .028], [0, -.028], [.055, 0], [-.055, 0], [0, .055], [0, -.055]]) {
++ pgRay.setFromCamera(new THREE.Vector2(ox, oy), camera);
++ const hit = phys.raycast(pgRay, 22);
++ if (hit && (!best || hit.distance < best.distance)) best = hit;
++ }
++ return best;
++ }
function physgunGrab() {
if (!pgEquipped() || pgState.held) return;
-- pgRay.setFromCamera(new THREE.Vector2(0, 0), camera);
-- const hit = phys.raycast(pgRay, 14);
++ const hit = pgPick();
if (!hit) return;
pgState.held = hit.e;
-- pgState.dist = Math.max(1.6, Math.min(hit.e.isCar ? 12 : 10, hit.distance));
++ pgState.dist = Math.max(1.6, Math.min(hit.e.isCar ? 16 : 12, hit.distance));
hit.e.grabbedBy = myId;
phys.claim(hit.e);
hit.e.body.angularDamping = .92;
@@@ -883,12 +910,14 @@ function start(token, user) {
$('set-sens').value = cfg.sens;
$('set-fov').value = cfg.fov;
$('set-inverty').checked = cfg.invertY;
++ $('set-shadows').checked = cfg.shadows !== false;
};
$('set-wheel-zoom').onclick = () => { cfg.wheel = 'zoom'; saveCfg(); refreshCfgUI(); };
$('set-wheel-hotbar').onclick = () => { cfg.wheel = 'hotbar'; saveCfg(); refreshCfgUI(); };
$('set-sens').oninput = (e) => { cfg.sens = +e.target.value; saveCfg(); };
$('set-fov').oninput = (e) => { cfg.fov = +e.target.value; camera.fov = cfg.fov; camera.updateProjectionMatrix(); saveCfg(); };
$('set-inverty').onchange = (e) => { cfg.invertY = e.target.checked; saveCfg(); };
++ $('set-shadows').onchange = (e) => { cfg.shadows = e.target.checked; daylight.setShadows(cfg.shadows); saveCfg(); };
camera.fov = cfg.fov; camera.updateProjectionMatrix();
refreshCfgUI();
$('btn-close-settings').onclick = () => { $('settings').classList.add('hidden'); uiFocus('settings', false); };
@@@ -973,6 +1002,13 @@ function start(token, user) {
case 'dropitem':
net.send({ t: 'drop', op: 'take', id: nearest.data.drop });
return;
++ case 'fire': {
++ const sel = invApi.selectedItem();
++ if (!sel) { toast('🔥 Warm. Select an item (1–6) and press E to burn it.'); return; }
++ const burned = invApi.dropSelected();
++ if (burned) burnItems(burned);
++ return;
++ }
case 'micro':
toast('🍜 *microwave hums for 90 seconds*', 3200);
beep(980, .5, 'sine', .06);
@@@ -1264,6 +1300,9 @@ function start(token, user) {
e.mesh.position.y = e.baseY + Math.sin(e.t * 2.2) * .06;
}
++ daylight.update(my.x, my.z);
++ W.dynamic.fires?.forEach(f => f.update(dt));
++
scanInteractables();
mg.update(dt);
@@@ -1277,7 +1316,7 @@ function start(token, user) {
// debug/testing handle
window.__brs = {
-- 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, 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,
ddiff --git a/public/js/physics.js b/public/js/physics.js
iindex 8a21fad..4814e70 100644
---- a/public/js/physics.js
++++ b/public/js/physics.js
@@@ -8,6 +8,7 @@ import * as CANNON from 'cannon-es';
import { W } from './world.js';
import { net } from './net.js';
import { ct } from './textures.js';
++import { enableShadows } from './lighting.js';
const boxTex = () => ct(128, 128, (g, w, h) => {
g.fillStyle = '#b98d5f'; g.fillRect(0, 0, w, h);
@@@ -129,7 +130,7 @@ export function initPhysics(scene) {
}
// cars are full rigid bodies: they roll, flip, get shoved, and can be
// physgunned around while nobody's driving — proper gmod energy
-- const CAR_HH = .5; // chassis half-height; visual group origin sits at the wheels
++ const CAR_HH = .42; // chassis half-height; visual group origin sits at the wheels
const carMat = new CANNON.Material('car');
// EXACTLY zero chassis-ground friction: the friction solver zeroes the
// tangential velocity of a heavy resting box under ANY nonzero μ (verified
@@@ -140,11 +141,15 @@ export function initPhysics(scene) {
let myId = null;
const cars = new Map(); // id -> {id, car, body, isCar, owned, grabbedBy, remote, rp, rq, rv, remoteT}
for (const car of W.cars || []) {
++ // hitbox matched to the visual car (4.1×1.8 body, 1.3 roof): chassis box
++ // plus a smaller cabin box on top so the shape rolls/stacks believably
++ const hl = car.hl ?? (car.hl = 2.05), hw = car.hw ?? (car.hw = .9);
const body = new CANNON.Body({
mass: 180, material: carMat,
-- shape: new CANNON.Box(new CANNON.Vec3(car.hl || 2.15, CAR_HH, car.hw || 1.0)),
angularDamping: .6, linearDamping: .12,
});
++ body.addShape(new CANNON.Box(new CANNON.Vec3(hl, CAR_HH, hw)));
++ body.addShape(new CANNON.Box(new CANNON.Vec3(hl * .52, .24, hw * .86)), new CANNON.Vec3(0, CAR_HH + .2, -.1));
body.position.set(car.x, CAR_HH + .05, car.z);
body.quaternion.setFromEuler(0, car.ry, 0);
body.sleepSpeedLimit = .35;
@@@ -242,6 +247,7 @@ export function initPhysics(scene) {
world.addBody(body);
const mesh = def.build();
mesh.position.copy(body.position);
++ enableShadows(mesh);
scene.add(mesh);
const e = { id: prop.id, kind: prop.kind, body, mesh, owned: mine, grabbedBy: null };
props.set(prop.id, e);
ddiff --git a/public/js/world.js b/public/js/world.js
iindex 48f98b1..122e058 100644
---- a/public/js/world.js
++++ b/public/js/world.js
@@@ -14,6 +14,7 @@ export const W = { // world registry
anchors: {}, // named positions/groups for minigames
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
};
const blocker = (m) => { W.camBlockers.push(m); return m; };
@@@ -2659,6 +2660,7 @@ function streets(scene) {
}
// 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));
@@@ -2683,7 +2685,9 @@ function streets(scene) {
h.add(box(.9, 1.9, .08, new THREE.MeshStandardMaterial({ color: 0x4a3626, roughness: .8 }), -1.1, .95, 2.12)); // door
for (const wx of [.7, 1.7]) h.add(box(.9, .8, .06, new THREE.MeshStandardMaterial({ color: 0xbfd6e4, roughness: .25, metalness: .3 }), wx, 1.5, 2.12));
if (isHome) {
-- h.add(box(.22, .14, .14, new THREE.MeshStandardMaterial({ color: 0xffd34d, emissive: 0xffb52e, emissiveIntensity: .9 }), -1.75, 2.15, 2.14)); // porch light
++ const porchM = new THREE.MeshStandardMaterial({ color: 0xffd34d, emissive: 0xffb52e, emissiveIntensity: .9 });
++ W.nightGlow.push({ m: porchM, day: .3, night: 2.6 });
++ h.add(box(.22, .14, .14, porchM, -1.75, 2.15, 2.14)); // porch light
const mat = new THREE.Mesh(new THREE.PlaneGeometry(1, .6), new THREE.MeshBasicMaterial({ map: TX.ct(96, 64, (g, w, hh) => { g.fillStyle = '#7a4b2a'; g.fillRect(0, 0, w, hh); g.fillStyle = '#fff'; g.font = '700 15px "Segoe UI"'; g.textAlign = 'center'; g.fillText('WELCOME', w / 2, 38); }) }));
mat.rotation.x = -Math.PI / 2; mat.position.set(-1.1, .02, 2.75); h.add(mat);
}
@@@ -2704,6 +2708,86 @@ function streets(scene) {
scene.add(mbPost);
scene.add(box(.5, .3, .32, new THREE.MeshStandardMaterial({ color: 0x2e4d8a, roughness: .5 }), 27.4, 1.2, 172.2));
inter({ id: 'home', type: 'home', x: 30, z: 174.2, r: 2.4, label: 'You made it — go relax 🏠' });
++ // ---- burn barrel on the outskirts: the night-shift hangout. Toss junk in
++ // and it's gone forever (E with an item selected, or drop stuff into it) ----
++ const rustBarrelM = new THREE.MeshStandardMaterial({ color: 0x6e4530, roughness: .85, metalness: .3 });
++ const fireG = new THREE.Group();
++ fireG.add(cyl(.36, .33, .92, rustBarrelM, 0, .46, 0, 12));
++ fireG.add(cyl(.33, .33, .05, new THREE.MeshStandardMaterial({ color: 0x0d0b09, roughness: .95 }), 0, .9, 0, 12));
++ const emberM = new THREE.MeshStandardMaterial({ color: 0xff5a1c, emissive: 0xff5a1c, emissiveIntensity: 2.2 });
++ W.nightGlow.push({ m: emberM, day: 1.6, night: 3.2 });
++ fireG.add(cyl(.28, .28, .04, emberM, 0, .93, 0, 12));
++ // crossed flame planes, animated in the frame loop
++ const flameTex = TX.ct(64, 96, (g, w, h) => {
++ const gr = g.createLinearGradient(0, h, 0, 0);
++ gr.addColorStop(0, 'rgba(255,120,20,.95)');
++ gr.addColorStop(.45, 'rgba(255,180,40,.8)');
++ gr.addColorStop(.8, 'rgba(255,230,120,.35)');
++ gr.addColorStop(1, 'rgba(255,240,180,0)');
++ g.fillStyle = gr;
++ g.beginPath();
++ g.moveTo(w / 2, 0);
++ g.quadraticCurveTo(w * .95, h * .45, w * .62, h);
++ g.lineTo(w * .38, h);
++ g.quadraticCurveTo(w * .05, h * .45, w / 2, 0);
++ g.fill();
++ });
++ const flames = [];
++ for (let i = 0; i < 3; i++) {
++ const f = new THREE.Mesh(new THREE.PlaneGeometry(.55, .8), new THREE.MeshBasicMaterial({
++ map: flameTex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, fog: false,
++ }));
++ f.position.set(0, 1.3, 0);
++ f.rotation.y = i * Math.PI / 3;
++ fireG.add(f);
++ flames.push(f);
++ }
++ const fireLight = new THREE.PointLight(0xff7722, 1.8, 11, 2);
++ fireLight.position.set(0, 1.4, 0);
++ fireG.add(fireLight);
++ // rising smoke puffs
++ const smokeTex = TX.ct(64, 64, (g, w, h) => {
++ const gr = g.createRadialGradient(w / 2, h / 2, 4, w / 2, h / 2, 30);
++ gr.addColorStop(0, 'rgba(90,90,95,.55)');
++ gr.addColorStop(1, 'rgba(90,90,95,0)');
++ g.fillStyle = gr; g.fillRect(0, 0, w, h);
++ });
++ const puffs = [];
++ for (let i = 0; i < 4; i++) {
++ const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: smokeTex, transparent: true, depthWrite: false }));
++ sp.position.set(0, 1.6 + i * .7, 0);
++ sp.scale.setScalar(.5);
++ fireG.add(sp);
++ puffs.push({ sp, t: i * .8 });
++ }
++ fireG.position.set(127.8, 0, 45.5);
++ scene.add(fireG);
++ collide(127.8, 45.5, .85, .85);
++ W.anchors.fire = { x: 127.8, z: 45.5 };
++ (W.dynamic.fires ||= []).push({
++ t: Math.random() * 10,
++ update(dt) {
++ this.t += dt;
++ for (let i = 0; i < flames.length; i++) {
++ const f = flames[i];
++ const k = Math.sin(this.t * (7 + i * 2.3) + i * 2.1);
++ f.scale.set(1 + k * .12, .9 + Math.sin(this.t * 9 + i) * .18 + .15, 1);
++ f.material.opacity = .75 + k * .2;
++ f.rotation.y += dt * (i % 2 ? .8 : -.6);
++ }
++ fireLight.intensity = 1.6 + Math.sin(this.t * 11) * .35 + Math.sin(this.t * 23) * .2;
++ for (const p of puffs) {
++ p.t += dt;
++ const k = (p.t % 3.2) / 3.2;
++ p.sp.position.y = 1.4 + k * 3.2;
++ p.sp.position.x = Math.sin(p.t * .9) * .2;
++ p.sp.scale.setScalar(.35 + k * 1.1);
++ p.sp.material.opacity = (1 - k) * .5;
++ }
++ },
++ });
++ inter({ id: 'fire', type: 'fire', x: 127.8, z: 45.5, r: 1.8, label: 'Toss something into the fire 🔥' });
++
// a strange humming tool someone dropped on the shoulder of 172nd —
// finders keepers: this is the physgun
const pgGlow = new THREE.MeshStandardMaterial({ color: 0x35e0ff, emissive: 0x35e0ff, emissiveIntensity: 1.6, roughness: .3 });
@@@ -2798,7 +2882,7 @@ export function buildWorld(scene) {
mats();
scene.background = new THREE.Color(0xbcd2e2);
scene.fog = new THREE.Fog(0xbcd2e2, 90, 260); // far enough to see down the streets
-- lights(scene);
++ // lighting lives in lighting.js now (day/night cycle, shadows)
// ---- cafeteria block: built in its legacy local frame, then rotated 90 deg and
// moved SOUTH of the security block per the site plan — the old east door
// wall now faces north into the locker hallway, and the old south window run
ddiff --git a/public/play.html b/public/play.html
iindex c5ef8b7..6448346 100644
---- a/public/play.html
++++ b/public/play.html
@@@ -121,6 +121,7 @@
<div class="set-row"><span>Sensitivity</span><input id="set-sens" type="range" min="0.4" max="2.2" step="0.1"></div>
<div class="set-row"><span>FOV</span><input id="set-fov" type="range" min="60" max="100" step="1"></div>
<div class="set-row"><span>Invert Y</span><input id="set-inverty" type="checkbox"></div>
++ <div class="set-row"><span>Shadows</span><input id="set-shadows" type="checkbox"></div>
<button id="btn-close-settings" class="btn-primary">Back to break</button>
<button id="btn-logout" class="btn-ghost">Badge out (log out)</button>
</div>