feat: add character studio and aircraft foundation
This commit is contained in:
+115
-24
@@ -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)
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user