On the office door, the map is of the office
office.lumbridgecorp.com and tera.lumbridgecorp.com are one bundle behind two
names, and the office door had two ways of forgetting which one you came in
through.
The plan panel was the loud one. `createMinimap` is built per city, from
`city.world`, and nothing swapped it when you stepped inside — so the one piece
of chrome whose entire job is to answer "where am I" went on drawing the Bay
Area while the scene in front of it was a thirty-four-metre floor plate. Not
merely unhelpful: pointing at another county.
So there is a second widget for the other place. `officeMinimap.ts` reads the
`Plan` the scene was already built from — no second resolution pass, no geometry
re-derived — and draws it: room slabs, the walls as their `solid` runs only,
which leaves a gap at every door and arch because `Plan` had already split them
for the collider; a glazing hairline across each window, without which the
reference office's north edge simply is not there; furniture at its real
footprint from the asset registry, turned by its own yaw, so four benches of
twelve read as four benches of twelve; room names over a halo, because a room's
centroid is usually the middle of its own desks. Same corner, same `M`, same
camera footprint, same click-to-seek, same keyboard aim. The footprint is cut
against `level.floorY` rather than y=0, which is the same number on a one-storey
pack and a whole storey out on any other.
It is a second module rather than a mode inside the first because the two share
their shape and almost none of their content: one projects lat/lng through a
`World`, rasterises a coastline and a hillshade and follows the sun; this one is
already in metres and lives under a fixed interior rig where the sun does not
reach. One file would have been an `if (city)` at the top of every function.
It arrives in the office chunk, not the entry chunk. It reads prop footprints
off the asset registry, so it is downstream of the furniture catalogue that the
`await import()` in `loadOffice` exists to hold back; a value import in main.ts
would have pulled all of it forward and silently undone the split. So main.ts
takes the constructor as an argument and names the module type-only, and the
build agrees: officeMinimap is its own 10.3 kB chunk and the entry is unmoved.
The quiet one was the URL. Leaving the office swapped the scene and left the
address bar saying `office.` — which is the thing you copy, bookmark and send to
someone, and it took them somewhere other than what you were looking at. From
the `office.` host the exit is now a real navigation to the same hostname with
its first label swapped: the same string comparison that decided this was the
office door decides where the city is, so a deployment serving one name and not
the other gets an honest 404 on a name it chose not to serve rather than a
silent lie. `?city=` carries the metro across so stepping out into SoCal lands
in SoCal, and is read unconditionally because a deep link to a city is a
reasonable thing to want on its own. On `?view=office` there is no other host to
go to, so the scene swap stands and the query is dropped on the way out.
Checked against the production build with both real hostnames resolved at a
local static server: office.lumbridgecorp.com boots into the office with the
floor plan in the panel, the hover readout gives office metres and the room
under the pointer ("9.8, 16.7 m · Bernal"), `O` lands on tera.lumbridgecorp.com
showing the Bay Area, and the console is clean.
This commit is contained in:
+214
-8
@@ -51,6 +51,13 @@ import { createMinimap, type Minimap } from "./engine/minimap.ts";
|
||||
import type { OfficeScene } from "./interiors/officeScene.ts";
|
||||
import type { Office } from "./interiors/types.ts";
|
||||
import type { MaterialRegistry } from "./assets/materials.ts";
|
||||
// Type-only for the reason above, and it matters more here than it looks:
|
||||
// `officeMinimap.ts` imports the asset registry as a *value*, to read prop
|
||||
// footprints, so a value import of it here would drag the furniture catalogue
|
||||
// back into the entry chunk and quietly undo the split this block exists to
|
||||
// protect. It arrives with the office, in `loadOffice()`, because it is only
|
||||
// ever drawn once you are standing in one.
|
||||
import type { OfficeMinimap } from "./engine/officeMinimap.ts";
|
||||
import type { Godmode, GodmodePlace } from "./tools/index.ts";
|
||||
import type { PoseEditor } from "./tools/poseEditor.ts";
|
||||
|
||||
@@ -165,6 +172,20 @@ let poseEditor: PoseEditor | null = null;
|
||||
*/
|
||||
let officePack: Office | null = null;
|
||||
let officeMaterials: MaterialRegistry | null = null;
|
||||
/**
|
||||
* The plan panel's other occupant.
|
||||
*
|
||||
* There are two of these because there are two places, and the panel shows the
|
||||
* one you are standing in. Holding the city plan up while the scene in front of
|
||||
* it is a floor plate was the bug this pair exists to fix: the single piece of
|
||||
* chrome whose whole job is "where am I" was answering about another county.
|
||||
*
|
||||
* It is built with the office and dies with it, so the office's `Plan`, camera
|
||||
* and controls — all three of which it holds by reference — cannot outlive the
|
||||
* scene they came from. Which of the two is *mounted* is a separate question
|
||||
* from which exist, and `showPlan()` is the only thing that answers it.
|
||||
*/
|
||||
let officePlan: OfficeMinimap | null = null;
|
||||
|
||||
/**
|
||||
* `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind
|
||||
@@ -174,9 +195,50 @@ let officeMaterials: MaterialRegistry | null = null;
|
||||
* city" work from the office front door — so this is a policy about where boot
|
||||
* *stops*, not about what boot builds.
|
||||
*/
|
||||
const OFFICE_HOST = location.hostname.split(".")[0] === "office";
|
||||
|
||||
const OPENS_IN_OFFICE =
|
||||
location.hostname.split(".")[0] === "office" ||
|
||||
new URLSearchParams(location.search).get("view") === "office";
|
||||
OFFICE_HOST || new URLSearchParams(location.search).get("view") === "office";
|
||||
|
||||
/**
|
||||
* Where the city is, when this page is the office front door.
|
||||
*
|
||||
* `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind
|
||||
* two names, and until now leaving the office from the office door swapped the
|
||||
* scene and left the address bar saying `office.` — so the site's own URL
|
||||
* disagreed with the site. That is not a cosmetic complaint: it is the thing you
|
||||
* copy, bookmark and send to someone, and it took them somewhere other than what
|
||||
* you were looking at.
|
||||
*
|
||||
* So the city door is this hostname with its first label swapped, and the guess
|
||||
* is exactly as safe as the one directly above it: it is only ever consulted
|
||||
* when that label is literally `office`, which is the same string comparison
|
||||
* that made this the office door in the first place. Same registrable domain,
|
||||
* same origin policy, same static root, same API — a deployment that serves one
|
||||
* of these names and not the other has half-configured itself, and the honest
|
||||
* failure for that is a 404 on a name it chose not to serve rather than a silent
|
||||
* lie in the address bar.
|
||||
*
|
||||
* Anything else — a bare domain, `spaces.example.com`, `?view=office` on the
|
||||
* city door — derives nothing and keeps the in-page swap, which is what the
|
||||
* whole scene-swap design was for.
|
||||
*/
|
||||
function cityDoorUrl(cityWanted?: string): string | null {
|
||||
if (!OFFICE_HOST) return null;
|
||||
const url = new URL(location.href);
|
||||
const labels = url.hostname.split(".");
|
||||
labels[0] = "tera";
|
||||
url.hostname = labels.join(".");
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
// Carried so that stepping out of the office into SoCal lands in SoCal. The
|
||||
// page load is the cost of telling the truth about where you are; arriving in
|
||||
// the wrong metro on top of it would not be.
|
||||
if (cityWanted !== undefined && cityWanted !== CITIES[0]?.id) {
|
||||
url.searchParams.set("city", cityWanted);
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
// ---- Time -----------------------------------------------------------------
|
||||
|
||||
@@ -271,6 +333,8 @@ async function mountCity(id: string) {
|
||||
weatherWatch = null;
|
||||
poseEditor?.destroy();
|
||||
poseEditor = null;
|
||||
officePlan?.dispose();
|
||||
officePlan = null;
|
||||
office?.dispose();
|
||||
office = null;
|
||||
inside = false;
|
||||
@@ -438,7 +502,7 @@ async function mountCity(id: string) {
|
||||
: "";
|
||||
},
|
||||
});
|
||||
minimapFrame?.replaceChildren(minimap.canvas);
|
||||
showPlan();
|
||||
minimap.setMarkers(id === "sf" ? markers : []);
|
||||
|
||||
// The instruments, for the one visitor in a deployment who has them. The pose
|
||||
@@ -463,7 +527,12 @@ async function mountCity(id: string) {
|
||||
*/
|
||||
requestAnimationFrame(function pumpMinimap() {
|
||||
requestAnimationFrame(pumpMinimap);
|
||||
minimap?.tick();
|
||||
// Only the mounted one. The other is still constructed and still holds a live
|
||||
// camera, but its canvas is out of the document, so `clientWidth` is 0, every
|
||||
// `resize()` puts it back to `ready = false`, and ticking it would be a
|
||||
// function call that exists to do nothing sixty times a second.
|
||||
if (inside) officePlan?.tick();
|
||||
else minimap?.tick();
|
||||
// The pose editor is on the same pump for the same reasons, and is `null` for
|
||||
// everyone who is not god, so this is one property read per frame on a
|
||||
// public page.
|
||||
@@ -511,7 +580,7 @@ async function enterOffice() {
|
||||
// it did not arrive at all. Either way there is no room to walk into and
|
||||
// `loadOffice` has already said so on the button.
|
||||
if (!built || !city) return;
|
||||
const { createOfficeScene, pack, materials } = built;
|
||||
const { createOfficeScene, createOfficeMinimap, pack, materials } = built;
|
||||
const depth = access.can.officeDepth;
|
||||
office = createOfficeScene(pack, {
|
||||
dom: city.stage.renderer.domElement,
|
||||
@@ -526,18 +595,100 @@ async function enterOffice() {
|
||||
: { onPlacePick: (place) => showDetail(place ? place.label : null) }),
|
||||
});
|
||||
office.onViewChange(() => renderLegend());
|
||||
officePlan = buildOfficePlan(createOfficeMinimap, office);
|
||||
}
|
||||
city.stage.setScene(office);
|
||||
inside = true;
|
||||
showPlan();
|
||||
showDetail(null);
|
||||
refreshGodmodePlace();
|
||||
renderLegend();
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan panel, for the office.
|
||||
*
|
||||
* Built once alongside the scene rather than on every entry, because it reads
|
||||
* that scene's camera and controls by reference — a second one built against a
|
||||
* disposed office would draw a footprint from a camera nobody is moving.
|
||||
*
|
||||
* The constructor is passed in rather than imported, for the reason written at
|
||||
* the import block: naming the module here as a value would put the furniture
|
||||
* catalogue back in the entry chunk.
|
||||
*/
|
||||
function buildOfficePlan(
|
||||
create: typeof import("./engine/officeMinimap.ts").createOfficeMinimap,
|
||||
scene: OfficeScene,
|
||||
): OfficeMinimap {
|
||||
return create({
|
||||
plan: scene.plan,
|
||||
camera: scene.camera,
|
||||
controls: scene.controls,
|
||||
onSeek(x, z) {
|
||||
/**
|
||||
* The same move the city plan makes, and for the same reason: slide the
|
||||
* orbit target and carry the camera with it, keeping the offset between
|
||||
* them. A seek is "look over there", not "go to viewpoint three".
|
||||
*
|
||||
* The height is deliberately untouched. Indoors that matters more than it
|
||||
* does outdoors — the difference between a viewpoint two metres off the
|
||||
* floor and one fourteen metres up is the difference between standing in
|
||||
* the room and looking down into it, and clicking the plan should not
|
||||
* silently move you between those two.
|
||||
*/
|
||||
const { camera, controls } = scene;
|
||||
const dx = camera.position.x - controls.target.x;
|
||||
const dy = camera.position.y - controls.target.y;
|
||||
const dz = camera.position.z - controls.target.z;
|
||||
controls.target.set(x, controls.target.y, z);
|
||||
camera.position.set(x + dx, controls.target.y + dy, z + dz);
|
||||
},
|
||||
onHover(info) {
|
||||
if (!minimapReadout) return;
|
||||
if (!info) {
|
||||
minimapReadout.textContent = "";
|
||||
return;
|
||||
}
|
||||
// Metres, not degrees. The city widget prints a latitude and a longitude
|
||||
// because that is the address of a place on a map; the address of a place
|
||||
// in a building is where it is on the floor, in the units the pack was
|
||||
// authored in, so a self-hoster can read a number off this and type it
|
||||
// into their own office file.
|
||||
const where = `${info.x.toFixed(1)}, ${info.z.toFixed(1)} m`;
|
||||
// The storey is named only when there is more than one to be on. On a
|
||||
// single-level pack — which is the reference office and will be most
|
||||
// packs — "Level 1" on every hover is a word that never changes and
|
||||
// therefore never informs.
|
||||
const level = scene.plan.levels.length > 1 && info.level ? ` · ${info.level}` : "";
|
||||
minimapReadout.textContent = `${where}${level}${info.room ? ` · ${info.room}` : ""}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the plan for wherever you are into the panel.
|
||||
*
|
||||
* One function rather than a `replaceChildren` at each of the call sites,
|
||||
* because the failure it prevents is silent: the panel keeps showing a widget
|
||||
* that is still ticking and still correct *about somewhere else*, which looks
|
||||
* exactly like a working map.
|
||||
*/
|
||||
function showPlan() {
|
||||
const widget = inside ? officePlan : minimap;
|
||||
if (!minimapFrame || !widget) return;
|
||||
minimapFrame.replaceChildren(widget.canvas);
|
||||
// The canvas has just re-entered the document with a real box for the first
|
||||
// time, or the first time since it left it. Nothing else tells it that: the
|
||||
// `ResizeObserver` fires on a change of size, and going from detached to
|
||||
// attached at the same size is not one.
|
||||
widget.resize();
|
||||
}
|
||||
|
||||
function leaveOffice() {
|
||||
if (!city) return;
|
||||
city.stage.setScene(city.stageScene);
|
||||
inside = false;
|
||||
showPlan();
|
||||
showDetail(null);
|
||||
refreshGodmodePlace();
|
||||
renderLegend();
|
||||
@@ -569,19 +720,27 @@ function leaveOffice() {
|
||||
*/
|
||||
async function loadOffice(): Promise<{
|
||||
createOfficeScene: typeof import("./interiors/officeScene.ts").createOfficeScene;
|
||||
createOfficeMinimap: typeof import("./engine/officeMinimap.ts").createOfficeMinimap;
|
||||
pack: Office;
|
||||
materials: MaterialRegistry;
|
||||
} | null> {
|
||||
try {
|
||||
const [interiors, pack, assets] = await Promise.all([
|
||||
// A fourth import and still one arrival. The plan renderer reads prop
|
||||
// footprints off the asset registry, so it is already downstream of the
|
||||
// furniture catalogue this chunk exists to hold back — asking for it here
|
||||
// costs nothing beyond the module itself, and asking for it anywhere else
|
||||
// would cost the whole catalogue in the entry chunk.
|
||||
const [interiors, pack, assets, plan] = await Promise.all([
|
||||
import("./interiors/officeScene.ts"),
|
||||
import("./offices/lumbridge-hq.ts"),
|
||||
import("./assets/materials.ts"),
|
||||
import("./engine/officeMinimap.ts"),
|
||||
]);
|
||||
officePack ??= pack.default;
|
||||
officeMaterials ??= new assets.MaterialRegistry({ quality: "high" });
|
||||
return {
|
||||
createOfficeScene: interiors.createOfficeScene,
|
||||
createOfficeMinimap: plan.createOfficeMinimap,
|
||||
pack: officePack,
|
||||
materials: officeMaterials,
|
||||
};
|
||||
@@ -687,7 +846,11 @@ function renderLegend() {
|
||||
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
|
||||
);
|
||||
}
|
||||
// Each plan rings the entry its own legend is showing as current. Both are
|
||||
// updated whichever one is mounted, so the hidden one is already right when it
|
||||
// comes back rather than correcting itself a frame after it appears.
|
||||
minimap?.setChapters(city.chapters, city.current());
|
||||
officePlan?.setActiveView(office?.current() ?? null);
|
||||
renderOfficeBadge();
|
||||
}
|
||||
|
||||
@@ -853,7 +1016,35 @@ function flyToIndex(index: number) {
|
||||
else city?.flyTo(view.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step out of the office to the city, by whichever route keeps the URL honest.
|
||||
*
|
||||
* Returns true when it has started a navigation and the caller should stop — the
|
||||
* page is on its way out, and anything after this runs against a document that
|
||||
* is about to be replaced.
|
||||
*
|
||||
* The `?view=office` case is the cheap half of the same fix. There is no other
|
||||
* host to go to, so the scene swap stands, but the query that put us in the
|
||||
* office is dropped from the address bar on the way out. `replaceState` rather
|
||||
* than `pushState`: the office and the city are one page, and a back button that
|
||||
* walks a history of scene swaps is a back button that does not leave.
|
||||
*/
|
||||
function leaveToCity(cityWanted?: string): boolean {
|
||||
const url = cityDoorUrl(cityWanted);
|
||||
if (url !== null) {
|
||||
location.assign(url);
|
||||
return true;
|
||||
}
|
||||
const here = new URL(location.href);
|
||||
if (here.searchParams.get("view") === "office") {
|
||||
here.searchParams.delete("view");
|
||||
history.replaceState(null, "", `${here.pathname}${here.search}${here.hash}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function switchCity(id: string) {
|
||||
if (inside && leaveToCity(id)) return;
|
||||
if (inside) leaveOffice();
|
||||
if (id === wantedCity) return;
|
||||
wantedCity = id;
|
||||
@@ -880,6 +1071,7 @@ let entering = false;
|
||||
|
||||
async function toggleOffice() {
|
||||
if (inside) {
|
||||
if (leaveToCity()) return;
|
||||
leaveOffice();
|
||||
return;
|
||||
}
|
||||
@@ -1030,7 +1222,9 @@ window.addEventListener("keydown", (event) => {
|
||||
|
||||
if (event.key === "Escape") {
|
||||
if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
|
||||
else if (inside) leaveOffice();
|
||||
else if (inside) {
|
||||
if (!leaveToCity()) leaveOffice();
|
||||
}
|
||||
else showDetail(null);
|
||||
return;
|
||||
}
|
||||
@@ -1450,7 +1644,19 @@ async function boot() {
|
||||
}
|
||||
}
|
||||
|
||||
const first = CITIES[0];
|
||||
/**
|
||||
* Which metro to build, from the URL if it names one this build has.
|
||||
*
|
||||
* The parameter exists to make leaving the office lossless — `office.` sends
|
||||
* you to `tera.?city=socal` rather than dropping you in the Bay Area — but it
|
||||
* is read unconditionally, because a deep link to a city is a reasonable thing
|
||||
* to want on its own and a parameter that only works when it arrives from one
|
||||
* particular page is a trap. An unknown id falls back rather than failing:
|
||||
* `?city=paris` on a build with two cities in it should show a city, not a
|
||||
* black screen.
|
||||
*/
|
||||
const wanted = new URLSearchParams(location.search).get("city");
|
||||
const first = CITIES.find((c) => c.id === wanted) ?? CITIES[0];
|
||||
await building(`Building ${first?.label ?? "the city"}…`, () => mountCity(first?.id ?? "sf"));
|
||||
|
||||
// The instruments, after the first board, because the panel reads a live
|
||||
|
||||
Reference in New Issue
Block a user