openvibers/breakroomsimulator Commits

Cars you can craft & share, passengers, death screen, campfires, gmod physgun grip, solid props & walkable ramps, brighter nights

OP OpenVibers committed 30 Jul 2026 · 5bd07e5c60 · 9 files, +524 −69

- Craftable car kit (90 wood + 60 stone): spawns your own car, owner-locked
  driver seat (server-enforced, friends allowed), persisted across restarts
- Passenger seats: E on an occupied car rides along (3 seats), E hops out
- Death screen: killer, weapon, distance, time survived, kills that life;
  dying in a car now frees the car and never respawns you inside it
- Campfire craftable (10 wood + 6 stone): flickering pooled light, animated flame
- Physgun grip is now view-relative like gmod: the prop stays rigid in your
  view frame (yaw AND pitch follow), E+mouse rotates in screen space
- Props push players: you can't phase a crate through someone anymore
- Frozen rotated props are walkable ramps (cannon ray ground sampling +
  surface-probing walk colliders); you can stand on props and car roofs
- Headlights much brighter and wider; night ambient/moon/sky raised again

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

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex db8bc57..df06898 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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; }
ddiff --git a/public/js/inventory.js b/public/js/inventory.js
iindex 698a8c5..aa1ecef 100644
---- a/public/js/inventory.js
++++ b/public/js/inventory.js
@@@ -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 } },
ddiff --git a/public/js/lighting.js b/public/js/lighting.js
iindex f94ceb8..55155b0 100644
---- a/public/js/lighting.js
++++ b/public/js/lighting.js
@@@ -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 readable
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);
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;
ddiff --git a/public/js/main.js b/public/js/main.js
iindex 5f7dd54..aa55dd9 100644
---- a/public/js/main.js
++++ b/public/js/main.js
@@@ -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 map
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);
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 tell
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) => {
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 tip
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 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 frame
pgState.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 people
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;
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 up
my.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)
// avatar
myAvatar.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 view
torch.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) => {
ddiff --git a/public/js/physics.js b/public/js/physics.js
iindex 491714c..75b638c 100644
---- a/public/js/physics.js
++++ b/public/js/physics.js
@@@ -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 believably
const 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 overhead
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; }
} 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 stream
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 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; },
};
}
ddiff --git a/public/js/world.js b/public/js/world.js
iindex 13646fa..e23bb41 100644
---- a/public/js/world.js
++++ b/public/js/world.js
@@@ -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 land
mats();
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;
ddiff --git a/public/play.html b/public/play.html
iindex d1594d0..2ca6ada 100644
---- a/public/play.html
++++ b/public/play.html
@@@ -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>
ddiff --git a/server/server.js b/server/server.js
iindex 6c232e7..e3da476 100644
---- a/server/server.js
++++ b/server/server.js
@@@ -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 tick
else 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 off
const 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 stream
if (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 stop
if (c.driver) return;
++ if (!canUse()) return; // personal cars only move for their owner & friends
if (!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 relay
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);
}
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;
}
ddiff --git a/server/store.js b/server/store.js
iindex 9604457..c5e76ee 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -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);