openvibers/breakroomsimulator Commits
Cars you can craft & share, passengers, death screen, campfires, gmod physgun grip, solid props & walkable ramps, brighter nights
Changes
@@@ -180,3 +180,20 @@ body.touch #hp-wrap { bottom: calc(174px + env(safe-area-inset-bottom)); }#friends-list .fr { background: rgba(53,224,255,.1); border: 1px solid rgba(53,224,255,.4); color: #9fe8ff; border-radius: 999px; padding: 3px 10px; font-size: .82rem; cursor: pointer; }#friends-list .fr:hover { background: rgba(226,38,45,.2); border-color: #e2262d; }#friend-name { background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.15); color: #dfe7f0; border-radius: 8px; padding: 6px 10px; font-family: inherit; }++++/* ---------- death screen ---------- */++#death-screen { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center;++ background: radial-gradient(ellipse at center, rgba(30,4,6,.55) 30%, rgba(12,2,3,.94) 100%);++ opacity: 0; transition: opacity .6s ease; cursor: pointer; }++#death-screen.show { opacity: 1; }++#ds-inner { text-align: center; max-width: 440px; padding: 24px; transform: scale(.94); transition: transform .6s ease; }++#death-screen.show #ds-inner { transform: scale(1); }++#death-screen h1 { font-size: 64px; letter-spacing: .28em; margin: 0 0 14px; color: #e2262d;++ text-shadow: 0 0 26px rgba(226,38,45,.65), 0 2px 0 #5c0a0e; font-weight: 900; }++#ds-sub { color: #f2d9d9; font-size: 17px; margin-bottom: 18px; }++#ds-sub b { color: #ffb2ac; }++#ds-stats { display: flex; flex-direction: column; gap: 6px; margin: 0 auto 18px; max-width: 330px; }++.ds-row { display: flex; justify-content: space-between; background: rgba(255,255,255,.05);++ border: 1px solid rgba(226,38,45,.25); border-radius: 8px; padding: 7px 12px; font-size: 14px; color: #d8c2c2; }++.ds-row b { color: #ffd9d5; }++#ds-hint { color: #8a6b6b; font-size: 12.5px; font-style: italic; }@@@ -36,6 +36,8 @@ export const ITEMS = {barrel: { name: 'Barrel', icon: '🛢️', type: 'prop' },melon: { name: 'Watermelon', icon: '🍉', type: 'prop' },cone: { name: 'Traffic cone', icon: '🚧', type: 'prop' },++ campfire: { name: 'Campfire', icon: '🔥', type: 'prop' },++ carkit: { name: 'Car kit', icon: '🚗', type: 'car' },'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 } },@@@ -78,11 +78,11 @@ export function initLighting(scene, renderer, shadows = true) {const skyDay = new THREE.Color(0xbcd2e2);const skyDusk = new THREE.Color(0xe89a5e);-- const skyNight = new THREE.Color(0x0b1322);++ const skyNight = new THREE.Color(0x151f33);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 hemiDay = new THREE.Color(0xf2f5f8), hemiNight = new THREE.Color(0x48597c);++ const gndDay = new THREE.Color(0x54575c), gndNight = new THREE.Color(0x222a36);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); };@@@ -127,15 +127,15 @@ export function initLighting(scene, renderer, shadows = true) {sun.position.set(qx + Math.sin(az) * 80, Math.max(4, elev * 90), qz + Math.cos(az) * 40);sun.target.position.set(qx, 0, qz);-- moon.intensity = .22 * (1 - d);++ moon.intensity = .55 * (1 - d); // bright "movie moonlight" so night stays readablemoon.position.set(px - Math.sin(az) * 70, Math.max(10, -elev * 80), pz - Math.cos(az) * 35);moon.target.position.set(px, 0, pz);const dl = Math.max(d, indoorK * .82); // indoor light floor: fluorescents-- hemi.intensity = .3 + .75 * dl;++ hemi.intensity = .48 + .57 * dl;hemi.color.copy(hemiNight).lerp(hemiDay, dl);hemi.groundColor.copy(gndNight).lerp(gndDay, dl);-- amb.intensity = .08 + .16 * dl;++ amb.intensity = .16 + .1 * dl;stars.material.opacity = (1 - d) * .95;@@@ -1,6 +1,6 @@// Amazon Break Room Simulator — client bootstrap & game loop.import * as THREE from 'three';--import { buildWorld, resolveCollisions, W } from './world.js';++import { buildWorld, resolveCollisions, addWorldCar, W } from './world.js';import { makeAvatar, buildHeldMesh, SKINS, SHIRTS, HAIRS, HATS } from './avatar.js';import { initInventory, ITEMS } from './inventory.js';import { input, initInput, keyMove, uiFocus } from './input.js';@@@ -118,18 +118,51 @@ function start(token, user) {}// ---------- drivable cars (rigid-body vehicles — they roll and flip) ------------ const carState = { driving: null }; // driving = W.cars entry++ const carState = { driving: null, riding: null }; // driving = W.cars entry · riding = {car, seat}let carSendT = 0;++ // passenger seat offsets in car-local space (forward = +z): shotgun + two rear++ const PAX_OFFS = [{ x: .62, z: .5 }, { x: -.55, z: -.95 }, { x: .55, z: -.95 }];++ const myCarIds = new Set(); // personal cars I own (server-confirmed)++ function carUsable(car) { return !car.ownerName || myCarIds.has(car.id) || car.ownerName === me.name; }function enterCar(car) {-- if (car.driver && car.driver !== myId) { toast('🚗 Someone is already driving that one.'); return; }++ if (car.driver && car.driver !== myId) { enterPassenger(car); return; }++ // optimistic: take the wheel now; an owner-locked car comes back as 'deny'++ prepDrive(car);++ net.send({ t: 'car', id: car.id, op: 'enter' });++ if (carUsable(car)) toast('🚗 W/S throttle · A/D steer · Space handbrake · E to get out');++ }++ function prepDrive(car) {const e = phys.cars.get(car.id);if (pgState.held === e) physgunRelease(false);carState.driving = car;car.col.off = true;car.driver = myId;phys.claim(e);-- net.send({ t: 'car', id: car.id, op: 'enter' });-- toast('🚗 W/S throttle · A/D steer · Space handbrake · E to get out');++ }++ function enterPassenger(car) {++ car.pax = car.pax || {};++ let seat = -1;++ for (let s = 0; s < 3; s++) if (car.pax[s] == null) { seat = s; break; }++ if (seat === -1) { toast('🚗 That car is full.'); return; }++ net.send({ t: 'car', id: car.id, op: 'sit', seat }); // server confirms via broadcast++ }++ function ridePose() { // glue me to my passenger seat, in car-local space++ const { car, seat } = carState.riding;++ const e = phys.cars.get(car.id);++ if (!e) { carState.riding = null; return; }++ const off = PAX_OFFS[seat] || PAX_OFFS[0];++ const fx = Math.sin(car.ry), fz = Math.cos(car.ry);++ my.x = car.x + fx * off.z + fz * off.x;++ my.z = car.z + fz * off.z - fx * off.x;++ my.y = Math.max(0, e.body.position.y - .15);++ my.ry = car.ry;++ }++ function exitPassengerLocal(car) {++ carState.riding = null;++ my.x = car.x + Math.cos(car.ry) * 1.7;++ my.z = car.z - Math.sin(car.ry) * 1.7;++ my.y = 0; my.vy = 0; my.vel.x = 0; my.vel.z = 0;++ sendPos(true);}function exitCar() {const car = carState.driving;@@@ -147,7 +180,48 @@ function start(token, user) {net.send({ t: 'car', id: car.id, op: 'exit' });sendPos(true);}++ function leaveAnyCar() { // death/desync: get out of whatever we're in, silently++ if (carState.driving) {++ const car = carState.driving;++ carState.driving = null;++ car.col.off = false;++ car.driver = null;++ const e = phys.cars.get(car.id);++ if (e) e.owned = false;++ net.send({ t: 'car', id: car.id, op: 'exit' });++ }++ if (carState.riding) {++ const { car } = carState.riding;++ carState.riding = null;++ net.send({ t: 'car', id: car.id, op: 'unsit' });++ }++ }++ // a crafted car arriving from the server (mine or someone else's)++ function registerCar(id, c, snap = true) {++ const car = addWorldCar({++ id, color: c.color || 0x8a8f96,++ x: Array.isArray(c.p) ? c.p[0] : 0, z: Array.isArray(c.p) ? c.p[2] : 0, ry: 0,++ ownerName: c.ownerName || null,++ });++ car.pax = c.pax || {};++ phys.addCar(car);++ if (snap && Array.isArray(c.p)) phys.applyCarState({ id, p: c.p, q: c.q || [0, 0, 0, 1], v: null }, true);++ return car;++ }net.on('car', (m) => {++ if (m.op === 'spawn') {++ if (!W.cars?.some(c => c.id === m.id)) {++ const car = registerCar(m.id, m.car);++ if (m.owner === myId) {++ myCarIds.add(m.id);++ car.inter.label = 'Your car 🚗 — E to drive';++ toast('🔧 Your car! Only you (and your friends) can drive it.', 5200);++ beep(740, .12, 'sine', .12);++ }++ }++ return;++ }++ if (m.op === 'spawnfail') { invApi.add('carkit'); return; }const car = W.cars?.find(c => c.id === m.id);const e = phys.cars.get(m.id);if (!car || !e) return;@@@ -160,6 +234,30 @@ function start(token, user) {car.driver = null;car.col.off = false;if (Array.isArray(m.p)) phys.applyCarState({ id: m.id, p: m.p, q: m.q, v: null }, true);++ } else if (m.op === 'deny') { // server bounced us out (owner-locked car)++ if (carState.driving === car) {++ carState.driving = null;++ car.col.off = false;++ car.driver = null;++ e.owned = false;++ my.x = car.x + Math.cos(car.ry) * 1.7;++ my.z = car.z - Math.sin(car.ry) * 1.7;++ my.y = 0; my.vy = 0;++ sendPos(true);++ enterPassenger(car); // can't drive it, but riding shotgun is allowed++ }++ if (pgState.held === e) { physgunRelease(false); pgState.lmb = false; }++ } else if (m.op === 'sit') {++ car.pax = car.pax || {};++ car.pax[m.seat] = m.by;++ if (m.by === myId) {++ carState.riding = { car, seat: m.seat };++ my.seat = null;++ toast('🚌 Riding along — E to hop out');++ }++ } else if (m.op === 'unsit') {++ if (car.pax) delete car.pax[m.seat];++ if (m.by === myId && carState.riding?.car === car) exitPassengerLocal(car);} else if (m.op === 'state') {if (m.driver === myId) return;car.driver = m.driver;@@@ -246,13 +344,22 @@ function start(token, user) {torch.position.set(.25, -.2, .1);torch.target.position.set(0, -.4, -12);camera.add(torch, torch.target);-- // headlight pool (created up-front — stable light count, no shader hitches)++ // headlight pool (created up-front — stable light count, no shader hitches).++ // Wide cone + long throw so night driving actually lights the road.const hlPool = [];for (let i = 0; i < 2; i++) {-- const sp = new THREE.SpotLight(0xfff2cf, 0, 36, .52, .5, 1.1);++ const sp = new THREE.SpotLight(0xfff2cf, 0, 60, .78, .55, .9);scene.add(sp, sp.target);hlPool.push(sp);}++ // campfire light pool: the nearest few campfires share these flickering points++ const cfPool = [];++ for (let i = 0; i < 4; i++) {++ const pl = new THREE.PointLight(0xff9a3a, 0, 16, 1.3);++ scene.add(pl);++ cfPool.push(pl);++ (W.extraLights = W.extraLights || []).push(pl); // the graphics toggle covers them++ }let vmSwing = 0;// per-item viewmodel pose so props read correctly in first person (Bug10)const VM_POSE = {@@@ -524,11 +631,16 @@ function start(token, user) {phys.setMyId(m.id);my.x = m.you.x; my.y = m.you.y; my.z = m.you.z; my.ry = m.you.ry;mg.applyInit(m);++ for (const cid of m.myCarIds || []) myCarIds.add(cid);for (const [cid, c] of Object.entries(m.cars || {})) {-- const car = W.cars?.find(k => k.id === cid);++ let car = W.cars?.find(k => k.id === cid);++ if (!car && cid.startsWith('pc')) car = registerCar(cid, c, false); // crafted cars aren't in the base mapif (!car) continue;if (Array.isArray(c.p)) phys.applyCarState({ id: cid, p: c.p, q: c.q || [0, 0, 0, 1], v: null }, true);car.driver = c.driver;++ car.pax = c.pax || {};++ car.ownerName = c.ownerName || car.ownerName || null;++ if (myCarIds.has(cid)) car.inter.label = 'Your car 🚗 — E to drive';if (c.driver) car.col.off = true;}me.admin = !!m.admin;@@@ -677,8 +789,42 @@ function start(token, user) {others.get(m.id)?.avatar.flash?.(); // victims glow red so everyone can tellif (m.by === myId) hitmarker(m.hs === true);});++ // the big red YOU DIED card: who, with what, from how far, how long you lasted++ const WEAPON_LABELS = {++ fists: '👊 bare fists', paddle: '🏓 a ping pong paddle', broom: '🧹 a broom', tapegun: '📼 a tape gun',++ tube: '📦 a cardboard tube', wrench: '🔧 a wrench', banana: '🍌 a banana (embarrassing)',++ axe: '🪓 an axe', pickaxe: '⛏️ a pickaxe', stoneaxe: '🪓 a stone axe', stonepick: '⛏️ a stone pickaxe',++ pistol: '🔫 a pistol', zombie: '🧟 zombie teeth',++ };++ const fmtLived = (s) => s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor(s % 3600 / 60)}m` : s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;++ function showDeathScreen(m) {++ const el = $('death-screen');++ $('ds-sub').innerHTML = m.hs++ ? `<b>${esc(m.by)}</b> headshotted you with ${WEAPON_LABELS[m.weapon] || 'something'} 🎯`++ : `taken out by <b>${esc(m.by)}</b>${m.weapon ? ` with ${WEAPON_LABELS[m.weapon] || 'something'}` : ''}`;++ const rows = [];++ if (Number.isFinite(m.dist)) rows.push(['📏 from', `${m.dist.toFixed(1)} m`]);++ if (Number.isFinite(m.lived)) rows.push(['⏱️ you survived', fmtLived(m.lived)]);++ rows.push(['💀 kills that life', String(m.kills || 0)]);++ rows.push(['🎒 your stuff', 'scattered where you fell']);++ $('ds-stats').innerHTML = rows.map(([k, v]) => `<div class="ds-row"><span>${k}</span><b>${v}</b></div>`).join('');++ el.classList.remove('hidden');++ requestAnimationFrame(() => el.classList.add('show'));++ clearTimeout(showDeathScreen._t);++ showDeathScreen._t = setTimeout(hideDeathScreen, 7000);++ el.onclick = hideDeathScreen;++ }++ function hideDeathScreen() {++ const el = $('death-screen');++ el.classList.remove('show');++ setTimeout(() => el.classList.add('hidden'), 600);++ }net.on('died', (m) => {setHp(100, false);++ leaveAnyCar(); // dying at the wheel used to respawn you "inside" the car++ if (pgState.held) physgunRelease(false);++ my.seat = null;++ myAvatar.seatId = null;my.x = m.x; my.z = m.z; my.y = 0; my.vy = 0;my.vel.x = 0; my.vel.z = 0;my.held = null;@@@ -688,7 +834,7 @@ function start(token, user) {updateViewmodel();dmgFlash.style.opacity = .9;setTimeout(() => { dmgFlash.style.opacity = 0; }, 700);-- toast(`💀 ${m.by} got you. Your stuff is scattered where you fell.`, 5600);++ showDeathScreen(m);beep(90, .5, 'sawtooth', .18);sendPos(true);});@@@ -1058,6 +1204,15 @@ function start(token, user) {spawnProp(kind);toast(`${ITEMS[kind].icon} placed`);}++ function placeCar() { // carkit: your own ride, owner-locked at the server++ if (my.held !== 'carkit') return;++ const mine = W.cars?.filter(c => myCarIds.has(c.id)).length || 0;++ if (mine >= 3) { toast('🚗 Three personal cars is plenty.'); return; }++ const used = invApi.dropSelected();++ if (used !== 'carkit') return;++ const fx = -Math.sin(my.yaw), fz = -Math.cos(my.yaw);++ net.send({ t: 'car', op: 'spawn', x: +(my.x + fx * 4.2).toFixed(2), z: +(my.z + fz * 4.2).toFixed(2), ry: +my.yaw.toFixed(2) });++ }// ---------- crafting (C) — rust-lite ----------const RECIPES = [@@@ -1075,6 +1230,8 @@ function start(token, user) {{ id: 'barrel', cost: { stone: 6 }, desc: 'Physics prop' },{ id: 'melon', cost: { wood: 3 }, desc: 'Physics prop, organic' },{ id: 'cone', cost: { stone: 3 }, desc: 'Physics prop, official' },++ { id: 'campfire', cost: { wood: 10, stone: 6 }, desc: 'Cozy light for the long nights' },++ { id: 'carkit', cost: { wood: 90, stone: 60 }, desc: 'Your own car — only you & friends can drive it' },];const craftEl = $('craft-menu'), craftList = $('craft-list'), craftHave = $('craft-have');let craftOpen = false;@@@ -1287,8 +1444,10 @@ function start(token, user) {if (fp) return camera.localToWorld(muzzleV.set(.31, -.26, -.75)); // viewmodel gun tipreturn myAvatar.heldAnchor.getWorldPosition(muzzleV);}-- const pgState = { held: null, dist: 0, lmb: false, missT: 0 };++ const pgState = { held: null, dist: 0, lmb: false, missT: 0, relQ: null };const pgRay = new THREE.Raycaster();++ const pgQTmp = new THREE.Quaternion(), pgQTmp2 = new THREE.Quaternion();++ const PG_X = new THREE.Vector3(1, 0, 0), PG_Y = new THREE.Vector3(0, 1, 0);function pgEquipped() { return my.held === 'physgun'; }function pgPick() {// fan of rays around the crosshair so edge clicks still land@@@ -1306,10 +1465,14 @@ function start(token, user) {if (!hit) return;if (hit.e.frozen) phys.setFrozen(hit.e, false); // thaw to move (server re-freezes on veto)pgState.held = hit.e;-- pgState.lastYaw = my.yaw; // view-follow rotation baseline-- // gmod grip: remember WHERE on the object you grabbed it++ pgState.lastYaw = my.yaw; // view-follow rotation baseline (cars: yaw-only)++ // gmod grip: remember WHERE on the object you grabbed it, and its++ // orientation RELATIVE TO YOUR VIEW — it stays locked in your view framepgState.anchor = phys.grabLocal(hit.e, hit.point.x, hit.point.y, hit.point.z);pgState.anchorOut = new hit.e.body.position.constructor();++ const bq = hit.e.body.quaternion;++ pgState.relQ = camera.quaternion.clone().invert()++ .multiply(new THREE.Quaternion(bq.x, bq.y, bq.z, bq.w));pgState.dist = Math.max(1.6, Math.min(hit.e.isCar ? 16 : 12, hit.distance));hit.e.grabbedBy = myId;phys.claim(hit.e);@@@ -1336,6 +1499,7 @@ function start(token, user) {} else if (my.held === 'pistol') firePistol();else if (ITEMS[my.held]?.type === 'build') placeBuild();else if (ITEMS[my.held]?.type === 'prop') placeProp();++ else if (ITEMS[my.held]?.type === 'car') placeCar();});addEventListener('mouseup', (e) => {if (e.button !== 0) return;@@@ -1708,7 +1872,12 @@ function start(token, user) {let label = null;if (my.seat) label = 'Stand up';else if (mg.myPongTable) label = 'Swing! (or walk away to quit)';-- else if (nearest && nearest.type !== 'c4' && nearest.type !== 'chess') label = nearest.label;++ else if (nearest?.type === 'car') { // live label: drive / ride along / locked++ const car = W.cars?.find(c => c.id === nearest.data.car);++ if (car?.driver && car.driver !== myId) label = 'Hop in — ride along 🚌';++ else if (car && car.ownerName && !carUsable(car)) label = `🔒 ${car.ownerName}'s car — E rides shotgun`;++ else label = nearest.label;++ } else if (nearest && nearest.type !== 'c4' && nearest.type !== 'chess') label = nearest.label;else if (nearestSeat) label = nearestSeat.type === 'couch' ? 'Sit on the couch' : 'Take a seat';if (label) { promptEl.classList.remove('hidden'); promptText.textContent = label; }else promptEl.classList.add('hidden');@@@ -1718,6 +1887,7 @@ function start(token, user) {function doAction() {if (mg.inArcade()) return;if (carState.driving) { exitCar(); return; }++ if (carState.riding) { net.send({ t: 'car', id: carState.riding.car.id, op: 'unsit' }); exitPassengerLocal(carState.riding.car); return; }if (my.seat) { standUp(); return; }if (mg.myPongTable) { mg.swing(); return; }if (nearest) {@@@ -1844,12 +2014,14 @@ function start(token, user) {requestAnimationFrame(frame);const dt = Math.min(clock.getDelta(), .05);-- // physgun rotate mode: hold E while carrying — the mouse turns the OBJECT-- if (pgState.held && input.keys.KeyE && !pgState.held.isCar) {++ // physgun rotate mode: hold E while carrying — the mouse turns the OBJECT.++ // Rotations are applied in VIEW space (screen axes), so "mouse right" always++ // spins the prop the same way no matter where you're facing — gmod feel.++ if (pgState.held && input.keys.KeyE && !pgState.held.isCar && pgState.relQ) {const rdx = input.lookDX * .005, rdy = input.lookDY * .005;input.lookDX = 0; input.lookDY = 0; // eaten: camera stays put-- if (rdx) phys.yawBody(pgState.held, -rdx, pgState.anchor);-- if (rdy) phys.rotateBody(pgState.held, Math.cos(my.yaw), 0, -Math.sin(my.yaw), -rdy, pgState.anchor);++ if (rdx) pgState.relQ.premultiply(pgQTmp.setFromAxisAngle(PG_Y, -rdx));++ if (rdy) pgState.relQ.premultiply(pgQTmp.setFromAxisAngle(PG_X, -rdy));}// look (sensitivity + optional inverted Y; FP allows full look up/down)my.lookVX = input.lookDX;@@@ -1871,7 +2043,7 @@ function start(token, user) {}if (input.jumpQueued) {input.jumpQueued = false;-- if (carState.driving) { /* Space is the handbrake while driving */ }++ if (carState.driving || carState.riding) { /* Space is the handbrake while driving */ }else if (mg.myPongTable && !my.seat) mg.swing();else if (my.onGround && !my.seat) { my.vy = my.crouch ? 4.2 : 5.4; my.onGround = false; my.crouch = false; beep(520, .04, 'sine', .05); }else if (my.seat) standUp();@@@ -1898,6 +2070,9 @@ function start(token, user) {v: [+b.velocity.x.toFixed(2), +b.velocity.y.toFixed(2), +b.velocity.z.toFixed(2)],});}++ } else if (carState.riding) {++ ridePose(); // passenger: glued to the seat, E hops out++ my.anim = 'sit';} else if (my.seat) {if (mvLen > .4) standUp();my.anim = 'sit';@@@ -1928,7 +2103,9 @@ function start(token, user) {my.vel.x += wx * add; my.vel.z += wz * add;}let nx = my.x + my.vel.x * dt, nz = my.z + my.vel.z * dt;++ W.feetY = my.y; // surface-probing colliders judge "climbable" from foot height[nx, nz] = resolveCollisions(nx, nz, .34);++ [nx, nz] = phys.resolvePlayer(nx, nz, my.y, .34, pgState.held); // props shove peopleif (dt > 0) { my.vel.x = (nx - my.x) / dt; my.vel.z = (nz - my.z) / dt; } // wall clipmy.x = nx; my.z = nz;const hsp = Math.hypot(my.vel.x, my.vel.z);@@@ -1942,13 +2119,16 @@ function start(token, user) {my.anim = !my.onGround ? (hsp > 5 ? 'run' : 'walk'): my.crouch ? (hsp > .5 ? 'crouchwalk' : 'crouch'): hsp > 5.2 ? 'run' : hsp > .5 ? 'walk' : 'idle';-- // gravity++ // gravity — against the walkable surface under our feet (props, ramps,++ // car roofs), not just the world floor++ const gY = phys.groundAt(my.x, my.z, my.y);++ if (my.onGround && gY > my.y && gY - my.y <= .68) { my.y = gY; my.vy = 0; } // step/ramp upmy.vy -= 16 * dt;my.y += my.vy * dt;-- if (my.y <= 0) {++ if (my.y <= gY + 1e-4 && my.vy <= 0) {if (!my.onGround && my.vy < -5) beep(140, .05, 'sine', .05); // landing thud-- my.y = 0; my.vy = 0; my.onGround = true;-- }++ my.y = gY; my.vy = 0; my.onGround = true;++ } else if (my.y > gY + .03) my.onGround = false;// walked away from pong table?if (mg.myPongTable) {// anchors from the rotated cafeteria group carry world coords in wx/wz@@@ -1974,11 +2154,18 @@ function start(token, user) {const vl = Math.hypot(vx3, vy3, vz3);if (vl > 22) { vx3 *= 22 / vl; vy3 *= 22 / vl; vz3 *= 22 / vl; }b.velocity.set(vx3, vy3, vz3);-- // gmod grip: the object turns with your view and otherwise holds its pose-- const dyaw = my.yaw - (pgState.lastYaw ?? my.yaw);-- pgState.lastYaw = my.yaw;b.angularVelocity.set(0, 0, 0);-- if (!input.keys.KeyE && Math.abs(dyaw) > 1e-4) phys.yawBody(held, dyaw, pgState.anchor); // view-follow (E = manual rotate instead)++ if (held.isCar) {++ // cars: yaw-only view-follow (full view-lock would pitch them absurdly)++ const dyaw = my.yaw - (pgState.lastYaw ?? my.yaw);++ pgState.lastYaw = my.yaw;++ if (Math.abs(dyaw) > 1e-4) phys.yawBody(held, dyaw, pgState.anchor);++ } else if (pgState.relQ) {++ // gmod grip: the prop is rigid in your VIEW frame — turning or pitching++ // your view carries it along, E+mouse edits the relative orientation++ pgQTmp2.copy(camera.quaternion).multiply(pgState.relQ);++ phys.setQuatAnchored(held, pgQTmp2.x, pgQTmp2.y, pgQTmp2.z, pgQTmp2.w, pgState.anchor);++ }}}phys.step(dt, my);@@@ -1989,6 +2176,7 @@ function start(token, user) {my.y = Math.max(0, ce2.body.position.y - .15);my.ry = car.ry;}++ if (carState.riding) ridePose(); // passengers re-sync too (no chop at speed)// avatarmyAvatar.group.position.set(my.x, my.y + (myAvatar.bobY || 0), my.z);@@@ -2181,12 +2369,34 @@ function start(token, user) {if (hl2.parent !== e2.car.group) {e2.car.group.add(hl2, hl2.target);hl2.position.set(0, .85, 2.0);-- hl2.target.position.set(0, .25, 15);++ hl2.target.position.set(0, 0, 24);}-- hl2.intensity = .25 + (1 - (W.dayFactor ?? 1)) * 3.4;++ hl2.intensity = .6 + (1 - (W.dayFactor ?? 1)) * 9;}for (; hi < hlPool.length; hi++) hlPool[hi].intensity = 0;}++ // campfires: hand the pooled lights to the nearest ones, flame flicker++ {++ const fires = [];++ for (const e2 of phys.props.values()) {++ if (e2.kind !== 'campfire') continue;++ const p2 = e2.body.interpolatedPosition;++ fires.push([(p2.x - my.x) ** 2 + (p2.z - my.z) ** 2, e2]);++ }++ if (fires.length > 1) fires.sort((a, b) => a[0] - b[0]);++ const tNow = performance.now() / 1000;++ for (let i2 = 0; i2 < cfPool.length; i2++) {++ const pl = cfPool[i2];++ const fe = fires[i2]?.[1];++ if (!fe) { pl.intensity = 0; continue; }++ const p2 = fe.body.interpolatedPosition;++ pl.position.set(p2.x, p2.y + .6, p2.z);++ const flick = 1 + Math.sin(tNow * 11 + i2 * 2.1) * .1 + Math.sin(tNow * 27.7 + i2) * .06;++ pl.intensity = (.9 + (1 - (W.dayFactor ?? 1)) * 1.6) * flick;++ const fl = fe.mesh.userData?.flame;++ if (fl) { fl.scale.set(1, 1 + Math.sin(tNow * 9.3 + i2) * .16, 1); fl.rotation.y = tNow * 1.7; }++ }++ }// flashlight follows your viewtorch.intensity = my.held === 'flashlight' ? .5 + (1 - (W.dayFactor ?? 1)) * 3.6 : 0;@@@ -2213,6 +2423,8 @@ function start(token, user) {action: doAction, nearest: () => nearest?.id || nearestSeat?.id || null,spawnProp, physgunGrab, physgunRelease, pgState, pgPick, sleepers: sleeperMap, inv: invApi,drops: worldDrops, dropItem: dropItemInWorld, knockIt,++ carState, myCarIds, enterCar, placeCar, doAction, net,++ get myId() { return myId; },};addEventListener('wheel', (e) => {@@@ -124,6 +124,41 @@ export const PHYS_KINDS = {return g;},},++ campfire: {++ label: 'Campfire', icon: '🔥', mass: 6,++ shape: () => new CANNON.Cylinder(.5, .5, .42, 8),++ build() {++ const g = new THREE.Group();++ const logM = new THREE.MeshStandardMaterial({ color: 0x6a4a2c, roughness: .95 });++ for (let i = 0; i < 3; i++) {++ const log = new THREE.Mesh(new THREE.CylinderGeometry(.07, .08, .8, 7), logM);++ log.rotation.z = Math.PI / 2 - .35;++ log.rotation.y = i * Math.PI * 2 / 3;++ log.position.y = -.05;++ g.add(log);++ }++ const stoneM = new THREE.MeshStandardMaterial({ color: 0x7d7f83, roughness: 1 });++ for (let i = 0; i < 7; i++) {++ const a = i / 7 * Math.PI * 2;++ const st = new THREE.Mesh(new THREE.SphereGeometry(.09, 6, 5), stoneM);++ st.position.set(Math.cos(a) * .48, -.14, Math.sin(a) * .48);++ st.scale.y = .7;++ g.add(st);++ }++ const flame = new THREE.Mesh(++ new THREE.ConeGeometry(.2, .55, 7),++ new THREE.MeshBasicMaterial({ color: 0xff8a2a, transparent: true, opacity: .85, blending: THREE.AdditiveBlending, depthWrite: false }));++ flame.position.y = .22;++ const core = new THREE.Mesh(++ new THREE.ConeGeometry(.1, .34, 6),++ new THREE.MeshBasicMaterial({ color: 0xffe08a, transparent: true, opacity: .9, blending: THREE.AdditiveBlending, depthWrite: false }));++ core.position.y = .16;++ g.add(flame, core);++ g.userData.flame = flame;++ g.userData.flameCore = core;++ return g;++ },++ },cone: {label: 'Traffic cone', icon: '🚧', mass: 1.4,shape: () => new CANNON.Cylinder(.05, .2, .55, 8),@@@ -179,7 +214,7 @@ export function initPhysics(scene) {world.addContactMaterial(new CANNON.ContactMaterial(propMat, carMat, { friction: .35, restitution: .3 }));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 || []) {++ function addCar(car) {// 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 believablyconst hl = car.hl ?? (car.hl = 2.05), hw = car.hw ?? (car.hw = .9);@@@ -200,10 +235,76 @@ export function initPhysics(scene) {body.sleepTimeLimit = .6;body.sleep();world.addBody(body);-- cars.set(car.id, { id: car.id, car, body, isCar: true, owned: false, grabbedBy: null, remote: false });++ car.col.hAt = (x, z) => surfaceYAt(body, x, z); // roofs/hoods are climbable terrain++ const e = { id: car.id, car, body, isCar: true, owned: false, grabbedBy: null, remote: false };++ cars.set(car.id, e);++ return e;}++ for (const car of W.cars || []) addCar(car);const V = (x, y, z) => new CANNON.Vec3(x, y, z);++ // ---- walkable surfaces: single-body downward raycast ----++ // Used for standing on props/cars and for climbing rotated pieces (ramps):++ // an AABB alone turns a tilted ramp into an invisible wall.++ const wRay = new CANNON.Ray();++ wRay.mode = CANNON.Ray.CLOSEST;++ wRay.skipBackfaces = true;++ const wRes = new CANNON.RaycastResult();++ function surfaceYAt(body, x, z, fromY = null) {++ body.updateAABB();++ const top = body.aabb.upperBound.y;++ wRay.from.set(x, fromY == null ? top + .6 : fromY, z);++ wRay.to.set(x, body.aabb.lowerBound.y - .3, z);++ wRes.reset();++ wRay.intersectBody(body, wRes);++ return wRes.hasHit ? wRes.hitPointWorld.y : null; // null: (x,z) misses the actual shape++ }++ const STEP = .68; // max walk-up height — one box, not one crate++ // highest walkable surface under (x,z) reachable from foot height py++ function groundAt(x, z, py) {++ let g = 0;++ const consider = (body) => {++ body.updateAABB();++ const bb = body.aabb;++ if (x < bb.lowerBound.x - .05 || x > bb.upperBound.x + .05 ||++ z < bb.lowerBound.z - .05 || z > bb.upperBound.z + .05) return;++ if (bb.lowerBound.y > py + STEP + 1.2) return; // floating way overhead++ const h = surfaceYAt(body, x, z);++ if (h != null && h <= py + STEP && h > g) g = h;++ };++ for (const e of props.values()) consider(e.body);++ for (const e of cars.values()) consider(e.body);++ return g;++ }++ // players are solid: shove the walking capsule out of dynamic props so a++ // physgunned crate PUSHES people instead of clipping through them++ function resolvePlayer(px, pz, py, r, exclude = null) {++ for (const e of props.values()) {++ if (e === exclude || e.frozen) continue;++ const bb = e.body.aabb;++ if (bb.upperBound.y - py <= STEP) continue; // low enough to step on++ if (bb.lowerBound.y > py + 1.6) continue; // floating overhead++ const nx = Math.max(bb.lowerBound.x, Math.min(px, bb.upperBound.x));++ const nz = Math.max(bb.lowerBound.z, Math.min(pz, bb.upperBound.z));++ const dx = px - nx, dz = pz - nz;++ const d2 = dx * dx + dz * dz;++ if (d2 >= r * r) continue;++ if (d2 < 1e-9) { // center inside: push out the nearest face++ const pushes = [++ [bb.lowerBound.x - r - px, 0], [bb.upperBound.x + r - px, 0],++ [0, bb.lowerBound.z - r - pz], [0, bb.upperBound.z + r - pz],++ ];++ pushes.sort((a, b) => Math.abs(a[0] + a[1]) - Math.abs(b[0] + b[1]));++ px += pushes[0][0]; pz += pushes[0][1];++ } else {++ const d = Math.sqrt(d2);++ px = nx + dx / d * r;++ pz = nz + dz / d * r;++ }++ }++ return [px, pz];++ }++// arcade forces on a real chassis: engine + brake along the body's forward// axis, hard-set yaw rate for snappy steering, lateral-grip kill, and a// gentle self-righting torque so a flipped car can be driven back onto@@@ -339,6 +440,9 @@ export function initPhysics(scene) {e.walkCol.z0 = b.aabb.lowerBound.z; e.walkCol.z1 = b.aabb.upperBound.z;// don't block feet on things lying flat on the ground or floating overheade.walkCol.off = b.aabb.upperBound.y < .45 || b.aabb.lowerBound.y > 1.6;++ // rotated pieces (ramps!): block only where the actual surface is too++ // tall to step onto — the resolver probes this instead of the raw AABB++ e.walkCol.hAt = (x, z) => surfaceYAt(b, x, z);if (e.inter) { e.inter.x = b.position.x; e.inter.z = b.position.z; }} else {b.type = CANNON.Body.DYNAMIC;@@@ -381,7 +485,9 @@ export function initPhysics(scene) {function playerPush(px, py, pz, dt) {for (const e of props.values()) {if (e.frozen) continue;++ if (e.grabbedBy && e.grabbedBy !== myId) continue; // don't fight the holder's streamconst b = e.body;++ if (py > b.aabb.upperBound.y - .25) continue; // standing on it, not into itconst dx = b.position.x - px, dz = b.position.z - pz;const d = Math.hypot(dx, dz);const rad = .34 + (e.kind === 'crate' ? .62 : .42);@@@ -536,6 +642,19 @@ export function initPhysics(scene) {b.position.set(aw.x - anchorTmp.x, aw.y - anchorTmp.y, aw.z - anchorTmp.z);} else yawQ.mult(b.quaternion, b.quaternion);}++ // hard-set orientation while keeping the grab anchor pinned in world space —++ // the physgun's view-relative grip drives this every frame++ function setQuatAnchored(e, qx, qy, qz, qw, local = null) {++ const b = e.body;++ if (local) {++ const aw = new CANNON.Vec3();++ b.quaternion.vmult(local, anchorTmp);++ aw.set(b.position.x + anchorTmp.x, b.position.y + anchorTmp.y, b.position.z + anchorTmp.z);++ b.quaternion.set(qx, qy, qz, qw);++ b.quaternion.vmult(local, anchorTmp);++ b.position.set(aw.x - anchorTmp.x, aw.y - anchorTmp.y, aw.z - anchorTmp.z);++ } else b.quaternion.set(qx, qy, qz, qw);++ }function yawBody(e, dyaw, local = null) {const b = e.body;yawQ.setFromAxisAngle(V(0, 1, 0), dyaw);@@@ -564,7 +683,8 @@ export function initPhysics(scene) {return {world, props, cars, add, remove, claim, applyState, applyCarState, sendState, sendCarState,-- drive, step, smack, raycast, yawBody, rotateBody, grabLocal, anchorWorld, addStatic, removeStatic, setFrozen, PHYS_KINDS,++ drive, step, smack, raycast, yawBody, rotateBody, setQuatAnchored, grabLocal, anchorWorld,++ addStatic, removeStatic, setFrozen, addCar, groundAt, resolvePlayer, PHYS_KINDS,setMyId(id) { myId = id; },};}@@@ -2422,31 +2422,13 @@ function exteriorEast(scene) {}stripes.count = si;scene.add(stripes);-- const carCols = [0x8a8f96, 0x2e3f55, 0x7a2c2c, 0xd8d8d8, 0x1a1c1e, 0x4a6b52];W.cars = [];for (let i = 0; i < 10; i++) {-- const g = new THREE.Group();-- const cM = new THREE.MeshStandardMaterial({ color: carCols[i % carCols.length], roughness: .35, metalness: .5 });-- g.add(box(1.8, .55, 4.1, cM, 0, .55, 0));-- g.add(box(1.6, .5, 2.2, new THREE.MeshStandardMaterial({ color: 0x1c2126, roughness: .2, metalness: .3 }), 0, 1.05, -.2));-- // headlights + taillights so driving at speed reads well-- for (const s2 of [-1, 1]) {-- g.add(box(.3, .12, .06, new THREE.MeshStandardMaterial({ color: 0xfff2cf, emissive: 0xfff2cf, emissiveIntensity: .5 }), s2 * .55, .62, 2.05));-- g.add(box(.3, .1, .06, new THREE.MeshStandardMaterial({ color: 0xd93025, emissive: 0x8a1610, emissiveIntensity: .5 }), s2 * .55, .62, -2.05));-- }-- for (const [wx, wz] of [[-.85, 1.3], [.85, 1.3], [-.85, -1.3], [.85, -1.3]]) {-- const wh = cyl(.32, .32, .22, M.blackMetal, wx, .32, wz); wh.rotation.z = Math.PI / 2; g.add(wh);-- }const s = i % 2 ? 1 : -1;-- g.position.set(101.4 + (i % 3) * 5.6, 0, s * (9 + Math.floor(i / 2) * 3.1));-- g.rotation.y = Math.PI / 2;-- scene.add(g);-- const col = { x0: g.position.x - 2.1, x1: g.position.x + 2.1, z0: g.position.z - 1, z1: g.position.z + 1, off: false };-- W.colliders.push(col);-- const id = 'car' + i;-- const it = { id, type: 'car', x: g.position.x, z: g.position.z, r: 3.4, label: 'Drive the car 🚗', data: { car: id } };-- inter(it);-- W.cars.push({ id, group: g, col, inter: it, driver: null, x: g.position.x, z: g.position.z, ry: Math.PI / 2 });++ addWorldCar({++ id: 'car' + i, color: CAR_COLORS[i % CAR_COLORS.length],++ x: 101.4 + (i % 3) * 5.6, z: s * (9 + Math.floor(i / 2) * 3.1), ry: Math.PI / 2,++ });}// flag pole + hydrant + food truck (from trips)const pole = cyl(.05, .07, 9, M.chrome, 94.5, 4.5, -10, 8);@@@ -2974,6 +2956,7 @@ function streets(scene) {}export function buildWorld(scene) {++ W.scene = scene; // late spawns (crafted cars) need somewhere to landmats();scene.background = new THREE.Color(0xbcd2e2);scene.fog = new THREE.Fog(0xbcd2e2, 90, 260); // far enough to see down the streets@@@ -3031,10 +3014,50 @@ export function buildWorld(scene) {return W;}++// sedan builder shared by the parking-lot fleet and player-crafted cars.++// Returns the W.cars entry (caller registers the physics body via phys.addCar).++export const CAR_COLORS = [0x8a8f96, 0x2e3f55, 0x7a2c2c, 0xd8d8d8, 0x1a1c1e, 0x4a6b52, 0xc9541e, 0x6a3f8f];++export function addWorldCar({ id, color, x, z, ry, ownerName = null }) {++ const scene = W.scene;++ const g = new THREE.Group();++ const cM = new THREE.MeshStandardMaterial({ color, roughness: .35, metalness: .5 });++ g.add(box(1.8, .55, 4.1, cM, 0, .55, 0));++ g.add(box(1.6, .5, 2.2, new THREE.MeshStandardMaterial({ color: 0x1c2126, roughness: .2, metalness: .3 }), 0, 1.05, -.2));++ // headlights + taillights so driving at speed reads well++ for (const s2 of [-1, 1]) {++ g.add(box(.3, .12, .06, new THREE.MeshStandardMaterial({ color: 0xfff2cf, emissive: 0xfff2cf, emissiveIntensity: .5 }), s2 * .55, .62, 2.05));++ g.add(box(.3, .1, .06, new THREE.MeshStandardMaterial({ color: 0xd93025, emissive: 0x8a1610, emissiveIntensity: .5 }), s2 * .55, .62, -2.05));++ }++ for (const [wx, wz] of [[-.85, 1.3], [.85, 1.3], [-.85, -1.3], [.85, -1.3]]) {++ const wh = cyl(.32, .32, .22, M.blackMetal, wx, .32, wz); wh.rotation.z = Math.PI / 2; g.add(wh);++ }++ g.position.set(x, 0, z);++ g.rotation.y = ry;++ g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });++ scene.add(g);++ const col = { x0: x - 2.1, x1: x + 2.1, z0: z - 1, z1: z + 1, off: false };++ W.colliders.push(col);++ const it = {++ id, type: 'car', x, z, r: 3.4,++ label: ownerName ? `${ownerName}'s car 🚗` : 'Drive the car 🚗', data: { car: id },++ };++ inter(it);++ const car = { id, group: g, col, inter: it, driver: null, x, z, ry, ownerName, pax: {} };++ W.cars.push(car);++ return car;++}++// circle-vs-AABB collision resolve, returns corrected [x,z]export function resolveCollisions(x, z, r) {for (const c of W.colliders) {if (c.off) continue;++ // surface-probing colliders (frozen props, cars): only block where the++ // actual surface is too tall to step onto — tilted ramps stay walkable.++ // Probe at the contact point on the box, not the player center.++ if (c.hAt && x >= c.x0 - .34 && x <= c.x1 + .34 && z >= c.z0 - .34 && z <= c.z1 + .34) {++ const h = c.hAt(Math.max(c.x0, Math.min(x, c.x1)), Math.max(c.z0, Math.min(z, c.z1)));++ if (h == null || h - (W.feetY || 0) <= .68) continue;++ }const nx = Math.max(c.x0, Math.min(x, c.x1));const nz = Math.max(c.z0, Math.min(z, c.z1));const dx = x - nx, dz = z - nz;@@@ -66,6 +66,14 @@<!-- health + combat feedback --><div id="hp-wrap"><div id="hp-fill"></div></div><div id="dmg-flash"></div>++ <div id="death-screen" class="hidden">++ <div id="ds-inner">++ <h1>YOU DIED</h1>++ <div id="ds-sub"></div>++ <div id="ds-stats"></div>++ <div id="ds-hint">back at the front walkway — click to dismiss</div>++ </div>++ </div><div id="hitmark"></div><div id="headshot-tag">HEADSHOT</div><div id="fps-meter" class="hidden"></div>@@@ -96,13 +96,22 @@ try { savedWorld = JSON.parse(fs.readFileSync(WORLD_FILE, 'utf8')); } catch {}const sleepers = new Map(Object.entries(savedWorld.sleepers || {})); // key -> {key,name,vest,ap,guest,x,y,z,ry,ts}const props = new Map(Object.entries(savedWorld.props || {})); // id -> {id,kind,p:[x,y,z],q:[x,y,z,w]}for (const [cid, c] of Object.entries(savedWorld.cars || {})) {-- if (!/^(car\d+|lambo)$/.test(cid)) continue;++ if (!/^(car\d+|lambo|pc\d+)$/.test(cid)) continue;if (Array.isArray(c.p)) cars[cid] = { p: c.p, q: Array.isArray(c.q) ? c.q : [0, 0, 0, 1], driver: null };else if (Number.isFinite(c.x)) { // migrate pre-rigid-body saves {x,z,ry}const h = (c.ry || 0) / 2;cars[cid] = { p: [c.x, .55, c.z], q: [0, +Math.sin(h).toFixed(3), 0, +Math.cos(h).toFixed(3)], driver: null };}++ if (cars[cid] && cid.startsWith('pc')) { // crafted personal cars keep their owner++ cars[cid].owner = c.owner || null;++ cars[cid].ownerName = c.ownerName || null;++ cars[cid].color = c.color || null;++ }}++let nextCarId = Object.keys(cars).reduce((m, id) => id.startsWith('pc') ? Math.max(m, Number(id.slice(2)) || 0) : m, 0) + 1;++// what clients get to see about a car: everything except the owner's account key++const pubCar = (c) => ({ p: c.p, q: c.q, driver: c.driver, ownerName: c.ownerName || null, color: c.color || null, pax: c.pax || {} });++const pubCars = () => Object.fromEntries(Object.entries(cars).map(([id, c]) => [id, pubCar(c)]));let nextPhysId = [...props.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(2)) || 0), 0) + 1;// legacy static builds become FROZEN props (darkrp-style: everything is a prop)for (const b of Object.values(savedWorld.builds || {})) {@@@ -162,13 +171,28 @@ for (const sig of ['SIGTERM', 'SIGINT']) {}// death: spill everything, reset, respawn at the walkway — used by pvp AND npcs--function killPlayer(t, byName) {++function killPlayer(t, byName, info = {}) {stats.kills++;worldDirty = true;spillInventory(t.key, t.x, t.z);++ // pry them out of any car first — respawning "inside" a car stuck players++ for (const [cid, c] of Object.entries(cars)) {++ if (c.driver === t.id) { c.driver = null; broadcast({ t: 'car', id: cid, op: 'exit', p: c.p, q: c.q }); }++ if (c.pax) for (const [seat, pid] of Object.entries(c.pax)) {++ if (pid === t.id) { delete c.pax[seat]; broadcast({ t: 'car', id: cid, op: 'unsit', seat: +seat, by: t.id }); }++ }++ }++ const lived = Math.max(0, Math.round((Date.now() - (t.bornAt || Date.now())) / 1000));++ const kills = t.myKills || 0;t.hp = 100;++ t.bornAt = Date.now();++ t.myKills = 0;t.x = 105 + Math.random() * 3; t.y = 0; t.z = -1 + Math.random() * 2;-- if (t.ws.readyState === 1) t.ws.send(JSON.stringify({ t: 'died', by: byName, x: t.x, z: t.z }));++ if (t.ws.readyState === 1) t.ws.send(JSON.stringify({++ t: 'died', by: byName, weapon: info.weapon || null,++ dist: Number.isFinite(info.dist) ? +info.dist.toFixed(1) : null,++ hs: info.hs === true, lived, kills, x: t.x, z: t.z,++ }));broadcast({ t: 'hp', id: t.id, hp: 100 });}@@@ -230,7 +254,7 @@ setInterval(() => { // 5Hz herd-and-horde tickelse if (Date.now() - n.atkT > 1200) {n.atkT = Date.now();best.hp = (best.hp ?? 100) - 8;-- if (best.hp <= 0) { killPlayer(best, 'a zombie'); sys(`🧟 ${best.name} got eaten by a zombie`); }++ if (best.hp <= 0) { killPlayer(best, 'a zombie', { weapon: 'zombie', dist: bd }); sys(`🧟 ${best.name} got eaten by a zombie`); }else broadcast({ t: 'hp', id: best.id, hp: best.hp, by: -1 });}}@@@ -284,7 +308,7 @@ wss.on('connection', (ws, req) => {const p = {id, key: user.key, name: user.name, vest: user.vest, admin: user.admin, ap: user.ap, guest: !!user.guest, ws,x: 105 + Math.random() * 3, y: 0, z: -1 + Math.random() * 2, ry: -Math.PI / 2,-- anim: 'idle', seat: null, held: null, lastChat: 0, hp: 100,++ anim: 'idle', seat: null, held: null, lastChat: 0, hp: 100, bornAt: Date.now(), myKills: 0,};// returning player: wake their napper and resume exactly where they dozed offconst slept = sleepers.get(user.key);@@@ -306,11 +330,12 @@ wss.on('connection', (ws, req) => {drops: [...drops.values()],mapEdits,players: [...players.values()].filter(q => q.id !== id).map(pubState),++ myCarIds: Object.entries(cars).filter(([, c]) => c.owner === user.key).map(([cid]) => cid),c4: { a: c4.a.state(), b: c4.b.state() },chess: chess.state(),pong: { a: pong.a.state(), b: pong.b.state() },highscores: store.getHighscores(),-- cars,++ cars: pubCars(),sleepers: [...sleepers.values()],props: [...props.values()],npcs: npcSnapshot(),@@@ -333,6 +358,9 @@ wss.on('connection', (ws, req) => {players.delete(id);for (const [cid, c] of Object.entries(cars)) {if (c.driver === id) { c.driver = null; broadcast({ t: 'car', id: cid, op: 'exit', p: c.p, q: c.q }); }++ if (c.pax) for (const [seat, pid] of Object.entries(c.pax)) {++ if (pid === id) { delete c.pax[seat]; broadcast({ t: 'car', id: cid, op: 'unsit', seat: +seat, by: id }); }++ }}for (const g of [c4.a, c4.b]) { g.leave(p.key); broadcast({ t: 'c4', s: g.state() }); }chess.leave(p.key); broadcast({ t: 'chess', s: chess.state() });@@@ -454,9 +482,37 @@ function handle(p, m) {break;}case 'car': {++ if (m.op === 'spawn') { // crafted personal car: owner-locked driver's seat++ const now = Date.now();++ if (now - (p.lastCarSpawn || 0) < 1000) return;++ p.lastCarSpawn = now;++ const fail = (text) => { if (p.ws.readyState === 1) { p.ws.send(JSON.stringify({ t: 'car', op: 'spawnfail' })); p.ws.send(JSON.stringify({ t: 'sys', text })); } };++ const mine = Object.values(cars).filter(c2 => c2.owner === p.key).length;++ const crafted = Object.keys(cars).filter(k => k.startsWith('pc')).length;++ if (mine >= 3) { fail('🚗 Three personal cars is plenty — the lot has limits.'); return; }++ if (crafted >= 30) { fail('🚗 The lot is full of crafted cars already.'); return; }++ const x = clampX(+m.x || 0), z = clampZ(+m.z || 0), h = (+m.ry || 0) / 2;++ const cid = 'pc' + nextCarId++;++ cars[cid] = {++ p: [x, .6, z], q: [0, +Math.sin(h).toFixed(3), 0, +Math.cos(h).toFixed(3)],++ driver: null, owner: p.key, ownerName: p.name,++ color: [0x8a8f96, 0x2e3f55, 0x7a2c2c, 0xd8d8d8, 0x4a6b52, 0xc9541e, 0x6a3f8f][Math.floor(Math.random() * 7)],++ pax: {},++ };++ worldDirty = true;++ broadcast({ t: 'car', op: 'spawn', id: cid, car: pubCar(cars[cid]), owner: p.id });++ sys(`🔧 ${p.name} built their own car!`);++ return;++ }const id = String(m.id || '').slice(0, 12);-- if (!/^(car\d+|lambo)$/.test(id)) return;++ if (!/^(car\d+|lambo|pc\d+)$/.test(id)) return;const c = cars[id] || (cars[id] = { p: null, q: [0, 0, 0, 1], driver: null });++ const canUse = () => !c.owner || c.owner === p.key || store.isFriend(c.owner, p.key);++ const deny = (text) => {++ if (p.ws.readyState !== 1) return;++ p.ws.send(JSON.stringify({ t: 'car', op: 'deny', id }));++ if (text) p.ws.send(JSON.stringify({ t: 'sys', text }));++ };const readPose = () => { // full rigid-body pose: position + quaternion (+velocity)const pos = Array.isArray(m.p) ? m.p.map(Number) : null;const q = Array.isArray(m.q) ? m.q.map(Number) : null;@@@ -469,6 +525,7 @@ function handle(p, m) {const vel = () => Array.isArray(m.v) && m.v.length === 3 && m.v.every(Number.isFinite) ? m.v : null;if (m.op === 'enter') {if (c.driver && c.driver !== p.id) return;++ if (!canUse()) { deny(`🔒 That's ${c.ownerName || 'someone'}'s car — only they (and their friends) can drive it. Hop in the back instead!`); return; }c.driver = p.id;stats.joyrides++;if (id === 'lambo') stats.lambo++;@@@ -479,16 +536,31 @@ function handle(p, m) {if (c.driver !== p.id) return;c.driver = null;broadcast({ t: 'car', id, op: 'exit', p: c.p, q: c.q });++ } else if (m.op === 'sit') { // passenger seats: anyone can ride along++ const seat = Math.floor(Number(m.seat));++ if (![0, 1, 2].includes(seat)) return;++ c.pax = c.pax || {};++ if (c.pax[seat] && c.pax[seat] !== p.id) return; // taken++ for (const [s2, pid] of Object.entries(c.pax)) if (pid === p.id) delete c.pax[s2];++ c.pax[seat] = p.id;++ broadcast({ t: 'car', id, op: 'sit', seat, by: p.id });++ } else if (m.op === 'unsit') {++ c.pax = c.pax || {};++ for (const [s2, pid] of Object.entries(c.pax)) {++ if (pid === p.id) { delete c.pax[s2]; broadcast({ t: 'car', id, op: 'unsit', seat: +s2, by: p.id }); }++ }} else if (m.op === 'state') { // driver's streamif (c.driver !== p.id) return;if (!readPose()) return;broadcast({ t: 'car', id, op: 'state', p: c.p, q: c.q, v: vel(), driver: p.id }, p.id);} else if (m.op === 'phys') { // driverless nudge: physgun, shoves, rolling to a stopif (c.driver) return;++ if (!canUse()) return; // personal cars only move for their owner & friendsif (!readPose()) return;broadcast({ t: 'car', id, op: 'phys', p: c.p, q: c.q, v: vel(), owner: p.id }, p.id);} else if (m.op === 'grab' || m.op === 'drop') { // physgun beam relayif (m.op === 'grab' && c.driver) return;++ if (m.op === 'grab' && !canUse()) { deny(`🔒 That car belongs to ${c.ownerName || 'someone else'}.`); return; }broadcast({ t: 'car', id, op: m.op, by: p.id }, p.id);}break;@@@ -499,7 +571,7 @@ function handle(p, m) {if (now - (p.lastSpawn || 0) < 250) return;p.lastSpawn = now;if (props.size >= 150) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '📦 Prop limit reached (150) — grab some with the physgun and press X to clear them.' })); return; }-- const KINDS = ['box', 'crate', 'ball', 'barrel', 'melon', 'cone', 'wall', 'floor', 'door'];++ const KINDS = ['box', 'crate', 'ball', 'barrel', 'melon', 'cone', 'campfire', 'wall', 'floor', 'door'];if (!KINDS.includes(m.kind)) return;const pos = Array.isArray(m.p) ? m.p.map(Number) : [];if (pos.length !== 3 || !pos.every(Number.isFinite)) return;@@@ -676,7 +748,8 @@ function handle(p, m) {return;}// death: everything they carried spills where they fell, then respawn-- killPlayer(t, p.name);++ p.myKills = (p.myKills || 0) + 1;++ killPlayer(t, p.name, { weapon, dist: Math.hypot(p.x - t.x, p.z - t.z), hs });sys(`💀 ${p.name} clocked ${t.name} with ${label} — their stuff hit the floor`);break;}@@@ -127,7 +127,7 @@ export function setAppearance(key, ap) {export const ITEM_IDS = ['chips','soda','candy','coffee','energy','water','food','paddle','broom','tapegun','tube','wrench','banana','physgun','flashlight','wood','stone','axe','pickaxe','stoneaxe','stonepick','pistol','wall','floor','door',-- 'box','crate','ball','barrel','melon','cone',++ 'box','crate','ball','barrel','melon','cone','campfire','carkit','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);
@@ -180,3 +180,20 @@ body.touch #hp-wrap { bottom: calc(174px + env(safe-area-inset-bottom)); }
@@ -180,3 +180,20 @@ body.touch #hp-wrap { bottom: calc(174px + env(safe-area-inset-bottom)); }
#friends-list .fr { background: rgba(53,224,255,.1); border: 1px solid rgba(53,224,255,.4); color: #9fe8ff; border-radius: 999px; padding: 3px 10px; font-size: .82rem; cursor: pointer; }
#friends-list .fr { background: rgba(53,224,255,.1); border: 1px solid rgba(53,224,255,.4); color: #9fe8ff; border-radius: 999px; padding: 3px 10px; font-size: .82rem; cursor: pointer; }
#friends-list .fr:hover { background: rgba(226,38,45,.2); border-color: #e2262d; }
#friends-list .fr:hover { background: rgba(226,38,45,.2); border-color: #e2262d; }
#friend-name { background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.15); color: #dfe7f0; border-radius: 8px; padding: 6px 10px; font-family: inherit; }
#friend-name { background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.15); color: #dfe7f0; border-radius: 8px; padding: 6px 10px; font-family: inherit; }
+
+/* ---------- death screen ---------- */
+#death-screen { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center;
+ background: radial-gradient(ellipse at center, rgba(30,4,6,.55) 30%, rgba(12,2,3,.94) 100%);
+ opacity: 0; transition: opacity .6s ease; cursor: pointer; }
+#death-screen.show { opacity: 1; }
+#ds-inner { text-align: center; max-width: 440px; padding: 24px; transform: scale(.94); transition: transform .6s ease; }
+#death-screen.show #ds-inner { transform: scale(1); }
+#death-screen h1 { font-size: 64px; letter-spacing: .28em; margin: 0 0 14px; color: #e2262d;
+ text-shadow: 0 0 26px rgba(226,38,45,.65), 0 2px 0 #5c0a0e; font-weight: 900; }
+#ds-sub { color: #f2d9d9; font-size: 17px; margin-bottom: 18px; }
+#ds-sub b { color: #ffb2ac; }
+#ds-stats { display: flex; flex-direction: column; gap: 6px; margin: 0 auto 18px; max-width: 330px; }
+.ds-row { display: flex; justify-content: space-between; background: rgba(255,255,255,.05);
+ border: 1px solid rgba(226,38,45,.25); border-radius: 8px; padding: 7px 12px; font-size: 14px; color: #d8c2c2; }
+.ds-row b { color: #ffd9d5; }
+#ds-hint { color: #8a6b6b; font-size: 12.5px; font-style: italic; }
@@ -36,6 +36,8 @@ export const ITEMS = {
@@ -36,6 +36,8 @@ export const ITEMS = {
barrel: { name: 'Barrel', icon: '🛢️', type: 'prop' },
barrel: { name: 'Barrel', icon: '🛢️', type: 'prop' },
melon: { name: 'Watermelon', icon: '🍉', type: 'prop' },
melon: { name: 'Watermelon', icon: '🍉', type: 'prop' },
cone: { name: 'Traffic cone', icon: '🚧', type: 'prop' },
cone: { name: 'Traffic cone', icon: '🚧', type: 'prop' },
+ campfire: { name: 'Campfire', icon: '🔥', type: 'prop' },
+ carkit: { name: 'Car kit', icon: '🚗', type: 'car' },
'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 } },
@@ -78,11 +78,11 @@ export function initLighting(scene, renderer, shadows = true) {
@@ -78,11 +78,11 @@ export function initLighting(scene, renderer, shadows = true) {
const skyDay = new THREE.Color(0xbcd2e2);
const skyDay = new THREE.Color(0xbcd2e2);
const skyDusk = new THREE.Color(0xe89a5e);
const skyDusk = new THREE.Color(0xe89a5e);
- const skyNight = new THREE.Color(0x0b1322);
+ const skyNight = new THREE.Color(0x151f33);
const sunWarm = new THREE.Color(0xff9a4a);
const sunWarm = new THREE.Color(0xff9a4a);
const sunNoon = new THREE.Color(0xffeed8);
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 hemiDay = new THREE.Color(0xf2f5f8), hemiNight = new THREE.Color(0x48597c);
+ const gndDay = new THREE.Color(0x54575c), gndNight = new THREE.Color(0x222a36);
const sky = new THREE.Color();
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); };
const smooth = (a, b, x) => { const k = Math.max(0, Math.min(1, (x - a) / (b - a))); return k * k * (3 - 2 * k); };
@@ -127,15 +127,15 @@ export function initLighting(scene, renderer, shadows = true) {
@@ -127,15 +127,15 @@ export function initLighting(scene, renderer, shadows = true) {
sun.position.set(qx + Math.sin(az) * 80, Math.max(4, elev * 90), qz + Math.cos(az) * 40);
sun.position.set(qx + Math.sin(az) * 80, Math.max(4, elev * 90), qz + Math.cos(az) * 40);
sun.target.position.set(qx, 0, qz);
sun.target.position.set(qx, 0, qz);
- moon.intensity = .22 * (1 - d);
+ moon.intensity = .55 * (1 - d); // bright "movie moonlight" so night stays readable
moon.position.set(px - Math.sin(az) * 70, Math.max(10, -elev * 80), pz - Math.cos(az) * 35);
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);
moon.target.position.set(px, 0, pz);
const dl = Math.max(d, indoorK * .82); // indoor light floor: fluorescents
const dl = Math.max(d, indoorK * .82); // indoor light floor: fluorescents
- hemi.intensity = .3 + .75 * dl;
+ hemi.intensity = .48 + .57 * dl;
hemi.color.copy(hemiNight).lerp(hemiDay, dl);
hemi.color.copy(hemiNight).lerp(hemiDay, dl);
hemi.groundColor.copy(gndNight).lerp(gndDay, dl);
hemi.groundColor.copy(gndNight).lerp(gndDay, dl);
- amb.intensity = .08 + .16 * dl;
+ amb.intensity = .16 + .1 * dl;
stars.material.opacity = (1 - d) * .95;
stars.material.opacity = (1 - d) * .95;
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
// Amazon Break Room Simulator — client bootstrap & game loop.
// Amazon Break Room Simulator — client bootstrap & game loop.
import * as THREE from 'three';
import * as THREE from 'three';
-import { buildWorld, resolveCollisions, W } from './world.js';
+import { buildWorld, resolveCollisions, addWorldCar, W } from './world.js';
import { makeAvatar, buildHeldMesh, SKINS, SHIRTS, HAIRS, HATS } from './avatar.js';
import { makeAvatar, buildHeldMesh, SKINS, SHIRTS, HAIRS, HATS } from './avatar.js';
import { initInventory, ITEMS } from './inventory.js';
import { initInventory, ITEMS } from './inventory.js';
import { input, initInput, keyMove, uiFocus } from './input.js';
import { input, initInput, keyMove, uiFocus } from './input.js';
@@ -118,18 +118,51 @@ function start(token, user) {
@@ -118,18 +118,51 @@ function start(token, user) {
}
}
// ---------- drivable cars (rigid-body vehicles — they roll and flip) ----------
// ---------- drivable cars (rigid-body vehicles — they roll and flip) ----------
- const carState = { driving: null }; // driving = W.cars entry
+ const carState = { driving: null, riding: null }; // driving = W.cars entry · riding = {car, seat}
let carSendT = 0;
let carSendT = 0;
+ // passenger seat offsets in car-local space (forward = +z): shotgun + two rear
+ const PAX_OFFS = [{ x: .62, z: .5 }, { x: -.55, z: -.95 }, { x: .55, z: -.95 }];
+ const myCarIds = new Set(); // personal cars I own (server-confirmed)
+ function carUsable(car) { return !car.ownerName || myCarIds.has(car.id) || car.ownerName === me.name; }
function enterCar(car) {
function enterCar(car) {
- if (car.driver && car.driver !== myId) { toast('🚗 Someone is already driving that one.'); return; }
+ if (car.driver && car.driver !== myId) { enterPassenger(car); return; }
+ // optimistic: take the wheel now; an owner-locked car comes back as 'deny'
+ prepDrive(car);
+ net.send({ t: 'car', id: car.id, op: 'enter' });
+ if (carUsable(car)) toast('🚗 W/S throttle · A/D steer · Space handbrake · E to get out');
+ }
+ function prepDrive(car) {
const e = phys.cars.get(car.id);
const e = phys.cars.get(car.id);
if (pgState.held === e) physgunRelease(false);
if (pgState.held === e) physgunRelease(false);
carState.driving = car;
carState.driving = car;
car.col.off = true;
car.col.off = true;
car.driver = myId;
car.driver = myId;
phys.claim(e);
phys.claim(e);
- net.send({ t: 'car', id: car.id, op: 'enter' });
- toast('🚗 W/S throttle · A/D steer · Space handbrake · E to get out');
+ }
+ function enterPassenger(car) {
+ car.pax = car.pax || {};
+ let seat = -1;
+ for (let s = 0; s < 3; s++) if (car.pax[s] == null) { seat = s; break; }
+ if (seat === -1) { toast('🚗 That car is full.'); return; }
+ net.send({ t: 'car', id: car.id, op: 'sit', seat }); // server confirms via broadcast
+ }
+ function ridePose() { // glue me to my passenger seat, in car-local space
+ const { car, seat } = carState.riding;
+ const e = phys.cars.get(car.id);
+ if (!e) { carState.riding = null; return; }
+ const off = PAX_OFFS[seat] || PAX_OFFS[0];
+ const fx = Math.sin(car.ry), fz = Math.cos(car.ry);
+ my.x = car.x + fx * off.z + fz * off.x;
+ my.z = car.z + fz * off.z - fx * off.x;
+ my.y = Math.max(0, e.body.position.y - .15);
+ my.ry = car.ry;
+ }
+ function exitPassengerLocal(car) {
+ carState.riding = null;
+ my.x = car.x + Math.cos(car.ry) * 1.7;
+ my.z = car.z - Math.sin(car.ry) * 1.7;
+ my.y = 0; my.vy = 0; my.vel.x = 0; my.vel.z = 0;
+ sendPos(true);
}
}
function exitCar() {
function exitCar() {
const car = carState.driving;
const car = carState.driving;
@@ -147,7 +180,48 @@ function start(token, user) {
@@ -147,7 +180,48 @@ function start(token, user) {
net.send({ t: 'car', id: car.id, op: 'exit' });
net.send({ t: 'car', id: car.id, op: 'exit' });
sendPos(true);
sendPos(true);
}
}
+ function leaveAnyCar() { // death/desync: get out of whatever we're in, silently
+ if (carState.driving) {
+ const car = carState.driving;
+ carState.driving = null;
+ car.col.off = false;
+ car.driver = null;
+ const e = phys.cars.get(car.id);
+ if (e) e.owned = false;
+ net.send({ t: 'car', id: car.id, op: 'exit' });
+ }
+ if (carState.riding) {
+ const { car } = carState.riding;
+ carState.riding = null;
+ net.send({ t: 'car', id: car.id, op: 'unsit' });
+ }
+ }
+ // a crafted car arriving from the server (mine or someone else's)
+ function registerCar(id, c, snap = true) {
+ const car = addWorldCar({
+ id, color: c.color || 0x8a8f96,
+ x: Array.isArray(c.p) ? c.p[0] : 0, z: Array.isArray(c.p) ? c.p[2] : 0, ry: 0,
+ ownerName: c.ownerName || null,
+ });
+ car.pax = c.pax || {};
+ phys.addCar(car);
+ if (snap && Array.isArray(c.p)) phys.applyCarState({ id, p: c.p, q: c.q || [0, 0, 0, 1], v: null }, true);
+ return car;
+ }
net.on('car', (m) => {
net.on('car', (m) => {
+ if (m.op === 'spawn') {
+ if (!W.cars?.some(c => c.id === m.id)) {
+ const car = registerCar(m.id, m.car);
+ if (m.owner === myId) {
+ myCarIds.add(m.id);
+ car.inter.label = 'Your car 🚗 — E to drive';
+ toast('🔧 Your car! Only you (and your friends) can drive it.', 5200);
+ beep(740, .12, 'sine', .12);
+ }
+ }
+ return;
+ }
+ if (m.op === 'spawnfail') { invApi.add('carkit'); return; }
const car = W.cars?.find(c => c.id === m.id);
const car = W.cars?.find(c => c.id === m.id);
const e = phys.cars.get(m.id);
const e = phys.cars.get(m.id);
if (!car || !e) return;
if (!car || !e) return;
@@ -160,6 +234,30 @@ function start(token, user) {
@@ -160,6 +234,30 @@ function start(token, user) {
car.driver = null;
car.driver = null;
car.col.off = false;
car.col.off = false;
if (Array.isArray(m.p)) phys.applyCarState({ id: m.id, p: m.p, q: m.q, v: null }, true);
if (Array.isArray(m.p)) phys.applyCarState({ id: m.id, p: m.p, q: m.q, v: null }, true);
+ } else if (m.op === 'deny') { // server bounced us out (owner-locked car)
+ if (carState.driving === car) {
+ carState.driving = null;
+ car.col.off = false;
+ car.driver = null;
+ e.owned = false;
+ my.x = car.x + Math.cos(car.ry) * 1.7;
+ my.z = car.z - Math.sin(car.ry) * 1.7;
+ my.y = 0; my.vy = 0;
+ sendPos(true);
+ enterPassenger(car); // can't drive it, but riding shotgun is allowed
+ }
+ if (pgState.held === e) { physgunRelease(false); pgState.lmb = false; }
+ } else if (m.op === 'sit') {
+ car.pax = car.pax || {};
+ car.pax[m.seat] = m.by;
+ if (m.by === myId) {
+ carState.riding = { car, seat: m.seat };
+ my.seat = null;
+ toast('🚌 Riding along — E to hop out');
+ }
+ } else if (m.op === 'unsit') {
+ if (car.pax) delete car.pax[m.seat];
+ if (m.by === myId && carState.riding?.car === car) exitPassengerLocal(car);
} else if (m.op === 'state') {
} else if (m.op === 'state') {
if (m.driver === myId) return;
if (m.driver === myId) return;
car.driver = m.driver;
car.driver = m.driver;
@@ -246,13 +344,22 @@ function start(token, user) {
@@ -246,13 +344,22 @@ function start(token, user) {
torch.position.set(.25, -.2, .1);
torch.position.set(.25, -.2, .1);
torch.target.position.set(0, -.4, -12);
torch.target.position.set(0, -.4, -12);
camera.add(torch, torch.target);
camera.add(torch, torch.target);
- // headlight pool (created up-front — stable light count, no shader hitches)
+ // headlight pool (created up-front — stable light count, no shader hitches).
+ // Wide cone + long throw so night driving actually lights the road.
const hlPool = [];
const hlPool = [];
for (let i = 0; i < 2; i++) {
for (let i = 0; i < 2; i++) {
- const sp = new THREE.SpotLight(0xfff2cf, 0, 36, .52, .5, 1.1);
+ const sp = new THREE.SpotLight(0xfff2cf, 0, 60, .78, .55, .9);
scene.add(sp, sp.target);
scene.add(sp, sp.target);
hlPool.push(sp);
hlPool.push(sp);
}
}
+ // campfire light pool: the nearest few campfires share these flickering points
+ const cfPool = [];
+ for (let i = 0; i < 4; i++) {
+ const pl = new THREE.PointLight(0xff9a3a, 0, 16, 1.3);
+ scene.add(pl);
+ cfPool.push(pl);
+ (W.extraLights = W.extraLights || []).push(pl); // the graphics toggle covers them
+ }
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 = {
@@ -524,11 +631,16 @@ function start(token, user) {
@@ -524,11 +631,16 @@ function start(token, user) {
phys.setMyId(m.id);
phys.setMyId(m.id);
my.x = m.you.x; my.y = m.you.y; my.z = m.you.z; my.ry = m.you.ry;
my.x = m.you.x; my.y = m.you.y; my.z = m.you.z; my.ry = m.you.ry;
mg.applyInit(m);
mg.applyInit(m);
+ for (const cid of m.myCarIds || []) myCarIds.add(cid);
for (const [cid, c] of Object.entries(m.cars || {})) {
for (const [cid, c] of Object.entries(m.cars || {})) {
- const car = W.cars?.find(k => k.id === cid);
+ let car = W.cars?.find(k => k.id === cid);
+ if (!car && cid.startsWith('pc')) car = registerCar(cid, c, false); // crafted cars aren't in the base map
if (!car) continue;
if (!car) continue;
if (Array.isArray(c.p)) phys.applyCarState({ id: cid, p: c.p, q: c.q || [0, 0, 0, 1], v: null }, true);
if (Array.isArray(c.p)) phys.applyCarState({ id: cid, p: c.p, q: c.q || [0, 0, 0, 1], v: null }, true);
car.driver = c.driver;
car.driver = c.driver;
+ car.pax = c.pax || {};
+ car.ownerName = c.ownerName || car.ownerName || null;
+ if (myCarIds.has(cid)) car.inter.label = 'Your car 🚗 — E to drive';
if (c.driver) car.col.off = true;
if (c.driver) car.col.off = true;
}
}
me.admin = !!m.admin;
me.admin = !!m.admin;
@@ -677,8 +789,42 @@ function start(token, user) {
@@ -677,8 +789,42 @@ function start(token, user) {
others.get(m.id)?.avatar.flash?.(); // victims glow red so everyone can tell
others.get(m.id)?.avatar.flash?.(); // victims glow red so everyone can tell
if (m.by === myId) hitmarker(m.hs === true);
if (m.by === myId) hitmarker(m.hs === true);
});
});
+ // the big red YOU DIED card: who, with what, from how far, how long you lasted
+ const WEAPON_LABELS = {
+ fists: '👊 bare fists', paddle: '🏓 a ping pong paddle', broom: '🧹 a broom', tapegun: '📼 a tape gun',
+ tube: '📦 a cardboard tube', wrench: '🔧 a wrench', banana: '🍌 a banana (embarrassing)',
+ axe: '🪓 an axe', pickaxe: '⛏️ a pickaxe', stoneaxe: '🪓 a stone axe', stonepick: '⛏️ a stone pickaxe',
+ pistol: '🔫 a pistol', zombie: '🧟 zombie teeth',
+ };
+ const fmtLived = (s) => s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor(s % 3600 / 60)}m` : s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
+ function showDeathScreen(m) {
+ const el = $('death-screen');
+ $('ds-sub').innerHTML = m.hs
+ ? `<b>${esc(m.by)}</b> headshotted you with ${WEAPON_LABELS[m.weapon] || 'something'} 🎯`
+ : `taken out by <b>${esc(m.by)}</b>${m.weapon ? ` with ${WEAPON_LABELS[m.weapon] || 'something'}` : ''}`;
+ const rows = [];
+ if (Number.isFinite(m.dist)) rows.push(['📏 from', `${m.dist.toFixed(1)} m`]);
+ if (Number.isFinite(m.lived)) rows.push(['⏱️ you survived', fmtLived(m.lived)]);
+ rows.push(['💀 kills that life', String(m.kills || 0)]);
+ rows.push(['🎒 your stuff', 'scattered where you fell']);
+ $('ds-stats').innerHTML = rows.map(([k, v]) => `<div class="ds-row"><span>${k}</span><b>${v}</b></div>`).join('');
+ el.classList.remove('hidden');
+ requestAnimationFrame(() => el.classList.add('show'));
+ clearTimeout(showDeathScreen._t);
+ showDeathScreen._t = setTimeout(hideDeathScreen, 7000);
+ el.onclick = hideDeathScreen;
+ }
+ function hideDeathScreen() {
+ const el = $('death-screen');
+ el.classList.remove('show');
+ setTimeout(() => el.classList.add('hidden'), 600);
+ }
net.on('died', (m) => {
net.on('died', (m) => {
setHp(100, false);
setHp(100, false);
+ leaveAnyCar(); // dying at the wheel used to respawn you "inside" the car
+ if (pgState.held) physgunRelease(false);
+ my.seat = null;
+ myAvatar.seatId = null;
my.x = m.x; my.z = m.z; my.y = 0; my.vy = 0;
my.x = m.x; my.z = m.z; my.y = 0; my.vy = 0;
my.vel.x = 0; my.vel.z = 0;
my.vel.x = 0; my.vel.z = 0;
my.held = null;
my.held = null;
@@ -688,7 +834,7 @@ function start(token, user) {
@@ -688,7 +834,7 @@ function start(token, user) {
updateViewmodel();
updateViewmodel();
dmgFlash.style.opacity = .9;
dmgFlash.style.opacity = .9;
setTimeout(() => { dmgFlash.style.opacity = 0; }, 700);
setTimeout(() => { dmgFlash.style.opacity = 0; }, 700);
- toast(`💀 ${m.by} got you. Your stuff is scattered where you fell.`, 5600);
+ showDeathScreen(m);
beep(90, .5, 'sawtooth', .18);
beep(90, .5, 'sawtooth', .18);
sendPos(true);
sendPos(true);
});
});
@@ -1058,6 +1204,15 @@ function start(token, user) {
@@ -1058,6 +1204,15 @@ function start(token, user) {
spawnProp(kind);
spawnProp(kind);
toast(`${ITEMS[kind].icon} placed`);
toast(`${ITEMS[kind].icon} placed`);
}
}
+ function placeCar() { // carkit: your own ride, owner-locked at the server
+ if (my.held !== 'carkit') return;
+ const mine = W.cars?.filter(c => myCarIds.has(c.id)).length || 0;
+ if (mine >= 3) { toast('🚗 Three personal cars is plenty.'); return; }
+ const used = invApi.dropSelected();
+ if (used !== 'carkit') return;
+ const fx = -Math.sin(my.yaw), fz = -Math.cos(my.yaw);
+ net.send({ t: 'car', op: 'spawn', x: +(my.x + fx * 4.2).toFixed(2), z: +(my.z + fz * 4.2).toFixed(2), ry: +my.yaw.toFixed(2) });
+ }
// ---------- crafting (C) — rust-lite ----------
// ---------- crafting (C) — rust-lite ----------
const RECIPES = [
const RECIPES = [
@@ -1075,6 +1230,8 @@ function start(token, user) {
@@ -1075,6 +1230,8 @@ function start(token, user) {
{ id: 'barrel', cost: { stone: 6 }, desc: 'Physics prop' },
{ id: 'barrel', cost: { stone: 6 }, desc: 'Physics prop' },
{ id: 'melon', cost: { wood: 3 }, desc: 'Physics prop, organic' },
{ id: 'melon', cost: { wood: 3 }, desc: 'Physics prop, organic' },
{ id: 'cone', cost: { stone: 3 }, desc: 'Physics prop, official' },
{ id: 'cone', cost: { stone: 3 }, desc: 'Physics prop, official' },
+ { id: 'campfire', cost: { wood: 10, stone: 6 }, desc: 'Cozy light for the long nights' },
+ { id: 'carkit', cost: { wood: 90, stone: 60 }, desc: 'Your own car — only you & friends can drive it' },
];
];
const craftEl = $('craft-menu'), craftList = $('craft-list'), craftHave = $('craft-have');
const craftEl = $('craft-menu'), craftList = $('craft-list'), craftHave = $('craft-have');
let craftOpen = false;
let craftOpen = false;
@@ -1287,8 +1444,10 @@ function start(token, user) {
@@ -1287,8 +1444,10 @@ function start(token, user) {
if (fp) return camera.localToWorld(muzzleV.set(.31, -.26, -.75)); // viewmodel gun tip
if (fp) return camera.localToWorld(muzzleV.set(.31, -.26, -.75)); // viewmodel gun tip
return myAvatar.heldAnchor.getWorldPosition(muzzleV);
return myAvatar.heldAnchor.getWorldPosition(muzzleV);
}
}
- const pgState = { held: null, dist: 0, lmb: false, missT: 0 };
+ const pgState = { held: null, dist: 0, lmb: false, missT: 0, relQ: null };
const pgRay = new THREE.Raycaster();
const pgRay = new THREE.Raycaster();
+ const pgQTmp = new THREE.Quaternion(), pgQTmp2 = new THREE.Quaternion();
+ const PG_X = new THREE.Vector3(1, 0, 0), PG_Y = new THREE.Vector3(0, 1, 0);
function pgEquipped() { return my.held === 'physgun'; }
function pgEquipped() { return my.held === 'physgun'; }
function pgPick() {
function pgPick() {
// fan of rays around the crosshair so edge clicks still land
// fan of rays around the crosshair so edge clicks still land
@@ -1306,10 +1465,14 @@ function start(token, user) {
@@ -1306,10 +1465,14 @@ function start(token, user) {
if (!hit) return;
if (!hit) return;
if (hit.e.frozen) phys.setFrozen(hit.e, false); // thaw to move (server re-freezes on veto)
if (hit.e.frozen) phys.setFrozen(hit.e, false); // thaw to move (server re-freezes on veto)
pgState.held = hit.e;
pgState.held = hit.e;
- pgState.lastYaw = my.yaw; // view-follow rotation baseline
- // gmod grip: remember WHERE on the object you grabbed it
+ pgState.lastYaw = my.yaw; // view-follow rotation baseline (cars: yaw-only)
+ // gmod grip: remember WHERE on the object you grabbed it, and its
+ // orientation RELATIVE TO YOUR VIEW — it stays locked in your view frame
pgState.anchor = phys.grabLocal(hit.e, hit.point.x, hit.point.y, hit.point.z);
pgState.anchor = phys.grabLocal(hit.e, hit.point.x, hit.point.y, hit.point.z);
pgState.anchorOut = new hit.e.body.position.constructor();
pgState.anchorOut = new hit.e.body.position.constructor();
+ const bq = hit.e.body.quaternion;
+ pgState.relQ = camera.quaternion.clone().invert()
+ .multiply(new THREE.Quaternion(bq.x, bq.y, bq.z, bq.w));
pgState.dist = Math.max(1.6, Math.min(hit.e.isCar ? 16 : 12, 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);
@@ -1336,6 +1499,7 @@ function start(token, user) {
@@ -1336,6 +1499,7 @@ function start(token, user) {
} else if (my.held === 'pistol') firePistol();
} else if (my.held === 'pistol') firePistol();
else if (ITEMS[my.held]?.type === 'build') placeBuild();
else if (ITEMS[my.held]?.type === 'build') placeBuild();
else if (ITEMS[my.held]?.type === 'prop') placeProp();
else if (ITEMS[my.held]?.type === 'prop') placeProp();
+ else if (ITEMS[my.held]?.type === 'car') placeCar();
});
});
addEventListener('mouseup', (e) => {
addEventListener('mouseup', (e) => {
if (e.button !== 0) return;
if (e.button !== 0) return;
@@ -1708,7 +1872,12 @@ function start(token, user) {
@@ -1708,7 +1872,12 @@ function start(token, user) {
let label = null;
let label = null;
if (my.seat) label = 'Stand up';
if (my.seat) label = 'Stand up';
else if (mg.myPongTable) label = 'Swing! (or walk away to quit)';
else if (mg.myPongTable) label = 'Swing! (or walk away to quit)';
- else if (nearest && nearest.type !== 'c4' && nearest.type !== 'chess') label = nearest.label;
+ else if (nearest?.type === 'car') { // live label: drive / ride along / locked
+ const car = W.cars?.find(c => c.id === nearest.data.car);
+ if (car?.driver && car.driver !== myId) label = 'Hop in — ride along 🚌';
+ else if (car && car.ownerName && !carUsable(car)) label = `🔒 ${car.ownerName}'s car — E rides shotgun`;
+ else label = nearest.label;
+ } else if (nearest && nearest.type !== 'c4' && nearest.type !== 'chess') label = nearest.label;
else if (nearestSeat) label = nearestSeat.type === 'couch' ? 'Sit on the couch' : 'Take a seat';
else if (nearestSeat) label = nearestSeat.type === 'couch' ? 'Sit on the couch' : 'Take a seat';
if (label) { promptEl.classList.remove('hidden'); promptText.textContent = label; }
if (label) { promptEl.classList.remove('hidden'); promptText.textContent = label; }
else promptEl.classList.add('hidden');
else promptEl.classList.add('hidden');
@@ -1718,6 +1887,7 @@ function start(token, user) {
@@ -1718,6 +1887,7 @@ function start(token, user) {
function doAction() {
function doAction() {
if (mg.inArcade()) return;
if (mg.inArcade()) return;
if (carState.driving) { exitCar(); return; }
if (carState.driving) { exitCar(); return; }
+ if (carState.riding) { net.send({ t: 'car', id: carState.riding.car.id, op: 'unsit' }); exitPassengerLocal(carState.riding.car); return; }
if (my.seat) { standUp(); return; }
if (my.seat) { standUp(); return; }
if (mg.myPongTable) { mg.swing(); return; }
if (mg.myPongTable) { mg.swing(); return; }
if (nearest) {
if (nearest) {
@@ -1844,12 +2014,14 @@ function start(token, user) {
@@ -1844,12 +2014,14 @@ function start(token, user) {
requestAnimationFrame(frame);
requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), .05);
const dt = Math.min(clock.getDelta(), .05);
- // physgun rotate mode: hold E while carrying — the mouse turns the OBJECT
- if (pgState.held && input.keys.KeyE && !pgState.held.isCar) {
+ // physgun rotate mode: hold E while carrying — the mouse turns the OBJECT.
+ // Rotations are applied in VIEW space (screen axes), so "mouse right" always
+ // spins the prop the same way no matter where you're facing — gmod feel.
+ if (pgState.held && input.keys.KeyE && !pgState.held.isCar && pgState.relQ) {
const rdx = input.lookDX * .005, rdy = input.lookDY * .005;
const rdx = input.lookDX * .005, rdy = input.lookDY * .005;
input.lookDX = 0; input.lookDY = 0; // eaten: camera stays put
input.lookDX = 0; input.lookDY = 0; // eaten: camera stays put
- if (rdx) phys.yawBody(pgState.held, -rdx, pgState.anchor);
- if (rdy) phys.rotateBody(pgState.held, Math.cos(my.yaw), 0, -Math.sin(my.yaw), -rdy, pgState.anchor);
+ if (rdx) pgState.relQ.premultiply(pgQTmp.setFromAxisAngle(PG_Y, -rdx));
+ if (rdy) pgState.relQ.premultiply(pgQTmp.setFromAxisAngle(PG_X, -rdy));
}
}
// look (sensitivity + optional inverted Y; FP allows full look up/down)
// look (sensitivity + optional inverted Y; FP allows full look up/down)
my.lookVX = input.lookDX;
my.lookVX = input.lookDX;
@@ -1871,7 +2043,7 @@ function start(token, user) {
@@ -1871,7 +2043,7 @@ function start(token, user) {
}
}
if (input.jumpQueued) {
if (input.jumpQueued) {
input.jumpQueued = false;
input.jumpQueued = false;
- if (carState.driving) { /* Space is the handbrake while driving */ }
+ if (carState.driving || carState.riding) { /* Space is the handbrake while driving */ }
else if (mg.myPongTable && !my.seat) mg.swing();
else if (mg.myPongTable && !my.seat) mg.swing();
else if (my.onGround && !my.seat) { my.vy = my.crouch ? 4.2 : 5.4; my.onGround = false; my.crouch = false; beep(520, .04, 'sine', .05); }
else if (my.onGround && !my.seat) { my.vy = my.crouch ? 4.2 : 5.4; my.onGround = false; my.crouch = false; beep(520, .04, 'sine', .05); }
else if (my.seat) standUp();
else if (my.seat) standUp();
@@ -1898,6 +2070,9 @@ function start(token, user) {
@@ -1898,6 +2070,9 @@ function start(token, user) {
v: [+b.velocity.x.toFixed(2), +b.velocity.y.toFixed(2), +b.velocity.z.toFixed(2)],
v: [+b.velocity.x.toFixed(2), +b.velocity.y.toFixed(2), +b.velocity.z.toFixed(2)],
});
});
}
}
+ } else if (carState.riding) {
+ ridePose(); // passenger: glued to the seat, E hops out
+ my.anim = 'sit';
} else if (my.seat) {
} else if (my.seat) {
if (mvLen > .4) standUp();
if (mvLen > .4) standUp();
my.anim = 'sit';
my.anim = 'sit';
@@ -1928,7 +2103,9 @@ function start(token, user) {
@@ -1928,7 +2103,9 @@ function start(token, user) {
my.vel.x += wx * add; my.vel.z += wz * add;
my.vel.x += wx * add; my.vel.z += wz * add;
}
}
let nx = my.x + my.vel.x * dt, nz = my.z + my.vel.z * dt;
let nx = my.x + my.vel.x * dt, nz = my.z + my.vel.z * dt;
+ W.feetY = my.y; // surface-probing colliders judge "climbable" from foot height
[nx, nz] = resolveCollisions(nx, nz, .34);
[nx, nz] = resolveCollisions(nx, nz, .34);
+ [nx, nz] = phys.resolvePlayer(nx, nz, my.y, .34, pgState.held); // props shove people
if (dt > 0) { my.vel.x = (nx - my.x) / dt; my.vel.z = (nz - my.z) / dt; } // wall clip
if (dt > 0) { my.vel.x = (nx - my.x) / dt; my.vel.z = (nz - my.z) / dt; } // wall clip
my.x = nx; my.z = nz;
my.x = nx; my.z = nz;
const hsp = Math.hypot(my.vel.x, my.vel.z);
const hsp = Math.hypot(my.vel.x, my.vel.z);
@@ -1942,13 +2119,16 @@ function start(token, user) {
@@ -1942,13 +2119,16 @@ function start(token, user) {
my.anim = !my.onGround ? (hsp > 5 ? 'run' : 'walk')
my.anim = !my.onGround ? (hsp > 5 ? 'run' : 'walk')
: my.crouch ? (hsp > .5 ? 'crouchwalk' : 'crouch')
: my.crouch ? (hsp > .5 ? 'crouchwalk' : 'crouch')
: hsp > 5.2 ? 'run' : hsp > .5 ? 'walk' : 'idle';
: hsp > 5.2 ? 'run' : hsp > .5 ? 'walk' : 'idle';
- // gravity
+ // gravity — against the walkable surface under our feet (props, ramps,
+ // car roofs), not just the world floor
+ const gY = phys.groundAt(my.x, my.z, my.y);
+ if (my.onGround && gY > my.y && gY - my.y <= .68) { my.y = gY; my.vy = 0; } // step/ramp up
my.vy -= 16 * dt;
my.vy -= 16 * dt;
my.y += my.vy * dt;
my.y += my.vy * dt;
- if (my.y <= 0) {
+ if (my.y <= gY + 1e-4 && my.vy <= 0) {
if (!my.onGround && my.vy < -5) beep(140, .05, 'sine', .05); // landing thud
if (!my.onGround && my.vy < -5) beep(140, .05, 'sine', .05); // landing thud
- my.y = 0; my.vy = 0; my.onGround = true;
- }
+ my.y = gY; my.vy = 0; my.onGround = true;
+ } else if (my.y > gY + .03) my.onGround = false;
// walked away from pong table?
// walked away from pong table?
if (mg.myPongTable) {
if (mg.myPongTable) {
// anchors from the rotated cafeteria group carry world coords in wx/wz
// anchors from the rotated cafeteria group carry world coords in wx/wz
@@ -1974,11 +2154,18 @@ function start(token, user) {
@@ -1974,11 +2154,18 @@ function start(token, user) {
const vl = Math.hypot(vx3, vy3, vz3);
const vl = Math.hypot(vx3, vy3, vz3);
if (vl > 22) { vx3 *= 22 / vl; vy3 *= 22 / vl; vz3 *= 22 / vl; }
if (vl > 22) { vx3 *= 22 / vl; vy3 *= 22 / vl; vz3 *= 22 / vl; }
b.velocity.set(vx3, vy3, vz3);
b.velocity.set(vx3, vy3, vz3);
- // gmod grip: the object turns with your view and otherwise holds its pose
- const dyaw = my.yaw - (pgState.lastYaw ?? my.yaw);
- pgState.lastYaw = my.yaw;
b.angularVelocity.set(0, 0, 0);
b.angularVelocity.set(0, 0, 0);
- if (!input.keys.KeyE && Math.abs(dyaw) > 1e-4) phys.yawBody(held, dyaw, pgState.anchor); // view-follow (E = manual rotate instead)
+ if (held.isCar) {
+ // cars: yaw-only view-follow (full view-lock would pitch them absurdly)
+ const dyaw = my.yaw - (pgState.lastYaw ?? my.yaw);
+ pgState.lastYaw = my.yaw;
+ if (Math.abs(dyaw) > 1e-4) phys.yawBody(held, dyaw, pgState.anchor);
+ } else if (pgState.relQ) {
+ // gmod grip: the prop is rigid in your VIEW frame — turning or pitching
+ // your view carries it along, E+mouse edits the relative orientation
+ pgQTmp2.copy(camera.quaternion).multiply(pgState.relQ);
+ phys.setQuatAnchored(held, pgQTmp2.x, pgQTmp2.y, pgQTmp2.z, pgQTmp2.w, pgState.anchor);
+ }
}
}
}
}
phys.step(dt, my);
phys.step(dt, my);
@@ -1989,6 +2176,7 @@ function start(token, user) {
@@ -1989,6 +2176,7 @@ function start(token, user) {
my.y = Math.max(0, ce2.body.position.y - .15);
my.y = Math.max(0, ce2.body.position.y - .15);
my.ry = car.ry;
my.ry = car.ry;
}
}
+ if (carState.riding) ridePose(); // passengers re-sync too (no chop at speed)
// avatar
// avatar
myAvatar.group.position.set(my.x, my.y + (myAvatar.bobY || 0), my.z);
myAvatar.group.position.set(my.x, my.y + (myAvatar.bobY || 0), my.z);
@@ -2181,12 +2369,34 @@ function start(token, user) {
@@ -2181,12 +2369,34 @@ function start(token, user) {
if (hl2.parent !== e2.car.group) {
if (hl2.parent !== e2.car.group) {
e2.car.group.add(hl2, hl2.target);
e2.car.group.add(hl2, hl2.target);
hl2.position.set(0, .85, 2.0);
hl2.position.set(0, .85, 2.0);
- hl2.target.position.set(0, .25, 15);
+ hl2.target.position.set(0, 0, 24);
}
}
- hl2.intensity = .25 + (1 - (W.dayFactor ?? 1)) * 3.4;
+ hl2.intensity = .6 + (1 - (W.dayFactor ?? 1)) * 9;
}
}
for (; hi < hlPool.length; hi++) hlPool[hi].intensity = 0;
for (; hi < hlPool.length; hi++) hlPool[hi].intensity = 0;
}
}
+ // campfires: hand the pooled lights to the nearest ones, flame flicker
+ {
+ const fires = [];
+ for (const e2 of phys.props.values()) {
+ if (e2.kind !== 'campfire') continue;
+ const p2 = e2.body.interpolatedPosition;
+ fires.push([(p2.x - my.x) ** 2 + (p2.z - my.z) ** 2, e2]);
+ }
+ if (fires.length > 1) fires.sort((a, b) => a[0] - b[0]);
+ const tNow = performance.now() / 1000;
+ for (let i2 = 0; i2 < cfPool.length; i2++) {
+ const pl = cfPool[i2];
+ const fe = fires[i2]?.[1];
+ if (!fe) { pl.intensity = 0; continue; }
+ const p2 = fe.body.interpolatedPosition;
+ pl.position.set(p2.x, p2.y + .6, p2.z);
+ const flick = 1 + Math.sin(tNow * 11 + i2 * 2.1) * .1 + Math.sin(tNow * 27.7 + i2) * .06;
+ pl.intensity = (.9 + (1 - (W.dayFactor ?? 1)) * 1.6) * flick;
+ const fl = fe.mesh.userData?.flame;
+ if (fl) { fl.scale.set(1, 1 + Math.sin(tNow * 9.3 + i2) * .16, 1); fl.rotation.y = tNow * 1.7; }
+ }
+ }
// flashlight follows your view
// flashlight follows your view
torch.intensity = my.held === 'flashlight' ? .5 + (1 - (W.dayFactor ?? 1)) * 3.6 : 0;
torch.intensity = my.held === 'flashlight' ? .5 + (1 - (W.dayFactor ?? 1)) * 3.6 : 0;
@@ -2213,6 +2423,8 @@ function start(token, user) {
@@ -2213,6 +2423,8 @@ function start(token, user) {
action: doAction, nearest: () => nearest?.id || nearestSeat?.id || null,
action: doAction, nearest: () => nearest?.id || nearestSeat?.id || null,
spawnProp, physgunGrab, physgunRelease, pgState, pgPick, sleepers: sleeperMap, inv: invApi,
spawnProp, physgunGrab, physgunRelease, pgState, pgPick, sleepers: sleeperMap, inv: invApi,
drops: worldDrops, dropItem: dropItemInWorld, knockIt,
drops: worldDrops, dropItem: dropItemInWorld, knockIt,
+ carState, myCarIds, enterCar, placeCar, doAction, net,
+ get myId() { return myId; },
};
};
addEventListener('wheel', (e) => {
addEventListener('wheel', (e) => {
@@ -124,6 +124,41 @@ export const PHYS_KINDS = {
@@ -124,6 +124,41 @@ export const PHYS_KINDS = {
return g;
return g;
},
},
},
},
+ campfire: {
+ label: 'Campfire', icon: '🔥', mass: 6,
+ shape: () => new CANNON.Cylinder(.5, .5, .42, 8),
+ build() {
+ const g = new THREE.Group();
+ const logM = new THREE.MeshStandardMaterial({ color: 0x6a4a2c, roughness: .95 });
+ for (let i = 0; i < 3; i++) {
+ const log = new THREE.Mesh(new THREE.CylinderGeometry(.07, .08, .8, 7), logM);
+ log.rotation.z = Math.PI / 2 - .35;
+ log.rotation.y = i * Math.PI * 2 / 3;
+ log.position.y = -.05;
+ g.add(log);
+ }
+ const stoneM = new THREE.MeshStandardMaterial({ color: 0x7d7f83, roughness: 1 });
+ for (let i = 0; i < 7; i++) {
+ const a = i / 7 * Math.PI * 2;
+ const st = new THREE.Mesh(new THREE.SphereGeometry(.09, 6, 5), stoneM);
+ st.position.set(Math.cos(a) * .48, -.14, Math.sin(a) * .48);
+ st.scale.y = .7;
+ g.add(st);
+ }
+ const flame = new THREE.Mesh(
+ new THREE.ConeGeometry(.2, .55, 7),
+ new THREE.MeshBasicMaterial({ color: 0xff8a2a, transparent: true, opacity: .85, blending: THREE.AdditiveBlending, depthWrite: false }));
+ flame.position.y = .22;
+ const core = new THREE.Mesh(
+ new THREE.ConeGeometry(.1, .34, 6),
+ new THREE.MeshBasicMaterial({ color: 0xffe08a, transparent: true, opacity: .9, blending: THREE.AdditiveBlending, depthWrite: false }));
+ core.position.y = .16;
+ g.add(flame, core);
+ g.userData.flame = flame;
+ g.userData.flameCore = core;
+ return g;
+ },
+ },
cone: {
cone: {
label: 'Traffic cone', icon: '🚧', mass: 1.4,
label: 'Traffic cone', icon: '🚧', mass: 1.4,
shape: () => new CANNON.Cylinder(.05, .2, .55, 8),
shape: () => new CANNON.Cylinder(.05, .2, .55, 8),
@@ -179,7 +214,7 @@ export function initPhysics(scene) {
@@ -179,7 +214,7 @@ export function initPhysics(scene) {
world.addContactMaterial(new CANNON.ContactMaterial(propMat, carMat, { friction: .35, restitution: .3 }));
world.addContactMaterial(new CANNON.ContactMaterial(propMat, carMat, { friction: .35, restitution: .3 }));
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 || []) {
+ function addCar(car) {
// hitbox matched to the visual car (4.1×1.8 body, 1.3 roof): chassis box
// 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
// 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 hl = car.hl ?? (car.hl = 2.05), hw = car.hw ?? (car.hw = .9);
@@ -200,10 +235,76 @@ export function initPhysics(scene) {
@@ -200,10 +235,76 @@ export function initPhysics(scene) {
body.sleepTimeLimit = .6;
body.sleepTimeLimit = .6;
body.sleep();
body.sleep();
world.addBody(body);
world.addBody(body);
- cars.set(car.id, { id: car.id, car, body, isCar: true, owned: false, grabbedBy: null, remote: false });
+ car.col.hAt = (x, z) => surfaceYAt(body, x, z); // roofs/hoods are climbable terrain
+ const e = { id: car.id, car, body, isCar: true, owned: false, grabbedBy: null, remote: false };
+ cars.set(car.id, e);
+ return e;
}
}
+ for (const car of W.cars || []) addCar(car);
const V = (x, y, z) => new CANNON.Vec3(x, y, z);
const V = (x, y, z) => new CANNON.Vec3(x, y, z);
+ // ---- walkable surfaces: single-body downward raycast ----
+ // Used for standing on props/cars and for climbing rotated pieces (ramps):
+ // an AABB alone turns a tilted ramp into an invisible wall.
+ const wRay = new CANNON.Ray();
+ wRay.mode = CANNON.Ray.CLOSEST;
+ wRay.skipBackfaces = true;
+ const wRes = new CANNON.RaycastResult();
+ function surfaceYAt(body, x, z, fromY = null) {
+ body.updateAABB();
+ const top = body.aabb.upperBound.y;
+ wRay.from.set(x, fromY == null ? top + .6 : fromY, z);
+ wRay.to.set(x, body.aabb.lowerBound.y - .3, z);
+ wRes.reset();
+ wRay.intersectBody(body, wRes);
+ return wRes.hasHit ? wRes.hitPointWorld.y : null; // null: (x,z) misses the actual shape
+ }
+ const STEP = .68; // max walk-up height — one box, not one crate
+ // highest walkable surface under (x,z) reachable from foot height py
+ function groundAt(x, z, py) {
+ let g = 0;
+ const consider = (body) => {
+ body.updateAABB();
+ const bb = body.aabb;
+ if (x < bb.lowerBound.x - .05 || x > bb.upperBound.x + .05 ||
+ z < bb.lowerBound.z - .05 || z > bb.upperBound.z + .05) return;
+ if (bb.lowerBound.y > py + STEP + 1.2) return; // floating way overhead
+ const h = surfaceYAt(body, x, z);
+ if (h != null && h <= py + STEP && h > g) g = h;
+ };
+ for (const e of props.values()) consider(e.body);
+ for (const e of cars.values()) consider(e.body);
+ return g;
+ }
+ // players are solid: shove the walking capsule out of dynamic props so a
+ // physgunned crate PUSHES people instead of clipping through them
+ function resolvePlayer(px, pz, py, r, exclude = null) {
+ for (const e of props.values()) {
+ if (e === exclude || e.frozen) continue;
+ const bb = e.body.aabb;
+ if (bb.upperBound.y - py <= STEP) continue; // low enough to step on
+ if (bb.lowerBound.y > py + 1.6) continue; // floating overhead
+ const nx = Math.max(bb.lowerBound.x, Math.min(px, bb.upperBound.x));
+ const nz = Math.max(bb.lowerBound.z, Math.min(pz, bb.upperBound.z));
+ const dx = px - nx, dz = pz - nz;
+ const d2 = dx * dx + dz * dz;
+ if (d2 >= r * r) continue;
+ if (d2 < 1e-9) { // center inside: push out the nearest face
+ const pushes = [
+ [bb.lowerBound.x - r - px, 0], [bb.upperBound.x + r - px, 0],
+ [0, bb.lowerBound.z - r - pz], [0, bb.upperBound.z + r - pz],
+ ];
+ pushes.sort((a, b) => Math.abs(a[0] + a[1]) - Math.abs(b[0] + b[1]));
+ px += pushes[0][0]; pz += pushes[0][1];
+ } else {
+ const d = Math.sqrt(d2);
+ px = nx + dx / d * r;
+ pz = nz + dz / d * r;
+ }
+ }
+ return [px, pz];
+ }
+
// arcade forces on a real chassis: engine + brake along the body's forward
// arcade forces on a real chassis: engine + brake along the body's forward
// axis, hard-set yaw rate for snappy steering, lateral-grip kill, and a
// axis, hard-set yaw rate for snappy steering, lateral-grip kill, and a
// gentle self-righting torque so a flipped car can be driven back onto
// gentle self-righting torque so a flipped car can be driven back onto
@@ -339,6 +440,9 @@ export function initPhysics(scene) {
@@ -339,6 +440,9 @@ export function initPhysics(scene) {
e.walkCol.z0 = b.aabb.lowerBound.z; e.walkCol.z1 = b.aabb.upperBound.z;
e.walkCol.z0 = b.aabb.lowerBound.z; e.walkCol.z1 = b.aabb.upperBound.z;
// don't block feet on things lying flat on the ground or floating overhead
// don't block feet on things lying flat on the ground or floating overhead
e.walkCol.off = b.aabb.upperBound.y < .45 || b.aabb.lowerBound.y > 1.6;
e.walkCol.off = b.aabb.upperBound.y < .45 || b.aabb.lowerBound.y > 1.6;
+ // rotated pieces (ramps!): block only where the actual surface is too
+ // tall to step onto — the resolver probes this instead of the raw AABB
+ e.walkCol.hAt = (x, z) => surfaceYAt(b, x, z);
if (e.inter) { e.inter.x = b.position.x; e.inter.z = b.position.z; }
if (e.inter) { e.inter.x = b.position.x; e.inter.z = b.position.z; }
} else {
} else {
b.type = CANNON.Body.DYNAMIC;
b.type = CANNON.Body.DYNAMIC;
@@ -381,7 +485,9 @@ export function initPhysics(scene) {
@@ -381,7 +485,9 @@ export function initPhysics(scene) {
function playerPush(px, py, pz, dt) {
function playerPush(px, py, pz, dt) {
for (const e of props.values()) {
for (const e of props.values()) {
if (e.frozen) continue;
if (e.frozen) continue;
+ if (e.grabbedBy && e.grabbedBy !== myId) continue; // don't fight the holder's stream
const b = e.body;
const b = e.body;
+ if (py > b.aabb.upperBound.y - .25) continue; // standing on it, not into it
const dx = b.position.x - px, dz = b.position.z - pz;
const dx = b.position.x - px, dz = b.position.z - pz;
const d = Math.hypot(dx, dz);
const d = Math.hypot(dx, dz);
const rad = .34 + (e.kind === 'crate' ? .62 : .42);
const rad = .34 + (e.kind === 'crate' ? .62 : .42);
@@ -536,6 +642,19 @@ export function initPhysics(scene) {
@@ -536,6 +642,19 @@ export function initPhysics(scene) {
b.position.set(aw.x - anchorTmp.x, aw.y - anchorTmp.y, aw.z - anchorTmp.z);
b.position.set(aw.x - anchorTmp.x, aw.y - anchorTmp.y, aw.z - anchorTmp.z);
} else yawQ.mult(b.quaternion, b.quaternion);
} else yawQ.mult(b.quaternion, b.quaternion);
}
}
+ // hard-set orientation while keeping the grab anchor pinned in world space —
+ // the physgun's view-relative grip drives this every frame
+ function setQuatAnchored(e, qx, qy, qz, qw, local = null) {
+ const b = e.body;
+ if (local) {
+ const aw = new CANNON.Vec3();
+ b.quaternion.vmult(local, anchorTmp);
+ aw.set(b.position.x + anchorTmp.x, b.position.y + anchorTmp.y, b.position.z + anchorTmp.z);
+ b.quaternion.set(qx, qy, qz, qw);
+ b.quaternion.vmult(local, anchorTmp);
+ b.position.set(aw.x - anchorTmp.x, aw.y - anchorTmp.y, aw.z - anchorTmp.z);
+ } else b.quaternion.set(qx, qy, qz, qw);
+ }
function yawBody(e, dyaw, local = null) {
function yawBody(e, dyaw, local = null) {
const b = e.body;
const b = e.body;
yawQ.setFromAxisAngle(V(0, 1, 0), dyaw);
yawQ.setFromAxisAngle(V(0, 1, 0), dyaw);
@@ -564,7 +683,8 @@ export function initPhysics(scene) {
@@ -564,7 +683,8 @@ export function initPhysics(scene) {
return {
return {
world, props, cars, add, remove, claim, applyState, applyCarState, sendState, sendCarState,
world, props, cars, add, remove, claim, applyState, applyCarState, sendState, sendCarState,
- drive, step, smack, raycast, yawBody, rotateBody, grabLocal, anchorWorld, addStatic, removeStatic, setFrozen, PHYS_KINDS,
+ drive, step, smack, raycast, yawBody, rotateBody, setQuatAnchored, grabLocal, anchorWorld,
+ addStatic, removeStatic, setFrozen, addCar, groundAt, resolvePlayer, PHYS_KINDS,
setMyId(id) { myId = id; },
setMyId(id) { myId = id; },
};
};
}
}
@@ -2422,31 +2422,13 @@ function exteriorEast(scene) {
@@ -2422,31 +2422,13 @@ function exteriorEast(scene) {
}
}
stripes.count = si;
stripes.count = si;
scene.add(stripes);
scene.add(stripes);
- const carCols = [0x8a8f96, 0x2e3f55, 0x7a2c2c, 0xd8d8d8, 0x1a1c1e, 0x4a6b52];
W.cars = [];
W.cars = [];
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 10; i++) {
- const g = new THREE.Group();
- const cM = new THREE.MeshStandardMaterial({ color: carCols[i % carCols.length], roughness: .35, metalness: .5 });
- g.add(box(1.8, .55, 4.1, cM, 0, .55, 0));
- g.add(box(1.6, .5, 2.2, new THREE.MeshStandardMaterial({ color: 0x1c2126, roughness: .2, metalness: .3 }), 0, 1.05, -.2));
- // headlights + taillights so driving at speed reads well
- for (const s2 of [-1, 1]) {
- g.add(box(.3, .12, .06, new THREE.MeshStandardMaterial({ color: 0xfff2cf, emissive: 0xfff2cf, emissiveIntensity: .5 }), s2 * .55, .62, 2.05));
- g.add(box(.3, .1, .06, new THREE.MeshStandardMaterial({ color: 0xd93025, emissive: 0x8a1610, emissiveIntensity: .5 }), s2 * .55, .62, -2.05));
- }
- for (const [wx, wz] of [[-.85, 1.3], [.85, 1.3], [-.85, -1.3], [.85, -1.3]]) {
- const wh = cyl(.32, .32, .22, M.blackMetal, wx, .32, wz); wh.rotation.z = Math.PI / 2; g.add(wh);
- }
const s = i % 2 ? 1 : -1;
const s = i % 2 ? 1 : -1;
- g.position.set(101.4 + (i % 3) * 5.6, 0, s * (9 + Math.floor(i / 2) * 3.1));
- g.rotation.y = Math.PI / 2;
- scene.add(g);
- const col = { x0: g.position.x - 2.1, x1: g.position.x + 2.1, z0: g.position.z - 1, z1: g.position.z + 1, off: false };
- W.colliders.push(col);
- const id = 'car' + i;
- const it = { id, type: 'car', x: g.position.x, z: g.position.z, r: 3.4, label: 'Drive the car 🚗', data: { car: id } };
- inter(it);
- W.cars.push({ id, group: g, col, inter: it, driver: null, x: g.position.x, z: g.position.z, ry: Math.PI / 2 });
+ addWorldCar({
+ id: 'car' + i, color: CAR_COLORS[i % CAR_COLORS.length],
+ x: 101.4 + (i % 3) * 5.6, z: s * (9 + Math.floor(i / 2) * 3.1), ry: Math.PI / 2,
+ });
}
}
// flag pole + hydrant + food truck (from trips)
// flag pole + hydrant + food truck (from trips)
const pole = cyl(.05, .07, 9, M.chrome, 94.5, 4.5, -10, 8);
const pole = cyl(.05, .07, 9, M.chrome, 94.5, 4.5, -10, 8);
@@ -2974,6 +2956,7 @@ function streets(scene) {
@@ -2974,6 +2956,7 @@ function streets(scene) {
}
}
export function buildWorld(scene) {
export function buildWorld(scene) {
+ W.scene = scene; // late spawns (crafted cars) need somewhere to land
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
@@ -3031,10 +3014,50 @@ export function buildWorld(scene) {
@@ -3031,10 +3014,50 @@ export function buildWorld(scene) {
return W;
return W;
}
}
+// sedan builder shared by the parking-lot fleet and player-crafted cars.
+// Returns the W.cars entry (caller registers the physics body via phys.addCar).
+export const CAR_COLORS = [0x8a8f96, 0x2e3f55, 0x7a2c2c, 0xd8d8d8, 0x1a1c1e, 0x4a6b52, 0xc9541e, 0x6a3f8f];
+export function addWorldCar({ id, color, x, z, ry, ownerName = null }) {
+ const scene = W.scene;
+ const g = new THREE.Group();
+ const cM = new THREE.MeshStandardMaterial({ color, roughness: .35, metalness: .5 });
+ g.add(box(1.8, .55, 4.1, cM, 0, .55, 0));
+ g.add(box(1.6, .5, 2.2, new THREE.MeshStandardMaterial({ color: 0x1c2126, roughness: .2, metalness: .3 }), 0, 1.05, -.2));
+ // headlights + taillights so driving at speed reads well
+ for (const s2 of [-1, 1]) {
+ g.add(box(.3, .12, .06, new THREE.MeshStandardMaterial({ color: 0xfff2cf, emissive: 0xfff2cf, emissiveIntensity: .5 }), s2 * .55, .62, 2.05));
+ g.add(box(.3, .1, .06, new THREE.MeshStandardMaterial({ color: 0xd93025, emissive: 0x8a1610, emissiveIntensity: .5 }), s2 * .55, .62, -2.05));
+ }
+ for (const [wx, wz] of [[-.85, 1.3], [.85, 1.3], [-.85, -1.3], [.85, -1.3]]) {
+ const wh = cyl(.32, .32, .22, M.blackMetal, wx, .32, wz); wh.rotation.z = Math.PI / 2; g.add(wh);
+ }
+ g.position.set(x, 0, z);
+ g.rotation.y = ry;
+ g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
+ scene.add(g);
+ const col = { x0: x - 2.1, x1: x + 2.1, z0: z - 1, z1: z + 1, off: false };
+ W.colliders.push(col);
+ const it = {
+ id, type: 'car', x, z, r: 3.4,
+ label: ownerName ? `${ownerName}'s car 🚗` : 'Drive the car 🚗', data: { car: id },
+ };
+ inter(it);
+ const car = { id, group: g, col, inter: it, driver: null, x, z, ry, ownerName, pax: {} };
+ W.cars.push(car);
+ return car;
+}
+
// circle-vs-AABB collision resolve, returns corrected [x,z]
// circle-vs-AABB collision resolve, returns corrected [x,z]
export function resolveCollisions(x, z, r) {
export function resolveCollisions(x, z, r) {
for (const c of W.colliders) {
for (const c of W.colliders) {
if (c.off) continue;
if (c.off) continue;
+ // surface-probing colliders (frozen props, cars): only block where the
+ // actual surface is too tall to step onto — tilted ramps stay walkable.
+ // Probe at the contact point on the box, not the player center.
+ if (c.hAt && x >= c.x0 - .34 && x <= c.x1 + .34 && z >= c.z0 - .34 && z <= c.z1 + .34) {
+ const h = c.hAt(Math.max(c.x0, Math.min(x, c.x1)), Math.max(c.z0, Math.min(z, c.z1)));
+ if (h == null || h - (W.feetY || 0) <= .68) continue;
+ }
const nx = Math.max(c.x0, Math.min(x, c.x1));
const nx = Math.max(c.x0, Math.min(x, c.x1));
const nz = Math.max(c.z0, Math.min(z, c.z1));
const nz = Math.max(c.z0, Math.min(z, c.z1));
const dx = x - nx, dz = z - nz;
const dx = x - nx, dz = z - nz;
@@ -66,6 +66,14 @@
@@ -66,6 +66,14 @@
<!-- health + combat feedback -->
<!-- health + combat feedback -->
<div id="hp-wrap"><div id="hp-fill"></div></div>
<div id="hp-wrap"><div id="hp-fill"></div></div>
<div id="dmg-flash"></div>
<div id="dmg-flash"></div>
+ <div id="death-screen" class="hidden">
+ <div id="ds-inner">
+ <h1>YOU DIED</h1>
+ <div id="ds-sub"></div>
+ <div id="ds-stats"></div>
+ <div id="ds-hint">back at the front walkway — click to dismiss</div>
+ </div>
+ </div>
<div id="hitmark"></div>
<div id="hitmark"></div>
<div id="headshot-tag">HEADSHOT</div>
<div id="headshot-tag">HEADSHOT</div>
<div id="fps-meter" class="hidden"></div>
<div id="fps-meter" class="hidden"></div>
@@ -96,13 +96,22 @@ try { savedWorld = JSON.parse(fs.readFileSync(WORLD_FILE, 'utf8')); } catch {}
@@ -96,13 +96,22 @@ try { savedWorld = JSON.parse(fs.readFileSync(WORLD_FILE, 'utf8')); } catch {}
const sleepers = new Map(Object.entries(savedWorld.sleepers || {})); // key -> {key,name,vest,ap,guest,x,y,z,ry,ts}
const sleepers = new Map(Object.entries(savedWorld.sleepers || {})); // key -> {key,name,vest,ap,guest,x,y,z,ry,ts}
const props = new Map(Object.entries(savedWorld.props || {})); // id -> {id,kind,p:[x,y,z],q:[x,y,z,w]}
const props = new Map(Object.entries(savedWorld.props || {})); // id -> {id,kind,p:[x,y,z],q:[x,y,z,w]}
for (const [cid, c] of Object.entries(savedWorld.cars || {})) {
for (const [cid, c] of Object.entries(savedWorld.cars || {})) {
- if (!/^(car\d+|lambo)$/.test(cid)) continue;
+ if (!/^(car\d+|lambo|pc\d+)$/.test(cid)) continue;
if (Array.isArray(c.p)) cars[cid] = { p: c.p, q: Array.isArray(c.q) ? c.q : [0, 0, 0, 1], driver: null };
if (Array.isArray(c.p)) cars[cid] = { p: c.p, q: Array.isArray(c.q) ? c.q : [0, 0, 0, 1], driver: null };
else if (Number.isFinite(c.x)) { // migrate pre-rigid-body saves {x,z,ry}
else if (Number.isFinite(c.x)) { // migrate pre-rigid-body saves {x,z,ry}
const h = (c.ry || 0) / 2;
const h = (c.ry || 0) / 2;
cars[cid] = { p: [c.x, .55, c.z], q: [0, +Math.sin(h).toFixed(3), 0, +Math.cos(h).toFixed(3)], driver: null };
cars[cid] = { p: [c.x, .55, c.z], q: [0, +Math.sin(h).toFixed(3), 0, +Math.cos(h).toFixed(3)], driver: null };
}
}
+ if (cars[cid] && cid.startsWith('pc')) { // crafted personal cars keep their owner
+ cars[cid].owner = c.owner || null;
+ cars[cid].ownerName = c.ownerName || null;
+ cars[cid].color = c.color || null;
+ }
}
}
+let nextCarId = Object.keys(cars).reduce((m, id) => id.startsWith('pc') ? Math.max(m, Number(id.slice(2)) || 0) : m, 0) + 1;
+// what clients get to see about a car: everything except the owner's account key
+const pubCar = (c) => ({ p: c.p, q: c.q, driver: c.driver, ownerName: c.ownerName || null, color: c.color || null, pax: c.pax || {} });
+const pubCars = () => Object.fromEntries(Object.entries(cars).map(([id, c]) => [id, pubCar(c)]));
let nextPhysId = [...props.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(2)) || 0), 0) + 1;
let nextPhysId = [...props.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(2)) || 0), 0) + 1;
// legacy static builds become FROZEN props (darkrp-style: everything is a prop)
// legacy static builds become FROZEN props (darkrp-style: everything is a prop)
for (const b of Object.values(savedWorld.builds || {})) {
for (const b of Object.values(savedWorld.builds || {})) {
@@ -162,13 +171,28 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
@@ -162,13 +171,28 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
}
}
// death: spill everything, reset, respawn at the walkway — used by pvp AND npcs
// death: spill everything, reset, respawn at the walkway — used by pvp AND npcs
-function killPlayer(t, byName) {
+function killPlayer(t, byName, info = {}) {
stats.kills++;
stats.kills++;
worldDirty = true;
worldDirty = true;
spillInventory(t.key, t.x, t.z);
spillInventory(t.key, t.x, t.z);
+ // pry them out of any car first — respawning "inside" a car stuck players
+ for (const [cid, c] of Object.entries(cars)) {
+ if (c.driver === t.id) { c.driver = null; broadcast({ t: 'car', id: cid, op: 'exit', p: c.p, q: c.q }); }
+ if (c.pax) for (const [seat, pid] of Object.entries(c.pax)) {
+ if (pid === t.id) { delete c.pax[seat]; broadcast({ t: 'car', id: cid, op: 'unsit', seat: +seat, by: t.id }); }
+ }
+ }
+ const lived = Math.max(0, Math.round((Date.now() - (t.bornAt || Date.now())) / 1000));
+ const kills = t.myKills || 0;
t.hp = 100;
t.hp = 100;
+ t.bornAt = Date.now();
+ t.myKills = 0;
t.x = 105 + Math.random() * 3; t.y = 0; t.z = -1 + Math.random() * 2;
t.x = 105 + Math.random() * 3; t.y = 0; t.z = -1 + Math.random() * 2;
- if (t.ws.readyState === 1) t.ws.send(JSON.stringify({ t: 'died', by: byName, x: t.x, z: t.z }));
+ if (t.ws.readyState === 1) t.ws.send(JSON.stringify({
+ t: 'died', by: byName, weapon: info.weapon || null,
+ dist: Number.isFinite(info.dist) ? +info.dist.toFixed(1) : null,
+ hs: info.hs === true, lived, kills, x: t.x, z: t.z,
+ }));
broadcast({ t: 'hp', id: t.id, hp: 100 });
broadcast({ t: 'hp', id: t.id, hp: 100 });
}
}
@@ -230,7 +254,7 @@ setInterval(() => { // 5Hz herd-and-horde tick
@@ -230,7 +254,7 @@ setInterval(() => { // 5Hz herd-and-horde tick
else if (Date.now() - n.atkT > 1200) {
else if (Date.now() - n.atkT > 1200) {
n.atkT = Date.now();
n.atkT = Date.now();
best.hp = (best.hp ?? 100) - 8;
best.hp = (best.hp ?? 100) - 8;
- if (best.hp <= 0) { killPlayer(best, 'a zombie'); sys(`🧟 ${best.name} got eaten by a zombie`); }
+ if (best.hp <= 0) { killPlayer(best, 'a zombie', { weapon: 'zombie', dist: bd }); sys(`🧟 ${best.name} got eaten by a zombie`); }
else broadcast({ t: 'hp', id: best.id, hp: best.hp, by: -1 });
else broadcast({ t: 'hp', id: best.id, hp: best.hp, by: -1 });
}
}
}
}
@@ -284,7 +308,7 @@ wss.on('connection', (ws, req) => {
@@ -284,7 +308,7 @@ wss.on('connection', (ws, req) => {
const p = {
const p = {
id, key: user.key, name: user.name, vest: user.vest, admin: user.admin, ap: user.ap, guest: !!user.guest, ws,
id, key: user.key, name: user.name, vest: user.vest, admin: user.admin, ap: user.ap, guest: !!user.guest, ws,
x: 105 + Math.random() * 3, y: 0, z: -1 + Math.random() * 2, ry: -Math.PI / 2,
x: 105 + Math.random() * 3, y: 0, z: -1 + Math.random() * 2, ry: -Math.PI / 2,
- anim: 'idle', seat: null, held: null, lastChat: 0, hp: 100,
+ anim: 'idle', seat: null, held: null, lastChat: 0, hp: 100, bornAt: Date.now(), myKills: 0,
};
};
// returning player: wake their napper and resume exactly where they dozed off
// returning player: wake their napper and resume exactly where they dozed off
const slept = sleepers.get(user.key);
const slept = sleepers.get(user.key);
@@ -306,11 +330,12 @@ wss.on('connection', (ws, req) => {
@@ -306,11 +330,12 @@ wss.on('connection', (ws, req) => {
drops: [...drops.values()],
drops: [...drops.values()],
mapEdits,
mapEdits,
players: [...players.values()].filter(q => q.id !== id).map(pubState),
players: [...players.values()].filter(q => q.id !== id).map(pubState),
+ myCarIds: Object.entries(cars).filter(([, c]) => c.owner === user.key).map(([cid]) => cid),
c4: { a: c4.a.state(), b: c4.b.state() },
c4: { a: c4.a.state(), b: c4.b.state() },
chess: chess.state(),
chess: chess.state(),
pong: { a: pong.a.state(), b: pong.b.state() },
pong: { a: pong.a.state(), b: pong.b.state() },
highscores: store.getHighscores(),
highscores: store.getHighscores(),
- cars,
+ cars: pubCars(),
sleepers: [...sleepers.values()],
sleepers: [...sleepers.values()],
props: [...props.values()],
props: [...props.values()],
npcs: npcSnapshot(),
npcs: npcSnapshot(),
@@ -333,6 +358,9 @@ wss.on('connection', (ws, req) => {
@@ -333,6 +358,9 @@ wss.on('connection', (ws, req) => {
players.delete(id);
players.delete(id);
for (const [cid, c] of Object.entries(cars)) {
for (const [cid, c] of Object.entries(cars)) {
if (c.driver === id) { c.driver = null; broadcast({ t: 'car', id: cid, op: 'exit', p: c.p, q: c.q }); }
if (c.driver === id) { c.driver = null; broadcast({ t: 'car', id: cid, op: 'exit', p: c.p, q: c.q }); }
+ if (c.pax) for (const [seat, pid] of Object.entries(c.pax)) {
+ if (pid === id) { delete c.pax[seat]; broadcast({ t: 'car', id: cid, op: 'unsit', seat: +seat, by: id }); }
+ }
}
}
for (const g of [c4.a, c4.b]) { g.leave(p.key); broadcast({ t: 'c4', s: g.state() }); }
for (const g of [c4.a, c4.b]) { g.leave(p.key); broadcast({ t: 'c4', s: g.state() }); }
chess.leave(p.key); broadcast({ t: 'chess', s: chess.state() });
chess.leave(p.key); broadcast({ t: 'chess', s: chess.state() });
@@ -454,9 +482,37 @@ function handle(p, m) {
@@ -454,9 +482,37 @@ function handle(p, m) {
break;
break;
}
}
case 'car': {
case 'car': {
+ if (m.op === 'spawn') { // crafted personal car: owner-locked driver's seat
+ const now = Date.now();
+ if (now - (p.lastCarSpawn || 0) < 1000) return;
+ p.lastCarSpawn = now;
+ const fail = (text) => { if (p.ws.readyState === 1) { p.ws.send(JSON.stringify({ t: 'car', op: 'spawnfail' })); p.ws.send(JSON.stringify({ t: 'sys', text })); } };
+ const mine = Object.values(cars).filter(c2 => c2.owner === p.key).length;
+ const crafted = Object.keys(cars).filter(k => k.startsWith('pc')).length;
+ if (mine >= 3) { fail('🚗 Three personal cars is plenty — the lot has limits.'); return; }
+ if (crafted >= 30) { fail('🚗 The lot is full of crafted cars already.'); return; }
+ const x = clampX(+m.x || 0), z = clampZ(+m.z || 0), h = (+m.ry || 0) / 2;
+ const cid = 'pc' + nextCarId++;
+ cars[cid] = {
+ p: [x, .6, z], q: [0, +Math.sin(h).toFixed(3), 0, +Math.cos(h).toFixed(3)],
+ driver: null, owner: p.key, ownerName: p.name,
+ color: [0x8a8f96, 0x2e3f55, 0x7a2c2c, 0xd8d8d8, 0x4a6b52, 0xc9541e, 0x6a3f8f][Math.floor(Math.random() * 7)],
+ pax: {},
+ };
+ worldDirty = true;
+ broadcast({ t: 'car', op: 'spawn', id: cid, car: pubCar(cars[cid]), owner: p.id });
+ sys(`🔧 ${p.name} built their own car!`);
+ return;
+ }
const id = String(m.id || '').slice(0, 12);
const id = String(m.id || '').slice(0, 12);
- if (!/^(car\d+|lambo)$/.test(id)) return;
+ if (!/^(car\d+|lambo|pc\d+)$/.test(id)) return;
const c = cars[id] || (cars[id] = { p: null, q: [0, 0, 0, 1], driver: null });
const c = cars[id] || (cars[id] = { p: null, q: [0, 0, 0, 1], driver: null });
+ const canUse = () => !c.owner || c.owner === p.key || store.isFriend(c.owner, p.key);
+ const deny = (text) => {
+ if (p.ws.readyState !== 1) return;
+ p.ws.send(JSON.stringify({ t: 'car', op: 'deny', id }));
+ if (text) p.ws.send(JSON.stringify({ t: 'sys', text }));
+ };
const readPose = () => { // full rigid-body pose: position + quaternion (+velocity)
const readPose = () => { // full rigid-body pose: position + quaternion (+velocity)
const pos = Array.isArray(m.p) ? m.p.map(Number) : null;
const pos = Array.isArray(m.p) ? m.p.map(Number) : null;
const q = Array.isArray(m.q) ? m.q.map(Number) : null;
const q = Array.isArray(m.q) ? m.q.map(Number) : null;
@@ -469,6 +525,7 @@ function handle(p, m) {
@@ -469,6 +525,7 @@ function handle(p, m) {
const vel = () => Array.isArray(m.v) && m.v.length === 3 && m.v.every(Number.isFinite) ? m.v : null;
const vel = () => Array.isArray(m.v) && m.v.length === 3 && m.v.every(Number.isFinite) ? m.v : null;
if (m.op === 'enter') {
if (m.op === 'enter') {
if (c.driver && c.driver !== p.id) return;
if (c.driver && c.driver !== p.id) return;
+ if (!canUse()) { deny(`🔒 That's ${c.ownerName || 'someone'}'s car — only they (and their friends) can drive it. Hop in the back instead!`); return; }
c.driver = p.id;
c.driver = p.id;
stats.joyrides++;
stats.joyrides++;
if (id === 'lambo') stats.lambo++;
if (id === 'lambo') stats.lambo++;
@@ -479,16 +536,31 @@ function handle(p, m) {
@@ -479,16 +536,31 @@ function handle(p, m) {
if (c.driver !== p.id) return;
if (c.driver !== p.id) return;
c.driver = null;
c.driver = null;
broadcast({ t: 'car', id, op: 'exit', p: c.p, q: c.q });
broadcast({ t: 'car', id, op: 'exit', p: c.p, q: c.q });
+ } else if (m.op === 'sit') { // passenger seats: anyone can ride along
+ const seat = Math.floor(Number(m.seat));
+ if (![0, 1, 2].includes(seat)) return;
+ c.pax = c.pax || {};
+ if (c.pax[seat] && c.pax[seat] !== p.id) return; // taken
+ for (const [s2, pid] of Object.entries(c.pax)) if (pid === p.id) delete c.pax[s2];
+ c.pax[seat] = p.id;
+ broadcast({ t: 'car', id, op: 'sit', seat, by: p.id });
+ } else if (m.op === 'unsit') {
+ c.pax = c.pax || {};
+ for (const [s2, pid] of Object.entries(c.pax)) {
+ if (pid === p.id) { delete c.pax[s2]; broadcast({ t: 'car', id, op: 'unsit', seat: +s2, by: p.id }); }
+ }
} else if (m.op === 'state') { // driver's stream
} else if (m.op === 'state') { // driver's stream
if (c.driver !== p.id) return;
if (c.driver !== p.id) return;
if (!readPose()) return;
if (!readPose()) return;
broadcast({ t: 'car', id, op: 'state', p: c.p, q: c.q, v: vel(), driver: p.id }, p.id);
broadcast({ t: 'car', id, op: 'state', p: c.p, q: c.q, v: vel(), driver: p.id }, p.id);
} else if (m.op === 'phys') { // driverless nudge: physgun, shoves, rolling to a stop
} else if (m.op === 'phys') { // driverless nudge: physgun, shoves, rolling to a stop
if (c.driver) return;
if (c.driver) return;
+ if (!canUse()) return; // personal cars only move for their owner & friends
if (!readPose()) return;
if (!readPose()) return;
broadcast({ t: 'car', id, op: 'phys', p: c.p, q: c.q, v: vel(), owner: p.id }, p.id);
broadcast({ t: 'car', id, op: 'phys', p: c.p, q: c.q, v: vel(), owner: p.id }, p.id);
} else if (m.op === 'grab' || m.op === 'drop') { // physgun beam relay
} else if (m.op === 'grab' || m.op === 'drop') { // physgun beam relay
if (m.op === 'grab' && c.driver) return;
if (m.op === 'grab' && c.driver) return;
+ if (m.op === 'grab' && !canUse()) { deny(`🔒 That car belongs to ${c.ownerName || 'someone else'}.`); return; }
broadcast({ t: 'car', id, op: m.op, by: p.id }, p.id);
broadcast({ t: 'car', id, op: m.op, by: p.id }, p.id);
}
}
break;
break;
@@ -499,7 +571,7 @@ function handle(p, m) {
@@ -499,7 +571,7 @@ function handle(p, m) {
if (now - (p.lastSpawn || 0) < 250) return;
if (now - (p.lastSpawn || 0) < 250) return;
p.lastSpawn = now;
p.lastSpawn = now;
if (props.size >= 150) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '📦 Prop limit reached (150) — grab some with the physgun and press X to clear them.' })); return; }
if (props.size >= 150) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '📦 Prop limit reached (150) — grab some with the physgun and press X to clear them.' })); return; }
- const KINDS = ['box', 'crate', 'ball', 'barrel', 'melon', 'cone', 'wall', 'floor', 'door'];
+ const KINDS = ['box', 'crate', 'ball', 'barrel', 'melon', 'cone', 'campfire', 'wall', 'floor', 'door'];
if (!KINDS.includes(m.kind)) return;
if (!KINDS.includes(m.kind)) return;
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
if (pos.length !== 3 || !pos.every(Number.isFinite)) return;
if (pos.length !== 3 || !pos.every(Number.isFinite)) return;
@@ -676,7 +748,8 @@ function handle(p, m) {
@@ -676,7 +748,8 @@ function handle(p, m) {
return;
return;
}
}
// death: everything they carried spills where they fell, then respawn
// death: everything they carried spills where they fell, then respawn
- killPlayer(t, p.name);
+ p.myKills = (p.myKills || 0) + 1;
+ killPlayer(t, p.name, { weapon, dist: Math.hypot(p.x - t.x, p.z - t.z), hs });
sys(`💀 ${p.name} clocked ${t.name} with ${label} — their stuff hit the floor`);
sys(`💀 ${p.name} clocked ${t.name} with ${label} — their stuff hit the floor`);
break;
break;
}
}
@@ -127,7 +127,7 @@ export function setAppearance(key, ap) {
@@ -127,7 +127,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','flashlight',
'paddle','broom','tapegun','tube','wrench','banana','physgun','flashlight',
'wood','stone','axe','pickaxe','stoneaxe','stonepick','pistol','wall','floor','door',
'wood','stone','axe','pickaxe','stoneaxe','stonepick','pistol','wall','floor','door',
- 'box','crate','ball','barrel','melon','cone',
+ 'box','crate','ball','barrel','melon','cone','campfire','carkit',
'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);