1
0

feat: add character studio and aircraft foundation

This commit is contained in:
2026-08-11 19:32:32 -07:00
parent 1c37ef8f3e
commit c0c7fcf974
16 changed files with 2250 additions and 25 deletions
+115 -24
View File
@@ -71,12 +71,15 @@ import {
} from "./journey/index.ts";
import {
actorKindForPresence,
createProfileEditor,
createDefaultLocalProfile,
loadLocalProfile,
resolveHumanoidAppearance,
saveLocalProfile,
type LocalProfile,
type ProfileEditor,
} from "./profile/index.ts";
import type { ActorIdentity } from "./actors/controller.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.
@@ -133,6 +136,26 @@ function humanoidAppearance() {
return localProfile ? resolveHumanoidAppearance(localProfile.appearance) : null;
}
function signedInActorIdentity(profile = localProfile): ActorIdentity {
const appearance = profile ? resolveHumanoidAppearance(profile.appearance) : null;
return {
id: access.subject ?? "anonymous",
displayName: profile?.displayName ?? access.subject ?? "Guest",
authenticated: access.subject !== null,
profile: {
appearance: appearance
? {
skinTone: appearance.skinTone,
primaryColor: appearance.outfitColor,
accentColor: appearance.accentColor,
hairColor: appearance.hairColor,
bodyShape: appearance.bodyShape,
}
: { primaryColor: "#151a20", accentColor: "#f2b134" },
},
};
}
function dispatchJourney(event: JourneyEvent): boolean {
const next = journeyReducer(journey, event);
if (next === journey) return false;
@@ -816,27 +839,14 @@ async function mountCity(id: string) {
city: entry.city,
actor: {
kind: actorKindForPresence(access.subject !== null, "outdoors"),
identity: {
id: access.subject ?? "anonymous",
displayName: access.subject ?? "Guest",
authenticated: access.subject !== null,
profile: {
appearance: access.subject === null
? { primaryColor: "#11151a", accentColor: "#f2b134" }
: (() => {
const appearance = humanoidAppearance();
return appearance
? {
skinTone: appearance.skinTone,
primaryColor: appearance.outfitColor,
accentColor: appearance.accentColor,
hairColor: appearance.hairColor,
bodyShape: appearance.bodyShape,
}
: { primaryColor: "#151a20", accentColor: "#f2b134" };
})(),
},
},
identity: access.subject === null
? {
id: "anonymous",
displayName: "Guest",
authenticated: false,
profile: { appearance: { primaryColor: "#11151a", accentColor: "#f2b134" } },
}
: signedInActorIdentity(),
mode: access.subject === null ? "flight" : "ground",
position: access.subject === null ? { y: 1_200 } : { y: 0 },
minFlightAltitude: 20,
@@ -1483,6 +1493,8 @@ 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");
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
let profileEditor: ProfileEditor | null = null;
function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail");
@@ -1777,12 +1789,20 @@ function renderCredits() {
function renderOfficeBadge() {
if (!officeBadge) return;
const publicOffice = inside && office !== null && office.depth === "public";
const mediaSurfaces = inside && office !== null && office.depth === "full"
? office.listMediaSurfaces()
: [];
// The fabricated-occupancy caption used to be here too and is now on
// `#source` — see `renderSource`. This badge keeps the message that is a call
// to action rather than a disclosure, because that one belongs beside the
// office controls and survives being missed; the other one does not.
officeBadge.hidden = !publicOffice;
if (!publicOffice) return;
officeBadge.hidden = !publicOffice && mediaSurfaces.length === 0;
if (!publicOffice) {
if (mediaSurfaces.length === 0) return;
const noun = mediaSurfaces.length === 1 ? "screen" : "screens";
officeBadge.textContent = `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`;
return;
}
officeBadge.replaceChildren(
document.createTextNode("Public view — the building, not the people. "),
);
@@ -1823,8 +1843,16 @@ function renderTierBadge() {
if (access.subject !== null) {
const who = document.createElement("span");
who.className = "who";
who.textContent = access.subject;
who.textContent = localProfile?.displayName ?? access.subject;
tierBadge.append(who);
if (localProfile) {
const customize = document.createElement("button");
customize.type = "button";
customize.className = "profile-trigger";
customize.textContent = "Character";
customize.addEventListener("click", openProfileEditor);
tierBadge.append(customize);
}
} else if (access.signInUrl !== null) {
const link = document.createElement("a");
link.href = access.signInUrl;
@@ -1834,6 +1862,68 @@ function renderTierBadge() {
tierBadge.hidden = false;
}
function applyProfilePreview(profile: LocalProfile): void {
if (access.subject === null || inside) return;
city?.setActorIdentity(signedInActorIdentity(profile));
}
async function rebuildOfficeActor(): Promise<void> {
if (!inside || !office) return;
const wasWalking = office.walker?.active() ?? false;
stopWatchingOccupancy();
officePlan?.dispose();
officePlan = null;
office.dispose();
office = null;
officeAtmosphere = null;
await building("Updating your character…", () => enterOffice());
const rebuilt = office as OfficeScene | null;
if (wasWalking) rebuilt?.walker?.setActive(true);
renderLegend();
}
function ensureProfileEditor(): ProfileEditor | null {
if (profileEditor) return profileEditor;
if (!profileOverlay || !localProfile || access.subject === null) return null;
profileEditor = createProfileEditor({
container: profileOverlay,
profile: localProfile,
identityId: access.subject,
onPreview: applyProfilePreview,
onSave(profile) {
localProfile = profile;
saveLocalProfile(sessionStorage, LOCAL_PROFILE_KEY, profile);
dispatchJourney({
type: "sign-in-actor-swap",
actor: {
id: access.subject ?? "anonymous",
kind: "humanoid",
signedIn: true,
profile: { displayName: profile.displayName, color: humanoidAppearance()?.accentColor },
},
});
city?.setActorIdentity(signedInActorIdentity(profile));
renderTierBadge();
profileOverlay.hidden = true;
if (inside) void rebuildOfficeActor();
},
onCancel() {
profileOverlay.hidden = true;
},
});
profileEditor.root.addEventListener("keydown", (event) => event.stopPropagation());
return profileEditor;
}
function openProfileEditor(): void {
if (!localProfile || !profileOverlay) return;
const editor = ensureProfileEditor();
if (!editor) return;
editor.update(localProfile);
profileOverlay.hidden = false;
editor.open();
}
// ---- Navigation -------------------------------------------------------------
/** The views on offer right now — city chapters, or office viewpoints inside. */
@@ -2257,6 +2347,7 @@ window.addEventListener("keydown", (event) => {
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLSelectElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {