feat: add private profile media and realtime contracts
This commit is contained in:
+83
-8
@@ -69,6 +69,14 @@ import {
|
||||
type JourneyEvent,
|
||||
type JourneyState,
|
||||
} from "./journey/index.ts";
|
||||
import {
|
||||
actorKindForPresence,
|
||||
createDefaultLocalProfile,
|
||||
loadLocalProfile,
|
||||
resolveHumanoidAppearance,
|
||||
saveLocalProfile,
|
||||
type LocalProfile,
|
||||
} from "./profile/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.
|
||||
@@ -118,6 +126,12 @@ let journey: JourneyState = restoredJourney.status === "loaded"
|
||||
profile: { displayName: "Guest" },
|
||||
},
|
||||
});
|
||||
const LOCAL_PROFILE_KEY = "tera:profile:v2";
|
||||
let localProfile: LocalProfile | null = null;
|
||||
|
||||
function humanoidAppearance() {
|
||||
return localProfile ? resolveHumanoidAppearance(localProfile.appearance) : null;
|
||||
}
|
||||
|
||||
function dispatchJourney(event: JourneyEvent): boolean {
|
||||
const next = journeyReducer(journey, event);
|
||||
@@ -801,7 +815,7 @@ async function mountCity(id: string) {
|
||||
const handle = await createScene(stage, {
|
||||
city: entry.city,
|
||||
actor: {
|
||||
kind: access.subject === null ? "crow" : "humanoid",
|
||||
kind: actorKindForPresence(access.subject !== null, "outdoors"),
|
||||
identity: {
|
||||
id: access.subject ?? "anonymous",
|
||||
displayName: access.subject ?? "Guest",
|
||||
@@ -809,11 +823,22 @@ async function mountCity(id: string) {
|
||||
profile: {
|
||||
appearance: access.subject === null
|
||||
? { primaryColor: "#11151a", accentColor: "#f2b134" }
|
||||
: { primaryColor: "#151a20", 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" };
|
||||
})(),
|
||||
},
|
||||
},
|
||||
mode: access.subject === null ? "flight" : "ground",
|
||||
position: access.subject === null ? { y: 500 } : { y: 0 },
|
||||
position: access.subject === null ? { y: 1_200 } : { y: 0 },
|
||||
minFlightAltitude: 20,
|
||||
maxFlightAltitude: 1_500,
|
||||
...(access.subject === null
|
||||
@@ -1010,6 +1035,7 @@ async function mountCity(id: string) {
|
||||
*/
|
||||
requestAnimationFrame(function pumpMinimap() {
|
||||
requestAnimationFrame(pumpMinimap);
|
||||
syncJourneyVehicle(performance.now());
|
||||
// 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
|
||||
@@ -1034,6 +1060,8 @@ requestAnimationFrame(function pumpMinimap() {
|
||||
* subtraction on the frames it skips.
|
||||
*/
|
||||
let livenessCheckedAt = 0;
|
||||
let journeySyncedAt = 0;
|
||||
let previousVehicleProgress: number | null = null;
|
||||
function pollLiveness() {
|
||||
const now = performance.now();
|
||||
if (now - livenessCheckedAt < 1000) return;
|
||||
@@ -1041,6 +1069,32 @@ function pollLiveness() {
|
||||
renderSource();
|
||||
}
|
||||
|
||||
/** Persist route progress cheaply and turn the hero's wrap into a real scale door. */
|
||||
function syncJourneyVehicle(now: number): void {
|
||||
if (!routeDriveIsActive() || !city || journey.route === null || journey.vehicle === null) {
|
||||
previousVehicleProgress = null;
|
||||
return;
|
||||
}
|
||||
const vehicle = city.vehicleState();
|
||||
if (!vehicle) return;
|
||||
const progress = vehicle.progress;
|
||||
const arrived = journey.route.direction === 1
|
||||
? previousVehicleProgress !== null && previousVehicleProgress > 0.94 && progress < 0.06
|
||||
: previousVehicleProgress !== null && previousVehicleProgress < 0.06 && progress > 0.94;
|
||||
previousVehicleProgress = progress;
|
||||
|
||||
if (arrived) {
|
||||
const endpoint = journey.route.direction === 1 ? "san-francisco" : "los-angeles";
|
||||
dispatchJourney({ type: "reach-route-endpoint", endpoint });
|
||||
dispatchJourney({ type: "exit-vehicle" });
|
||||
switchCity(endpoint === "san-francisco" ? "sf" : "socal");
|
||||
return;
|
||||
}
|
||||
if (now - journeySyncedAt < 500) return;
|
||||
journeySyncedAt = now;
|
||||
dispatchJourney({ type: "update-route-progress", progress });
|
||||
}
|
||||
|
||||
// ---- Office ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -1108,9 +1162,9 @@ async function enterOffice() {
|
||||
z: -Math.cos(pack.viewpoints[0].focus.rotation),
|
||||
}
|
||||
: { x: 0, z: -1 },
|
||||
actor: access.subject === null
|
||||
actor: actorKindForPresence(access.subject !== null, "office") === "dog"
|
||||
? { kind: "anonymous-dog", coatColor: 0x17191c, collarColor: 0xf2b134 }
|
||||
: { kind: "humanoid", outfitColor: 0x151a20, accentColor: 0xf2b134 },
|
||||
: { kind: "humanoid", ...(humanoidAppearance() ?? { 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
|
||||
@@ -1593,6 +1647,9 @@ function renderLegend() {
|
||||
renderOfficeBadge();
|
||||
if (driveControls) driveControls.hidden = !routeDriveIsActive();
|
||||
if (walkControls) walkControls.hidden = !(walking || exploring);
|
||||
for (const control of walkControls?.querySelectorAll<HTMLElement>(".flight-only") ?? []) {
|
||||
control.hidden = inside;
|
||||
}
|
||||
if (driveHint) driveHint.hidden = inside || cityId !== "california";
|
||||
if (walkHint) walkHint.hidden = !(inside || city.actorState());
|
||||
}
|
||||
@@ -2091,7 +2148,8 @@ function publishCityActorActions(): boolean {
|
||||
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,
|
||||
climb: (heldDriveKeys.has("e") || heldDriveKeys.has(" ") ? 1 : 0) -
|
||||
(heldDriveKeys.has("q") ? 1 : 0),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -2232,7 +2290,10 @@ window.addEventListener("keydown", (event) => {
|
||||
return;
|
||||
}
|
||||
const lower = event.key.toLowerCase();
|
||||
if (lower === "w" || lower === "a" || lower === "s" || lower === "d" || event.key === " ") {
|
||||
if (
|
||||
lower === "w" || lower === "a" || lower === "s" || lower === "d" ||
|
||||
lower === "q" || lower === "e" || event.key === " "
|
||||
) {
|
||||
heldDriveKeys.add(event.key === " " ? " " : lower);
|
||||
if (publishVehicleActions()) {
|
||||
event.preventDefault();
|
||||
@@ -2698,6 +2759,17 @@ async function boot() {
|
||||
|
||||
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
|
||||
access = await resolveAccess();
|
||||
if (access.subject !== null) {
|
||||
const loadedProfile = loadLocalProfile(sessionStorage, LOCAL_PROFILE_KEY);
|
||||
localProfile = loadedProfile.status === "loaded"
|
||||
? loadedProfile.profile
|
||||
: createDefaultLocalProfile(access.subject, access.subject);
|
||||
if (loadedProfile.status !== "loaded" || loadedProfile.migrated) {
|
||||
saveLocalProfile(sessionStorage, LOCAL_PROFILE_KEY, localProfile);
|
||||
}
|
||||
} else {
|
||||
localProfile = null;
|
||||
}
|
||||
dispatchJourney({
|
||||
type: "sign-in-actor-swap",
|
||||
actor: access.subject === null
|
||||
@@ -2711,7 +2783,10 @@ async function boot() {
|
||||
id: access.subject,
|
||||
kind: "humanoid",
|
||||
signedIn: true,
|
||||
profile: { displayName: access.subject },
|
||||
profile: {
|
||||
displayName: localProfile?.displayName ?? access.subject,
|
||||
color: humanoidAppearance()?.accentColor,
|
||||
},
|
||||
},
|
||||
});
|
||||
applyTimeControl();
|
||||
|
||||
Reference in New Issue
Block a user