Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@ deploy/coolify.sh ssh 'node /opt/agent-office/bin/agent-office.js accounts invit

## Controls

Floor travel now waits in an elevator cabin while graphics warm up, with quiet music and an **Emergency · let me out** button after 15 seconds if it gets stuck. Automatic graphics adjustments reduce sustained rendering lag. See [smoother floor travel](docs/performance.md).

| Key | Action |
| --- | --- |
| W A S D | Walk (hold Shift to run) |
Expand Down
13 changes: 13 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Smoother floor travel

When you choose another floor, an elevator cabin stays on screen while the destination is built, requested models settle, graphics programs compile, and the first frames upload textures. The doors then open automatically. The cabin shows the current preparation step and elapsed time rather than a guessed percentage.

Quiet original elevator music follows your office volume, music volume, and both mute controls. It stops when the cabin closes, and is silent while the tab is hidden. A browser that has not yet allowed audio may keep the ride silent.

After **15 seconds**, **Emergency · let me out** appears. It closes a stalled loading screen and restores movement. If the server has not delivered the destination, the ride is cancelled and the office requests the floor you came from. If the destination has arrived but its graphics are still warming, it lets you out there. Preparation errors expose the button immediately. Late preparation results cannot take control after you leave.

The top-right **✕** and **Esc** can end the wait at any time; they return you to the same mouse-look handling as other windows. Cancelling during the initial door animation prevents its delayed floor request from sending you away afterwards.

The 3D renderer starts with a pixel-ratio ceiling of **1.25** instead of rendering up to four device pixels per CSS pixel. Text and interface panels stay at the browser's normal resolution. While you are moving around, sustained frame times above 30 ms gradually lower the 3D resolution, down to 0.65. Above 40 ms, expensive real-time shadows and the extra outline rendering pass turn off for that page session. Hidden tabs, open windows and floor-loading spikes are excluded from these decisions. Reloading resets the automatic graphics adjustments.

The elevator covers preparation work; it cannot make a frozen browser thread responsive while that thread is executing a synchronous task. Saved floors, files, and project data are unchanged by graphics adjustments.
13 changes: 9 additions & 4 deletions src/client/core/travel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,16 +136,19 @@ export function installTravel(ctx: Ctx, core: CoreState, parts: TravelParts) {
const inside = inElevator(player.pos.x, player.pos.z);
const within = floorId === store.floor;
core.trip = { floor: floorId, how: 'elevator', garage, timer: window.setTimeout(tripFailed, 10_000) };
const trip = core.trip;
player.enabled = false;
player.clearKeys();
lift()?.setOpen(false);
// Wait for the doors to shut on you, then dim the lights and go.
setTimeout(
() => {
if (core.trip !== trip) return;
fade(true);
setTimeout(() => {
if (core.trip !== trip) return;
placeInCar(inside ? player.pos : undefined, garage && within);
if (within) setTimeout(rodeWithin, 700);
if (within) setTimeout(() => { if (core.trip === trip) rodeWithin(); }, 700);
// Down to the garage from the roof: the bottom floor, and down its shaft once it's here (see arrive).
else net.send({ t: 'floor.go', floor: floorId, ...(garage ? { at: { x: player.pos.x, y: streetBelow(0), z: player.pos.z, rotY: 0 } } : {}) });
}, 320);
Expand Down Expand Up @@ -210,18 +213,20 @@ export function installTravel(ctx: Ctx, core: CoreState, parts: TravelParts) {
// The floor list isn't a window, so nothing else stops a walk over to someone on this floor.
if (!keepWalking) parts.walking.stopWalkingTo();
core.trip = { floor: floorId, how: 'switch', timer: window.setTimeout(tripFailed, 10_000) };
const trip = core.trip;
player.enabled = false;
player.clearKeys();
fade(true, true);
setTimeout(() => net.send({ t: 'floor.go', floor: floorId, at: standingAt(floorId) }), 170);
setTimeout(() => { if (core.trip === trip) net.send({ t: 'floor.go', floor: floorId, at: standingAt(floorId) }); }, 170);
}

/** Through the ceiling up the ladder, or through the floor down one: the lights dip as you pass. */
function travel(floorId: string, how: Grip, at: Arrival) {
if (core.trip) return;
core.trip = { floor: floorId, how, timer: window.setTimeout(tripFailed, 10_000) };
const trip = core.trip;
fade(true, true);
setTimeout(() => net.send({ t: 'floor.go', floor: floorId, at }), 170);
setTimeout(() => { if (core.trip === trip) net.send({ t: 'floor.go', floor: floorId, at }); }, 170);
}

/** The floor never came (it's gone, or the office is unreachable): back where you were. */
Expand Down Expand Up @@ -339,5 +344,5 @@ export function installTravel(ctx: Ctx, core: CoreState, parts: TravelParts) {
doorsOpen();
}

return { syncStack, takenAway, showElevator, lift, ride, switchFloor, travel, leaveRoofFor, setPlace, arrive, pending };
return { syncStack, takenAway, showElevator, lift, ride, switchFloor, travel, leaveRoofFor, setPlace, arrive, pending, cancel: tripFailed };
}
40 changes: 40 additions & 0 deletions src/client/features/floor-loading/gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export const EMERGENCY_MS = 15_000;

export interface Clock {
set(fn: () => void, ms: number): unknown;
clear(id: unknown): void;
}

const clock: Clock = {
set: (fn, ms) => setTimeout(fn, ms),
clear: id => clearTimeout(id as ReturnType<typeof setTimeout>),
};

/** One ride owns its timer and readiness; late work from a cancelled ride cannot open doors. */
export class RideGate {
readonly abort = new AbortController();
private timer: unknown;
private closed = false;
private waiting = false;

constructor(private ready: () => void, private emergency: (error?: unknown) => void, private time = clock) {
this.timer = time.set(() => { if (!this.closed) this.emergency(); }, EMERGENCY_MS);
}

wait(work: Promise<unknown>) {
if (this.waiting || this.closed) return;
this.waiting = true;
void work.then(() => {
if (this.closed) return;
this.close();
this.ready();
}, error => { if (!this.closed) this.emergency(error); });
}

close() {
if (this.closed) return;
this.closed = true;
this.time.clear(this.timer);
this.abort.abort();
}
}
88 changes: 88 additions & 0 deletions src/client/features/floor-loading/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { Ctx, Trip } from '../../core/context';
import type { CoreState } from '../../core/ctx';
import type { Parts } from '../../core/parts';
import { ROOF, ROOF_NAME } from '../../../shared/rooftop';
import { store } from '../../state';
import { modalOpen } from '../../ui/dom';
import { onModelsProgress } from '../../world/models';
import { RideGate } from './gate';
import { adaptiveGraphics } from './quality';
import { ElevatorMusic } from './sound';
import { elevatorWait } from './ui';

type Deps = Pick<Parts, 'travel' | 'stage'>;
type Waiting = { gate: RideGate; ui: ReturnType<typeof elevatorWait>; music: ElevatorMusic; trip: Trip; origin: string | null; entered: boolean };

export function installFloorLoading(ctx: Ctx, core: CoreState, parts: Deps) {
adaptiveGraphics(ctx, core, parts);
let active: Waiting | null = null;

function release(cancel = false) {
const ride = active;
if (!ride) return;
active = null;
ride.gate.close(); ride.music.stop(); ride.ui.close();
if (cancel && core.trip === ride.trip) {
parts.travel.cancel();
// Ordered after the destination request, this also defeats a delayed server reply.
if (ride.origin && !ride.entered) ctx.net.send({ t: 'floor.go', floor: ride.origin });
}
document.getElementById('fade')?.classList.remove('on');
parts.travel.lift()?.setOpen(!!store.floor);
ctx.player.enabled = !modalOpen();
}

function begin(trip: Trip) {
if (active || (trip.how !== 'elevator' && trip.how !== 'switch')) return;
// The emergency control replaces the old ten-second automatic trip timeout.
clearTimeout(trip.timer);
const ui = elevatorWait(trip.floor === ROOF ? ROOF_NAME : store.floors.find(f => f.id === trip.floor)?.name ?? 'Your floor', () => release(true));
const music = new ElevatorMusic();
const gate = new RideGate(() => release(), error => ui.emergency(error));
active = { gate, ui, music, trip, origin: store.floor, entered: false };
music.start(() => ctx.settings);
}

function arrived() {
const ride = active;
if (!ride || ride.entered || store.floor !== ride.trip.floor) return;
ride.entered = true;
if (store.floor !== ROOF) ride.ui.destination(store.currentFloor()?.name ?? ctx.world().plan.name);
ride.gate.wait(prepare(ride));
}

async function prepare(ride: Waiting) {
const signal = ride.gate.abort.signal;
const frame = () => new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
ride.ui.status('Unpacking the floor…');
await frame();
if (signal.aborted) return;
await new Promise<void>(resolve => {
let settle = 0, off = () => {};
const finish = () => { clearTimeout(settle); off(); signal.removeEventListener('abort', finish); resolve(); };
signal.addEventListener('abort', finish, { once: true });
off = onModelsProgress(p => { clearTimeout(settle); if (p.done >= p.asked) settle = window.setTimeout(finish, 100); });
});
if (signal.aborted) return;
ride.ui.status('Warming up graphics…');
await ctx.renderer.compileAsync(ctx.scene, ctx.camera);
if (signal.aborted) return;
// Let texture uploads and the first outlined frames run behind the cabin.
ride.ui.status('Checking the doors…');
await frame(); await frame();
}

ctx.ticks.add('pre', () => {
if (!active && core.trip) begin(core.trip);
// The garage is a ride on the same floor; no floor.enter message is sent.
if (active && !core.trip && store.floor === active.trip.floor) arrived();
});
ctx.messages.on('floor.enter', () => { if (core.trip) begin(core.trip); }, 'before');
ctx.messages.on('floor.enter', arrived);
ctx.ticks.add('hud', () => {
if (!active) return;
ctx.player.enabled = false;
parts.travel.lift()?.setOpen(false);
});
window.addEventListener('pagehide', () => release(true));
}
39 changes: 39 additions & 0 deletions src/client/features/floor-loading/quality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Ctx } from '../../core/context';
import type { CoreState } from '../../core/ctx';
import type { Parts } from '../../core/parts';
import { modalOpen } from '../../ui/dom';

/** Decisions use sustained frame time, never one expensive first frame or a background tab. */
export class FrameBudget {
private total = 0;
private count = 0;
sample(ms: number): number | null {
if (ms <= 0 || ms > 250) { this.reset(); return null; }
this.total += ms;
if (++this.count < 60 || this.total < 2000) return null;
const mean = this.total / this.count;
this.reset();
return mean;
}
reset() { this.total = this.count = 0; }
}

export function adaptiveGraphics(ctx: Ctx, core: CoreState, parts: Pick<Parts, 'stage'>) {
const budget = new FrameBudget();
const ceiling = Math.min(window.devicePixelRatio || 1, 1.25);
ctx.renderer.setPixelRatio(ceiling);
ctx.canvas.dataset.renderScale = ceiling.toFixed(2);
ctx.ticks.add('pre', ({ delta }) => {
if (document.hidden || modalOpen() || core.trip) { budget.reset(); return; }
const mean = budget.sample(delta * 1000);
if (mean === null || mean <= 30) return;
const scale = Math.max(0.65, ctx.renderer.getPixelRatio() * 0.8);
ctx.renderer.setPixelRatio(scale);
ctx.canvas.dataset.renderScale = scale.toFixed(2);
if (mean > 40) {
ctx.renderer.shadowMap.enabled = false;
parts.stage.effect.enabled = false;
ctx.canvas.dataset.graphics = 'reduced';
}
});
}
38 changes: 38 additions & 0 deletions src/client/features/floor-loading/sound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Settings } from '../../state';

/** Quiet original electric-piano loop. Its context exists only while a ride is on screen. */
export class ElevatorMusic {
private audio: AudioContext | null = null;
private bus: GainNode | null = null;
private timer = 0;
private step = 0;
start(settings: () => Settings) {
try {
this.audio = new AudioContext();
this.bus = this.audio.createGain();
this.bus.connect(this.audio.destination);
void this.audio.resume().catch(() => {});
const play = () => {
const a = this.audio, bus = this.bus;
if (!a || !bus) return;
const s = settings();
bus.gain.value = document.hidden || s.muted || s.musicMuted ? 0 : s.volume * s.music * 0.08;
const notes = [261.63, 329.63, 392, 329.63, 220, 293.66, 349.23, 293.66];
const oscillator = a.createOscillator(), envelope = a.createGain();
oscillator.type = 'sine'; oscillator.frequency.value = notes[this.step++ % notes.length];
envelope.gain.setValueAtTime(0, a.currentTime);
envelope.gain.linearRampToValueAtTime(0.65, a.currentTime + 0.015);
envelope.gain.exponentialRampToValueAtTime(0.001, a.currentTime + 0.65);
oscillator.connect(envelope); envelope.connect(bus);
oscillator.start(); oscillator.stop(a.currentTime + 0.7);
oscillator.onended = () => { oscillator.disconnect(); envelope.disconnect(); };
};
play(); this.timer = window.setInterval(play, 420);
} catch { this.stop(); }
}
stop() {
clearInterval(this.timer);
if (this.audio) void this.audio.close().catch(() => {});
this.audio = null; this.bus = null;
}
}
15 changes: 15 additions & 0 deletions src/client/features/floor-loading/ui.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.backdrop.floor-wait-backdrop { background: #172329; z-index: 10; }
.modal.floor-wait { width: min(560px, 100%); padding: 22px; background: #e0e3e1; color: #243039; border-color: #243039; box-shadow: 0 12px 0 #0a151b; }
.floor-wait header { display: flex; justify-content: space-between; align-items: center; font-weight: 900; font-size: 20px; }
.floor-wait-display { margin: 18px 0; padding: 16px; background: #142a25; color: #c2f6c7; border-radius: 12px; text-align: center; }
.floor-wait-display > span { font-size: 12px; letter-spacing: .18em; }
.floor-wait-display h2 { margin: 8px 0 0; font-size: 23px; }
.floor-wait-doors { display: flex; height: min(28vh, 220px); border: 5px solid #627177; border-radius: 8px; overflow: hidden; }
.floor-wait-doors span { flex: 1; background: repeating-linear-gradient(90deg, #a5b2b6, #c9d0d1 28px, #b1bfc2 30px); }
.floor-wait-doors span + span { border-left: 4px solid #40545c; }
.floor-wait-status { min-height: 44px; font-weight: 900; margin: 20px 0 8px; }
.floor-wait-help { font-size: 13px; line-height: 1.5; }
.floor-wait-footer { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-top: 20px; }
.floor-wait-time { font-size: 13px; }
.floor-wait .floor-wait-emergency { background: #a32b31; border-color: #541319; color: white; margin-left: auto; }
.floor-wait-emergency[hidden] { display: none; }
29 changes: 29 additions & 0 deletions src/client/features/floor-loading/ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { h, openModal } from '../../ui/dom';
import './ui.css';

export function elevatorWait(name: string, leave: () => void) {
const destination = h('h2', {}, name);
const status = h('p.floor-wait-status', { role: 'status', 'aria-live': 'polite' }, 'Calling your floor…');
const emergency = h('button.btn.floor-wait-emergency', { type: 'button', hidden: true, onclick: leave }, 'Emergency · let me out');
const elapsed = h('span.floor-wait-time', {}, '0 seconds');
const cabin = h('div.modal.floor-wait', { role: 'dialog', 'aria-modal': true, 'aria-label': 'Elevator loading' },
h('header', {}, h('span', {}, '🛗 Elevator')),
h('div.floor-wait-display', {}, h('span', {}, 'NEXT STOP'), destination),
h('div.floor-wait-doors', { 'aria-hidden': true }, h('span'), h('span')),
status, h('p.floor-wait-help', {}, 'Preparing the floor before the doors open. Soft elevator music follows your sound settings.'),
h('div.floor-wait-footer', {}, elapsed, emergency),
);
const modal = openModal(cabin, { backdropCloses: false, doing: '🛗 waiting for the floor', onClose: leave });
modal.backdrop.classList.add('floor-wait-backdrop');
const started = performance.now();
const timer = window.setInterval(() => { elapsed.textContent = `${Math.floor((performance.now() - started) / 1000)} seconds`; }, 250);
return {
destination: (name: string) => { destination.textContent = name; },
status: (text: string) => { status.textContent = text; },
emergency: (error?: unknown) => {
emergency.hidden = false;
status.textContent = error ? 'This floor could not finish preparing. Use Emergency to leave.' : 'Taking longer than expected. You can leave using Emergency.';
},
close: () => { clearInterval(timer); modal.close(); },
};
}
2 changes: 2 additions & 0 deletions src/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { installKeyboard, installKeyGuards } from './input/keyboard';
import { installFocus } from './input/focus';
import { installPointer } from './input/pointer';
import { installArcade } from './features/arcade';
import { installFloorLoading } from './features/floor-loading';
import { installBar } from './features/bar';
import { installBarGames } from './features/bargames';
import { installBasketball } from './features/basketball';
Expand Down Expand Up @@ -179,6 +180,7 @@ installChat(ctx);
parts.talk = installVoice(ctx, { tv: parts.tv });
installDictation(ctx);
parts.hud = installHud(ctx, core, parts);
installFloorLoading(ctx, core, parts);

// ---- Main loop ---------------------------------------------------------------------------------------
fitWindow(ctx);
Expand Down
50 changes: 50 additions & 0 deletions tests/floor-loading.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { RideGate, EMERGENCY_MS, type Clock } from '../src/client/features/floor-loading/gate';
import { FrameBudget } from '../src/client/features/floor-loading/quality';

function fixture() {
const jobs = new Map<unknown, () => void>();
const clock: Clock = { set(fn, ms) { assert.equal(ms, EMERGENCY_MS); const id = {}; jobs.set(id, fn); return id; }, clear: id => { jobs.delete(id); } };
let ready = 0, emergency = 0;
const gate = new RideGate(() => ready++, () => emergency++, clock);
return { gate, jobs, counts: () => ({ ready, emergency }) };
}

test('doors wait for preparation, and a finished ride clears its emergency timer', async () => {
const f = fixture(); let finish!: () => void;
f.gate.wait(new Promise<void>(resolve => { finish = resolve; }));
assert.deepEqual(f.counts(), { ready: 0, emergency: 0 });
finish(); await Promise.resolve();
assert.deepEqual(f.counts(), { ready: 1, emergency: 0 });
assert.equal(f.jobs.size, 0);
});

test('a stalled ride offers emergency after fifteen seconds without silently releasing controls', () => {
assert.equal(EMERGENCY_MS, 15_000);
const f = fixture(); [...f.jobs.values()][0]();
assert.deepEqual(f.counts(), { ready: 0, emergency: 1 });
});

test('closing a ride aborts its work and ignores late shader completion', async () => {
const f = fixture(); let finish!: () => void;
f.gate.wait(new Promise<void>(resolve => { finish = resolve; }));
f.gate.close(); f.gate.close(); finish(); await Promise.resolve();
assert.equal(f.gate.abort.signal.aborted, true);
assert.equal(f.jobs.size, 0);
assert.deepEqual(f.counts(), { ready: 0, emergency: 0 });
});

test('preparation failures offer escape and do not mark the destination ready', async () => {
const f = fixture(); f.gate.wait(Promise.reject(Error('GPU unavailable'))); await Promise.resolve();
assert.deepEqual(f.counts(), { ready: 0, emergency: 1 }); f.gate.close();
});

test('frame budget ignores loading stalls and measures sustained rendering costs', () => {
const budget = new FrameBudget();
for (let i = 0; i < 59; i++) assert.equal(budget.sample(40), null);
assert.equal(budget.sample(40), 40);
for (let i = 0; i < 59; i++) budget.sample(40);
assert.equal(budget.sample(2000), null);
assert.equal(budget.sample(40), null);
});