1
0

feat: add playable actors and seamless journey state

This commit is contained in:
2026-08-11 19:10:14 -07:00
parent 4e313c4a79
commit a2a52bfdae
19 changed files with 2778 additions and 24 deletions
+237 -5
View File
@@ -60,6 +60,15 @@ import { OFFICE_SITES } from "./offices/sites.ts";
import { authFetch } from "./session.ts";
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
import { createMinimap, type Minimap } from "./engine/minimap.ts";
import {
createJourney,
journeyReducer,
loadJourneySession,
saveJourneySession,
type JourneyCity,
type JourneyEvent,
type JourneyState,
} from "./journey/index.ts";
/**
* Three type-only imports and not one value among them, which is what keeps the
* office and the instruments out of the entry chunk.
@@ -91,6 +100,39 @@ const CITIES: { id: string; label: string; city: City }[] = [
{ id: "socal", label: "SoCal", city: SOCAL },
];
/** The corridor's scale doors, and the office each detailed board arrives near. */
const CALIFORNIA_DESTINATIONS = new Map<string, { cityId: string; officeId: string }>([
["los-angeles", { cityId: "socal", officeId: "mateo-court" }],
["san-francisco", { cityId: "sf", officeId: "lumbridge-hq" }],
]);
const JOURNEY_SESSION_KEY = "tera:journey:v1";
const restoredJourney = loadJourneySession(sessionStorage, JOURNEY_SESSION_KEY);
let journey: JourneyState = restoredJourney.status === "loaded"
? restoredJourney.state
: createJourney({
actor: {
id: "anonymous",
kind: "crow",
signedIn: false,
profile: { displayName: "Guest" },
},
});
function dispatchJourney(event: JourneyEvent): boolean {
const next = journeyReducer(journey, event);
if (next === journey) return false;
journey = next;
saveJourneySession(sessionStorage, JOURNEY_SESSION_KEY, journey);
return true;
}
function journeyToCity(city: JourneyCity): void {
if (journey.location.scale === "office") dispatchJourney({ type: "leave-office" });
if (journey.location.scale !== "california") dispatchJourney({ type: "return-to-california" });
dispatchJourney({ type: "navigate-to-city", city });
}
/**
* The buildings this page can walk into.
*
@@ -758,6 +800,29 @@ async function mountCity(id: string) {
const handle = await createScene(stage, {
city: entry.city,
actor: {
kind: access.subject === null ? "crow" : "humanoid",
identity: {
id: access.subject ?? "anonymous",
displayName: access.subject ?? "Guest",
authenticated: access.subject !== null,
profile: {
appearance: access.subject === null
? { primaryColor: "#11151a", accentColor: "#f2b134" }
: { primaryColor: "#151a20", accentColor: "#f2b134" },
},
},
mode: access.subject === null ? "flight" : "ground",
position: access.subject === null ? { y: 500 } : { y: 0 },
minFlightAltitude: 20,
maxFlightAltitude: 1_500,
...(access.subject === null
? { camera: { distance: 1.55, height: 1.35, targetHeight: 0.18, lookAhead: 0 } }
: {}),
},
...(id === "california"
? { actorAnchor: { lat: 35.5, lng: -119.5 } }
: {}),
markerPalette: palette,
markers: initialMarkers,
...(id === "california"
@@ -1030,6 +1095,23 @@ async function enterOffice() {
office = createOfficeScene(pack, {
dom: city.stage.renderer.domElement,
// A visitor owns one local actor. Anonymous visitors are the promised
// office dog; a signed-in visitor gets the procedural humanoid. The
// arrival viewpoint is already a pack-authored clear point on a floor,
// which makes it the honest spawn and keeps coordinates out of the app.
walker: {
levelId: pack.viewpoints[0]?.levelId ?? pack.levels[0]?.id ?? "level-1",
position: pack.viewpoints[0]?.focus.at ?? { x: 0, z: 0 },
facing: pack.viewpoints[0]
? {
x: -Math.sin(pack.viewpoints[0].focus.rotation),
z: -Math.cos(pack.viewpoints[0].focus.rotation),
}
: { x: 0, z: -1 },
actor: access.subject === null
? { kind: "anonymous-dog", coatColor: 0x17191c, collarColor: 0xf2b134 }
: { kind: "humanoid", outfitColor: 0x151a20, accentColor: 0xf2b134 },
},
// Only when there is no sky to put behind it. A sited office computes a
// gradient and a horizon; painting the old flat colour over that is the
// bug that looks exactly like the sky not working.
@@ -1061,6 +1143,9 @@ async function enterOffice() {
}
city.stage.setScene(office);
inside = true;
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
journeyToCity(desiredCity);
dispatchJourney({ type: "enter-office", officeId: officeId as "lumbridge-hq" | "frontier-valley" | "mateo-court" });
showPlan();
showDetail(null);
refreshGodmodePlace();
@@ -1177,6 +1262,9 @@ function leaveOffice() {
// Before the scene swap, so the last thing the watch can do is abort a request
// rather than publish into a room the user has already left.
stopWatchingOccupancy();
office?.walker?.setActive(false);
office?.walker?.setAction({ x: 0, z: 0 });
dispatchJourney({ type: "leave-office" });
city.stage.setScene(city.stageScene);
inside = false;
showPlan();
@@ -1338,6 +1426,9 @@ const planToggle = document.querySelector<HTMLButtonElement>("#plan-toggle");
const credits = document.querySelector<HTMLElement>("#credits");
const driveControls = document.querySelector<HTMLElement>("#drive-controls");
const driveHint = document.querySelector<HTMLElement>("#drive-hint");
const walkButton = document.querySelector<HTMLButtonElement>("#walk");
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail");
@@ -1467,14 +1558,31 @@ function renderLegend() {
// what is behind it, and that is the badge's job to say, not the button's.
enterButton.textContent = inside ? "← Back to the city" : "Enter the office →";
}
const walking = inside && (office?.walker?.active() ?? false);
const exploring = !inside && (city.actorActive() ?? false);
if (walkButton) {
walkButton.hidden = inside ? office?.walker === null : city.actorState() === null;
walkButton.setAttribute("aria-pressed", String(walking || exploring));
if (inside && office?.walker) {
const actor = office.walker.state().actor === "anonymous-dog" ? "your dog" : "your humanoid";
walkButton.textContent = walking ? "Return to overview ↑" : `Walk as ${actor}`;
} else if (city.actorState()) {
const actor = city.actorState()?.kind === "crow" ? "your crow" : "your humanoid";
walkButton.textContent = exploring ? "Return to flyover ↑" : `Explore as ${actor}`;
}
}
renderSource();
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
if (canvas) {
canvas.setAttribute(
"aria-label",
inside
? `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
? walking
? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.`
: `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
: exploring
? `${cityLabel}, following your ${city.actorState()?.kind ?? "actor"}. Use W A S D to move.`
: `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
@@ -1484,7 +1592,9 @@ function renderLegend() {
officePlan?.setActiveView(office?.current() ?? null);
renderOfficeBadge();
if (driveControls) driveControls.hidden = !routeDriveIsActive();
if (walkControls) walkControls.hidden = !(walking || exploring);
if (driveHint) driveHint.hidden = inside || cityId !== "california";
if (walkHint) walkHint.hidden = !(inside || city.actorState());
}
/**
@@ -1679,7 +1789,21 @@ function flyToIndex(index: number) {
const view = currentViews()[index];
if (!view) return;
if (inside && office) office.flyTo(view.id);
else city?.flyTo(view.id);
else {
const destination = cityId === "california" ? CALIFORNIA_DESTINATIONS.get(view.id) : undefined;
if (destination) {
officeId = destination.officeId;
journeyToCity(destination.cityId === "socal" ? "socal" : "bay-area");
switchCity(destination.cityId);
return;
}
if (view.id === "la-sf-us-101" || view.id === "la-sf-i-5") {
dispatchJourney({ type: "set-mode", mode: "play" });
dispatchJourney({ type: "select-route", routeId: view.id, direction: 1 });
dispatchJourney({ type: "enter-vehicle", vehicleId: "model-x-black" });
}
city?.flyTo(view.id);
}
}
/**
@@ -1713,6 +1837,18 @@ function switchCity(id: string) {
if (inside && leaveToCity(id)) return;
if (inside) leaveOffice();
if (id === wantedCity) return;
// Arriving at a detailed board should put its local front door under the
// existing Office button. The California overview keeps whichever building
// the traveller last visited; it is a scale, not a fourth office location.
if (id === "socal") officeId = "mateo-court";
else if (id === "sf" && officeId === "mateo-court") officeId = "lumbridge-hq";
if (id === "california") {
if (journey.vehicle) dispatchJourney({ type: "exit-vehicle" });
if (journey.location.scale === "office") dispatchJourney({ type: "leave-office" });
if (journey.location.scale !== "california") dispatchJourney({ type: "return-to-california" });
} else {
journeyToCity(id === "socal" ? "socal" : "bay-area");
}
wantedCity = id;
const label = CITIES.find((c) => c.id === id)?.label ?? id;
void building(`Building ${label}`, () => mountCity(id));
@@ -1774,6 +1910,27 @@ async function toggleOffice() {
enterButton?.addEventListener("click", () => void toggleOffice());
/** Switch between the authored dollhouse camera and the local possessed actor. */
function toggleOfficeWalk(): boolean {
if (!inside) {
if (!city?.actorState()) return false;
const active = !city.actorActive();
city.setActorActive(active);
if (!active) city.setActorActions({});
renderLegend();
return true;
}
const walker = office?.walker;
if (!walker) return false;
const active = !walker.active();
walker.setActive(active);
if (!active) walker.setAction({ x: 0, z: 0 });
renderLegend();
return true;
}
walkButton?.addEventListener("click", () => toggleOfficeWalk());
/**
* Clicking a building on the city walks into it.
*
@@ -1894,7 +2051,7 @@ const heldDriveKeys = new Set<string>();
function routeDriveIsActive(): boolean {
const state = !inside ? city?.vehicleState() : null;
return state !== null && state !== undefined && city?.current() === state.routeId;
return state !== null && state !== undefined && !city?.actorActive() && city?.current() === state.routeId;
}
function publishVehicleActions(
@@ -1917,6 +2074,28 @@ function publishVehicleActions(
return true;
}
function publishOfficeWalkActions(): boolean {
const walker = inside ? office?.walker : null;
if (!walker?.active()) return false;
walker.setAction({
x: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
z: (heldDriveKeys.has("s") ? 1 : 0) - (heldDriveKeys.has("w") ? 1 : 0),
});
return true;
}
function publishCityActorActions(): boolean {
if (inside || !city?.actorActive()) return false;
city.setActorActions({
forward: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
right: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
turn: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
sprint: heldDriveKeys.has(" "),
climb: heldDriveKeys.has(" ") ? 1 : 0,
});
return true;
}
function toggleVehicleCamera(): boolean {
if (!routeDriveIsActive() || !city) return false;
city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver");
@@ -1944,6 +2123,29 @@ for (const button of driveControls?.querySelectorAll<HTMLButtonElement>("[data-d
button.addEventListener("lostpointercapture", release);
}
for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
const key = button.dataset.walkKey;
if (key === undefined) continue;
const release = (event: PointerEvent) => {
heldDriveKeys.delete(key);
button.setAttribute("aria-pressed", "false");
publishOfficeWalkActions();
publishCityActorActions();
event.preventDefault();
};
button.addEventListener("pointerdown", (event) => {
button.setPointerCapture(event.pointerId);
heldDriveKeys.add(key);
button.setAttribute("aria-pressed", "true");
publishOfficeWalkActions();
publishCityActorActions();
event.preventDefault();
});
button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release);
button.addEventListener("lostpointercapture", release);
}
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='assist']")
?.addEventListener("click", () => publishVehicleActions({ modeRequest: "assisted" }));
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
@@ -1954,12 +2156,14 @@ driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
window.addEventListener("keyup", (event) => {
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (!heldDriveKeys.delete(key)) return;
if (publishVehicleActions()) event.preventDefault();
if (publishVehicleActions() || publishOfficeWalkActions() || publishCityActorActions()) event.preventDefault();
});
window.addEventListener("blur", () => {
heldDriveKeys.clear();
publishVehicleActions();
publishOfficeWalkActions();
publishCityActorActions();
});
let gamepadButtons: GamepadButtonState = { assist: false, reset: false };
@@ -2034,6 +2238,14 @@ window.addEventListener("keydown", (event) => {
event.preventDefault();
return;
}
if (publishOfficeWalkActions()) {
event.preventDefault();
return;
}
if (publishCityActorActions()) {
event.preventDefault();
return;
}
}
if (lower === "p" && publishVehicleActions({ modeRequest: "assisted" })) {
event.preventDefault();
@@ -2047,6 +2259,10 @@ window.addEventListener("keydown", (event) => {
event.preventDefault();
return;
}
if (lower === "v" && toggleOfficeWalk()) {
event.preventDefault();
return;
}
if (lower === "m") {
togglePlan();
return;
@@ -2482,6 +2698,22 @@ async function boot() {
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
access = await resolveAccess();
dispatchJourney({
type: "sign-in-actor-swap",
actor: access.subject === null
? {
id: "anonymous",
kind: "crow",
signedIn: false,
profile: { displayName: "Guest" },
}
: {
id: access.subject,
kind: "humanoid",
signedIn: true,
profile: { displayName: access.subject },
},
});
applyTimeControl();
renderTierBadge();