feat: add playable actors and seamless journey state
This commit is contained in:
+237
-5
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user