openvibers/breakroomsimulator Commits
Day/night cycle, real shadows + tone mapping, physgun accuracy, car hitbox, burn barrel, drag-to-drop
Changes
@@@ -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') { // stackb.n += a.n; from.arr[from.i] = null;nnew file mode 100644@@@ -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 };++}@@@ -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 barrelconst 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 handlewindow.__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,@@@ -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 wheelsconst 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);@@@ -14,6 +14,7 @@ export const W = { // world registryanchors: {}, // named positions/groups for minigamesdynamic: {}, // tv/clock updaterscamBlockers: [], // 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 172ndconst 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)); // doorfor (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 lightconst 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 physgunconst 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@@@ -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>
@@ -101,11 +101,25 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
@@ -101,11 +101,25 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
const onUp = (e) => {
const onUp = (e) => {
if (!drag) return;
if (!drag) return;
drag.ghost.remove();
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;
const from = drag.ref;
drag = null;
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];
const a = from.arr[from.i], b = to.arr[to.i];
if (b && a && b.id === a.id && ITEMS[a.id].type === 'food') { // stack
if (b && a && b.id === a.id && ITEMS[a.id].type === 'food') { // stack
b.n += a.n; from.arr[from.i] = null;
b.n += a.n; from.arr[from.i] = null;
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -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 };
+}
@@ -9,6 +9,7 @@ import { initMinigames, beep } from './minigames.js';
@@ -9,6 +9,7 @@ import { initMinigames, beep } from './minigames.js';
import { initProps } from './props.js';
import { initProps } from './props.js';
import { initEditor } from './editor.js';
import { initEditor } from './editor.js';
import { initPhysics, PHYS_KINDS } from './physics.js';
import { initPhysics, PHYS_KINDS } from './physics.js';
+import { initLighting, enableShadows } from './lighting.js';
import { ct } from './textures.js';
import { ct } from './textures.js';
const $ = (id) => document.getElementById(id);
const $ = (id) => document.getElementById(id);
@@ -89,6 +90,8 @@ function start(token, user) {
@@ -89,6 +90,8 @@ function start(token, user) {
const mg = initMinigames(W, me, toast);
const mg = initMinigames(W, me, toast);
const props = initProps(scene, () => me.admin);
const props = initProps(scene, () => me.admin);
const phys = initPhysics(scene); // sandbox physics: dynamic props + physgun
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;
let editor = null;
// ---------- drivable cars (rigid-body vehicles — they roll and flip) ----------
// ---------- drivable cars (rigid-body vehicles — they roll and flip) ----------
@@ -168,10 +171,11 @@ function start(token, user) {
@@ -168,10 +171,11 @@ function start(token, user) {
let fp = false; // first-person mode
let fp = false; // first-person mode
// ---------- persistent game settings ----------
// ---------- persistent game settings ----------
const cfg = Object.assign(
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') || '{}'));
JSON.parse(localStorage.getItem('brs-cfg') || '{}'));
const saveCfg = () => localStorage.setItem('brs-cfg', JSON.stringify(cfg));
const saveCfg = () => localStorage.setItem('brs-cfg', JSON.stringify(cfg));
let myAvatar = makeAvatar(me.name, me.vest, me.guest, me.ap);
let myAvatar = makeAvatar(me.name, me.vest, me.guest, me.ap);
+ enableShadows(myAvatar.group);
scene.add(myAvatar.group);
scene.add(myAvatar.group);
function rebuildMyAvatar() {
function rebuildMyAvatar() {
const held = myAvatar.held, seatId = myAvatar.seatId;
const held = myAvatar.held, seatId = myAvatar.seatId;
@@ -179,6 +183,7 @@ function start(token, user) {
@@ -179,6 +183,7 @@ function start(token, user) {
myAvatar = makeAvatar(me.name, me.vest, me.guest, me.ap);
myAvatar = makeAvatar(me.name, me.vest, me.guest, me.ap);
myAvatar.setHeld(held);
myAvatar.setHeld(held);
myAvatar.seatId = seatId;
myAvatar.seatId = seatId;
+ enableShadows(myAvatar.group);
scene.add(myAvatar.group);
scene.add(myAvatar.group);
updateViewmodel(); // refresh first-person hands to the new look
updateViewmodel(); // refresh first-person hands to the new look
}
}
@@ -508,6 +513,7 @@ function start(token, user) {
@@ -508,6 +513,7 @@ function start(token, user) {
if (others.has(p.id)) return;
if (others.has(p.id)) return;
const avatar = makeAvatar(p.name, p.vest, false, p.ap);
const avatar = makeAvatar(p.name, p.vest, false, p.ap);
avatar.group.position.set(p.x, p.y, p.z);
avatar.group.position.set(p.x, p.y, p.z);
+ enableShadows(avatar.group);
scene.add(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 } });
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) {
@@ -548,6 +554,7 @@ function start(token, user) {
const zz = zzzSprite();
const zz = zzzSprite();
zz.position.set(.2, .7, -.7);
zz.position.set(.2, .7, -.7);
root.add(zz);
root.add(zz);
+ enableShadows(root);
scene.add(root);
scene.add(root);
sleeperMap.set(s.key, { root, zz, t: Math.random() * 6 });
sleeperMap.set(s.key, { root, zz, t: Math.random() * 6 });
}
}
@@ -562,9 +569,19 @@ function start(token, user) {
@@ -562,9 +569,19 @@ function start(token, user) {
// ---------- dropped items on the floor (minecraft style) ----------
// ---------- dropped items on the floor (minecraft style) ----------
const worldDrops = new Map(); // id -> {group, mesh, inter, t}
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);
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) {
function addWorldDrop(d) {
removeWorldDrop(d.id);
removeWorldDrop(d.id);
@@ -578,6 +595,7 @@ function start(token, user) {
@@ -578,6 +595,7 @@ function start(token, user) {
sh.position.y = .012;
sh.position.y = .012;
group.add(sh);
group.add(sh);
group.position.set(d.x, 0, d.z);
group.position.set(d.x, 0, d.z);
+ enableShadows(group);
scene.add(group);
scene.add(group);
const def = ITEMS[d.item];
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 } };
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) {
@@ -676,13 +694,22 @@ function start(token, user) {
const pgState = { held: null, dist: 0, lmb: false, missT: 0 };
const pgState = { held: null, dist: 0, lmb: false, missT: 0 };
const pgRay = new THREE.Raycaster();
const pgRay = new THREE.Raycaster();
function pgEquipped() { return my.held === 'physgun'; }
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() {
function physgunGrab() {
if (!pgEquipped() || pgState.held) return;
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;
if (!hit) return;
pgState.held = hit.e;
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;
hit.e.grabbedBy = myId;
phys.claim(hit.e);
phys.claim(hit.e);
hit.e.body.angularDamping = .92;
hit.e.body.angularDamping = .92;
@@ -883,12 +910,14 @@ function start(token, user) {
@@ -883,12 +910,14 @@ function start(token, user) {
$('set-sens').value = cfg.sens;
$('set-sens').value = cfg.sens;
$('set-fov').value = cfg.fov;
$('set-fov').value = cfg.fov;
$('set-inverty').checked = cfg.invertY;
$('set-inverty').checked = cfg.invertY;
+ $('set-shadows').checked = cfg.shadows !== false;
};
};
$('set-wheel-zoom').onclick = () => { cfg.wheel = 'zoom'; saveCfg(); refreshCfgUI(); };
$('set-wheel-zoom').onclick = () => { cfg.wheel = 'zoom'; saveCfg(); refreshCfgUI(); };
$('set-wheel-hotbar').onclick = () => { cfg.wheel = 'hotbar'; saveCfg(); refreshCfgUI(); };
$('set-wheel-hotbar').onclick = () => { cfg.wheel = 'hotbar'; saveCfg(); refreshCfgUI(); };
$('set-sens').oninput = (e) => { cfg.sens = +e.target.value; saveCfg(); };
$('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-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-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();
camera.fov = cfg.fov; camera.updateProjectionMatrix();
refreshCfgUI();
refreshCfgUI();
$('btn-close-settings').onclick = () => { $('settings').classList.add('hidden'); uiFocus('settings', false); };
$('btn-close-settings').onclick = () => { $('settings').classList.add('hidden'); uiFocus('settings', false); };
@@ -973,6 +1002,13 @@ function start(token, user) {
@@ -973,6 +1002,13 @@ function start(token, user) {
case 'dropitem':
case 'dropitem':
net.send({ t: 'drop', op: 'take', id: nearest.data.drop });
net.send({ t: 'drop', op: 'take', id: nearest.data.drop });
return;
return;
+ case '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':
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);
@@ -1264,6 +1300,9 @@ function start(token, user) {
@@ -1264,6 +1300,9 @@ function start(token, user) {
e.mesh.position.y = e.baseY + Math.sin(e.t * 2.2) * .06;
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();
scanInteractables();
mg.update(dt);
mg.update(dt);
@@ -1277,7 +1316,7 @@ function start(token, user) {
@@ -1277,7 +1316,7 @@ function start(token, user) {
// debug/testing handle
// debug/testing handle
window.__brs = {
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,
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,
drops: worldDrops, dropItem: dropItemInWorld,
@@ -8,6 +8,7 @@ import * as CANNON from 'cannon-es';
@@ -8,6 +8,7 @@ import * as CANNON from 'cannon-es';
import { W } from './world.js';
import { W } from './world.js';
import { net } from './net.js';
import { net } from './net.js';
import { ct } from './textures.js';
import { ct } from './textures.js';
+import { enableShadows } from './lighting.js';
const boxTex = () => ct(128, 128, (g, w, h) => {
const boxTex = () => ct(128, 128, (g, w, h) => {
g.fillStyle = '#b98d5f'; g.fillRect(0, 0, w, h);
g.fillStyle = '#b98d5f'; g.fillRect(0, 0, w, h);
@@ -129,7 +130,7 @@ export function initPhysics(scene) {
@@ -129,7 +130,7 @@ export function initPhysics(scene) {
}
}
// cars are full rigid bodies: they roll, flip, get shoved, and can be
// cars are full rigid bodies: they roll, flip, get shoved, and can be
// physgunned around while nobody's driving — proper gmod energy
// 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');
const carMat = new CANNON.Material('car');
// EXACTLY zero chassis-ground friction: the friction solver zeroes the
// EXACTLY zero chassis-ground friction: the friction solver zeroes the
// tangential velocity of a heavy resting box under ANY nonzero μ (verified
// tangential velocity of a heavy resting box under ANY nonzero μ (verified
@@ -140,11 +141,15 @@ export function initPhysics(scene) {
@@ -140,11 +141,15 @@ export function initPhysics(scene) {
let myId = null;
let myId = null;
const cars = new Map(); // id -> {id, car, body, isCar, owned, grabbedBy, remote, rp, rq, rv, remoteT}
const cars = new Map(); // id -> {id, car, body, isCar, owned, grabbedBy, remote, rp, rq, rv, remoteT}
for (const car of W.cars || []) {
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({
const body = new CANNON.Body({
mass: 180, material: carMat,
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,
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.position.set(car.x, CAR_HH + .05, car.z);
body.quaternion.setFromEuler(0, car.ry, 0);
body.quaternion.setFromEuler(0, car.ry, 0);
body.sleepSpeedLimit = .35;
body.sleepSpeedLimit = .35;
@@ -242,6 +247,7 @@ export function initPhysics(scene) {
@@ -242,6 +247,7 @@ export function initPhysics(scene) {
world.addBody(body);
world.addBody(body);
const mesh = def.build();
const mesh = def.build();
mesh.position.copy(body.position);
mesh.position.copy(body.position);
+ enableShadows(mesh);
scene.add(mesh);
scene.add(mesh);
const e = { id: prop.id, kind: prop.kind, body, mesh, owned: mine, grabbedBy: null };
const e = { id: prop.id, kind: prop.kind, body, mesh, owned: mine, grabbedBy: null };
props.set(prop.id, e);
props.set(prop.id, e);
@@ -14,6 +14,7 @@ export const W = { // world registry
@@ -14,6 +14,7 @@ export const W = { // world registry
anchors: {}, // named positions/groups for minigames
anchors: {}, // named positions/groups for minigames
dynamic: {}, // tv/clock updaters
dynamic: {}, // tv/clock updaters
camBlockers: [], // meshes the 3rd-person camera must not clip through
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; };
const blocker = (m) => { W.camBlockers.push(m); return m; };
@@ -2659,6 +2660,7 @@ function streets(scene) {
@@ -2659,6 +2660,7 @@ function streets(scene) {
}
}
// 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 });
+ W.nightGlow.push({ m: lampM, day: .25, night: 2.4 });
for (const lz of [-30, 10, 50, 90, 126]) {
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(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(1.6, .08, .12, M.blackMetal, 133, 6.35, lz));
@@ -2683,7 +2685,9 @@ function streets(scene) {
@@ -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
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));
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) {
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); }) }));
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);
mat.rotation.x = -Math.PI / 2; mat.position.set(-1.1, .02, 2.75); h.add(mat);
}
}
@@ -2704,6 +2708,86 @@ function streets(scene) {
@@ -2704,6 +2708,86 @@ function streets(scene) {
scene.add(mbPost);
scene.add(mbPost);
scene.add(box(.5, .3, .32, new THREE.MeshStandardMaterial({ color: 0x2e4d8a, roughness: .5 }), 27.4, 1.2, 172.2));
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 🏠' });
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 —
// a strange humming tool someone dropped on the shoulder of 172nd —
// finders keepers: this is the physgun
// finders keepers: this is the physgun
const pgGlow = new THREE.MeshStandardMaterial({ color: 0x35e0ff, emissive: 0x35e0ff, emissiveIntensity: 1.6, roughness: .3 });
const pgGlow = new THREE.MeshStandardMaterial({ color: 0x35e0ff, emissive: 0x35e0ff, emissiveIntensity: 1.6, roughness: .3 });
@@ -2798,7 +2882,7 @@ export function buildWorld(scene) {
@@ -2798,7 +2882,7 @@ export function buildWorld(scene) {
mats();
mats();
scene.background = new THREE.Color(0xbcd2e2);
scene.background = new THREE.Color(0xbcd2e2);
scene.fog = new THREE.Fog(0xbcd2e2, 90, 260); // far enough to see down the streets
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
// ---- 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
// 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
// wall now faces north into the locker hallway, and the old south window run
@@ -121,6 +121,7 @@
@@ -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>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>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>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-close-settings" class="btn-primary">Back to break</button>
<button id="btn-logout" class="btn-ghost">Badge out (log out)</button>
<button id="btn-logout" class="btn-ghost">Badge out (log out)</button>
</div>
</div>