openvibers/breakroomsimulator Commits
Night interior lights, headlights, flashlight, knockable scenery, arcade car feel, market wall fixes
Changes
@@@ -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);@@@ -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 persistedif (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 = () => {@@@ -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 thisreturn t;}@@@ -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) => {@@@ -121,7 +121,7 @@ export function initPhysics(scene) {// car colliders are skipped here and tracked as kinematic bodies insteadconst 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 carsconst 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; // dragvFwd = 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-upconst axis = up.cross(V(0, 1, 0));@@@ -15,7 +15,15 @@ export const W = { // world registrydynamic: {}, // tv/clock updaterscamBlockers: [], // meshes the 3rd-person camera must not clip throughnightGlow: [], // {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 plateconst 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-framesscene.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 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));-- 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 backfor (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 ----@@@ -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 foreverconst now = Date.now();if (now - (p.lastTag || 0) < 20000) {@@@ -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);
@@ -47,6 +47,7 @@ export const GRIPS = {
@@ -47,6 +47,7 @@ export const GRIPS = {
tapegun: { p: [0, .02, .05], r: [-1.3, .3, 0] },
tapegun: { p: [0, .02, .05], r: [-1.3, .3, 0] },
banana: { p: [0, .04, .05], r: [-1.9, .3, .6] },
banana: { p: [0, .04, .05], r: [-1.9, .3, .6] },
physgun: { p: [0, .03, .06], r: [-1.45, 0, 0] },
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] },
chips: { p: [0, .04, .05], r: [-.5, 0, 0] },
food: { p: [0, .02, .06], r: [-.4, 0, 0] },
food: { p: [0, .02, .06], r: [-.4, 0, 0] },
candy: { p: [0, .03, .05], r: [-.5, 0, 0] },
candy: { p: [0, .03, .05], r: [-.5, 0, 0] },
@@ -97,6 +98,14 @@ export function buildHeldMesh(item) {
@@ -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 }));
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;
b.rotation.z = .6;
g.add(b);
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') {
} else if (item === 'physgun') {
const glowM = new THREE.MeshStandardMaterial({ color: 0x35e0ff, emissive: 0x35e0ff, emissiveIntensity: 1.5, roughness: .3 });
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);
const bodyM = new THREE.Mesh(new THREE.BoxGeometry(.09, .1, .34), dark);
@@ -19,6 +19,7 @@ export const ITEMS = {
@@ -19,6 +19,7 @@ export const ITEMS = {
wrench: { name: 'Wrench', icon: '🔧', type: 'melee' },
wrench: { name: 'Wrench', icon: '🔧', type: 'melee' },
banana: { name: 'Banana', icon: '🍌', type: 'melee' },
banana: { name: 'Banana', icon: '🍌', type: 'melee' },
physgun: { name: 'Physgun', icon: '🧲', type: 'tool' },
physgun: { name: 'Physgun', icon: '🧲', type: 'tool' },
+ flashlight: { name: 'Flashlight', icon: '🔦', type: 'tool' },
'hat-cap': { name: 'Cap', icon: '🧢', type: 'clothes', ap: { hat: 1 } },
'hat-cap': { name: 'Cap', icon: '🧢', type: 'clothes', ap: { hat: 1 } },
'hat-beanie': { name: 'Beanie', icon: '👒', type: 'clothes', ap: { hat: 2 } },
'hat-beanie': { name: 'Beanie', icon: '👒', type: 'clothes', ap: { hat: 2 } },
'hat-hardhat': { name: 'Hard hat', icon: '⛑️', type: 'clothes', ap: { hat: 3 } },
'hat-hardhat': { name: 'Hard hat', icon: '⛑️', type: 'clothes', ap: { hat: 3 } },
@@ -37,7 +38,10 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
@@ -37,7 +38,10 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
// load persisted
// 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.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 (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;
let saveT = null;
const save = () => {
const save = () => {
@@ -40,6 +40,18 @@ export function initLighting(scene, renderer, shadows = true) {
@@ -40,6 +40,18 @@ export function initLighting(scene, renderer, shadows = true) {
sun.shadow.bias = -.0006;
sun.shadow.bias = -.0006;
sun.shadow.normalBias = .35;
sun.shadow.normalBias = .35;
scene.add(hemi, amb, sun, sun.target, moon, moon.target);
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
enableShadows(scene); // everything built so far
// stars, on a huge dome, visible only at night
// stars, on a huge dome, visible only at night
@@ -98,6 +110,10 @@ export function initLighting(scene, renderer, shadows = true) {
@@ -98,6 +110,10 @@ export function initLighting(scene, renderer, shadows = true) {
for (const g of W.nightGlow || []) {
for (const g of W.nightGlow || []) {
g.m.emissiveIntensity = g.day + (g.night - g.day) * (1 - d);
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;
return t;
}
}
@@ -217,6 +217,11 @@ function start(token, user) {
@@ -217,6 +217,11 @@ function start(token, user) {
viewmodel.rotation.set(-.2, -.35, .15);
viewmodel.rotation.set(-.2, -.35, .15);
camera.add(viewmodel);
camera.add(viewmodel);
scene.add(camera);
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;
let vmSwing = 0;
// per-item viewmodel pose so props read correctly in first person (Bug10)
// per-item viewmodel pose so props read correctly in first person (Bug10)
const VM_POSE = {
const VM_POSE = {
@@ -336,6 +341,7 @@ function start(token, user) {
@@ -336,6 +341,7 @@ function start(token, user) {
const r = invApi.useSelected();
const r = invApi.useSelected();
if (!r) return;
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 === '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.melee) { doSwing(); return; }
if (r.def) {
if (r.def) {
toast(`😋 ${r.def.icon} ${r.def.name} — delicious.`);
toast(`😋 ${r.def.icon} ${r.def.name} — delicious.`);
@@ -428,14 +434,16 @@ function start(token, user) {
@@ -428,14 +434,16 @@ function start(token, user) {
g.fillStyle = '#101418'; g.fillRect(0, 0, w, h);
g.fillStyle = '#101418'; g.fillRect(0, 0, w, h);
g.strokeStyle = '#2c343e'; g.lineWidth = 8; g.strokeRect(4, 4, w - 8, h - 8);
g.strokeStyle = '#2c343e'; g.lineWidth = 8; g.strokeRect(4, 4, w - 8, h - 8);
g.textAlign = 'center';
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.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.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 sg = new THREE.Group();
const post = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, 2.0, 8), new THREE.MeshStandardMaterial({ color: 0x2a2f36, roughness: .6 }));
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) {
@@ -646,6 +654,23 @@ function start(token, user) {
}
}
net.on('tag', (m) => { if (m.op === 'add') addMark(m.tag); });
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');
const tagForm = $('tag-form'), tagInput = $('tag-input');
let tagOpen = false;
let tagOpen = false;
function openTag() {
function openTag() {
@@ -1300,6 +1325,65 @@ function start(token, user) {
@@ -1300,6 +1325,65 @@ 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;
}
}
+ // 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);
daylight.update(my.x, my.z);
W.dynamic.fires?.forEach(f => f.update(dt));
W.dynamic.fires?.forEach(f => f.update(dt));
@@ -1319,7 +1403,7 @@ function start(token, user) {
@@ -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); },
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, knockIt,
};
};
addEventListener('wheel', (e) => {
addEventListener('wheel', (e) => {
@@ -121,7 +121,7 @@ export function initPhysics(scene) {
@@ -121,7 +121,7 @@ export function initPhysics(scene) {
// car colliders are skipped here and tracked as kinematic bodies instead
// car colliders are skipped here and tracked as kinematic bodies instead
const carCols = new Set((W.cars || []).map(c => c.col));
const carCols = new Set((W.cars || []).map(c => c.col));
for (const c of W.colliders) {
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;
const hw = (c.x1 - c.x0) / 2, hd = (c.z1 - c.z0) / 2;
if (hw <= 0 || hd <= 0) continue;
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)) });
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) {
@@ -182,14 +182,22 @@ export function initPhysics(scene) {
else if (throttle < 0) vFwd += (vFwd > .3 ? 14 : 5) * throttle * dt;
else if (throttle < 0) vFwd += (vFwd > .3 ? 14 : 5) * throttle * dt;
vFwd -= vFwd * (handbrake ? 2.2 : .45) * dt; // drag
vFwd -= vFwd * (handbrake ? 2.2 : .45) * dt; // drag
vFwd = Math.max(-6, Math.min(top, vFwd));
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(
b.velocity.set(
fwd.x * vFwd + right.x * vLat,
fwd.x * vFwd + right.x * vLat,
b.velocity.y,
b.velocity.y,
fwd.z * vFwd + right.z * vLat,
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
if (up.y < .95) { // self-righting: torque toward world-up
const axis = up.cross(V(0, 1, 0));
const axis = up.cross(V(0, 1, 0));
@@ -15,7 +15,15 @@ export const W = { // world registry
@@ -15,7 +15,15 @@ export const W = { // world registry
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
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 blocker = (m) => { W.camBlockers.push(m); return m; };
const M = {}; // shared materials
const M = {}; // shared materials
@@ -51,7 +59,11 @@ const cyl = (rt, rb, h, mat, x = 0, y = 0, z = 0, seg = 14) => {
@@ -51,7 +59,11 @@ const cyl = (rt, rb, h, mat, x = 0, y = 0, z = 0, seg = 14) => {
m.position.set(x, y, z);
m.position.set(x, y, z);
return m;
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);
const inter = (o) => W.interactables.push(o);
// ============================================================ ROOM SHELL
// ============================================================ ROOM SHELL
@@ -156,14 +168,16 @@ function shell(scene) {
@@ -156,14 +168,16 @@ function shell(scene) {
collide(38.1, -9.75, .6, 14.5); collide(38.1, 10.6, .6, 12.8);
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)
// 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)));
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);
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
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();
const lg = new THREE.Group();
lg.add(leaf);
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);
scene.add(lg);
}
}
for (const [i, f] of ['fiji', 'puertorico', 'kyrgyzstan', 'spain'].entries()) {
for (const [i, f] of ['fiji', 'puertorico', 'kyrgyzstan', 'spain'].entries()) {
@@ -1848,11 +1862,13 @@ function cooler(scene, x, z, kind) {
@@ -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 } });
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) {
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 }));
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);
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) {
function kiosk(scene, x, z, ry) {
const g = new THREE.Group();
const g = new THREE.Group();
@@ -1979,7 +1995,9 @@ function decor(scene) {
@@ -1979,7 +1995,9 @@ function decor(scene) {
}
}
// red LED clocks (from trips)
// red LED clocks (from trips)
W.dynamic.clocks = [];
W.dynamic.clocks = [];
- for (const s of [{ x: -25, y: 3.9, z: -16.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();
const ck = TX.makeClock();
ck.tex.anisotropy = 8; // the LED digits shimmered into moiré at oblique angles (Bug40)
ck.tex.anisotropy = 8; // the LED digits shimmered into moiré at oblique angles (Bug40)
const m = new THREE.Mesh(new THREE.PlaneGeometry(.85, .32), new THREE.MeshBasicMaterial({ map: ck.tex }));
const m = new THREE.Mesh(new THREE.PlaneGeometry(.85, .32), new THREE.MeshBasicMaterial({ map: ck.tex }));
@@ -2327,8 +2345,12 @@ function exteriorEast(scene) {
@@ -2327,8 +2345,12 @@ function exteriorEast(scene) {
t.add(fol);
t.add(fol);
t.position.set(bx2, 0, bz);
t.position.set(bx2, 0, bz);
scene.add(t);
scene.add(t);
+ knockable(t, bx2, bz, .7);
} else if (i % 3 === 1) {
} 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 {
} else {
for (let gj = 0; gj < 3; gj++) {
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 }));
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) {
@@ -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
// 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));
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) {
@@ -2651,21 +2674,35 @@ function streets(scene) {
g.closePath(); g.fill();
g.closePath(); g.fill();
});
});
for (const [sx, sz] of [[130.5, 5.5], [130.5, 75.5]]) {
for (const [sx, sz] of [[130.5, 5.5], [130.5, 75.5]]) {
- scene.add(cyl(.04, .05, 2.6, M.chrome, sx, 1.3, sz, 8));
+ 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 }));
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 }));
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
// 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 });
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(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 ----
// ---- Longhouse Trail houses ----
const house = (hx, hz, ry, cBody, cRoof, isHome) => {
const house = (hx, hz, ry, cBody, cRoof, isHome) => {
@@ -2819,13 +2856,18 @@ function streets(scene) {
@@ -2819,13 +2856,18 @@ function streets(scene) {
scene.add(pgG);
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' } });
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]]) {
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 }));
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 ----
// ---- the secret: a rusty container behind the smoke cage, something golden inside ----
@@ -436,6 +436,12 @@ function handle(p, m) {
@@ -436,6 +436,12 @@ function handle(p, m) {
}
}
break;
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
case 'tag': { // permanent marker on the floor — your mark stays forever
const now = Date.now();
const now = Date.now();
if (now - (p.lastTag || 0) < 20000) {
if (now - (p.lastTag || 0) < 20000) {
@@ -125,7 +125,7 @@ export function setAppearance(key, ap) {
@@ -125,7 +125,7 @@ export function setAppearance(key, ap) {
}
}
export const ITEM_IDS = ['chips','soda','candy','coffee','energy','water','food',
export const ITEM_IDS = ['chips','soda','candy','coffee','energy','water','food',
- 'paddle','broom','tapegun','tube','wrench','banana','physgun',
+ 'paddle','broom','tapegun','tube','wrench','banana','physgun','flashlight',
'hat-cap','hat-beanie','hat-hardhat','vest-yellow','vest-orange','vest-green','vest-blue','vest-pink'];
'hat-cap','hat-beanie','hat-hardhat','vest-yellow','vest-orange','vest-green','vest-blue','vest-pink'];
export function setInventory(key, inv, hotbar) {
export function setInventory(key, inv, hotbar) {
const u = users[key] || guests.get(key);
const u = users[key] || guests.get(key);