1
0

feat: unify life-sim play controls

This commit is contained in:
2026-08-19 03:04:53 -07:00
parent 120eac878a
commit 264daaab61
14 changed files with 1697 additions and 234 deletions
+104
View File
@@ -0,0 +1,104 @@
/**
* Renderer-neutral ownership for the one local player and the one local camera.
*
* A mode is deliberately more specific than "play": it names the subsystem
* allowed to consume held input and write the follow camera. Keeping this as a
* small pure state machine lets the DOM, Three.js scene and Journey reducer
* agree on a transition without any of them becoming the source of truth for
* the others.
*/
export type CityControlMode = "overview" | "drive" | "actor" | "aircraft";
export type OfficeControlMode = "office-overview" | "office-walk";
export type ControlMode = CityControlMode | OfficeControlMode;
export interface ControlModeAvailability {
insideOffice: boolean;
drive: boolean;
actor: boolean;
aircraft: boolean;
officeWalk: boolean;
}
export interface ControlModeState {
mode: ControlMode;
/** Increases only for an accepted change, useful to invalidate stale input. */
revision: number;
}
export interface ControlModeTransition {
previous: ControlMode;
state: ControlModeState;
changed: boolean;
accepted: boolean;
reason: "unchanged" | "unavailable" | null;
}
export interface CityControlOwnership {
orbit: boolean;
drive: boolean;
actor: boolean;
aircraft: boolean;
}
/** The renderer consumes this table; exactly one camera writer is always true. */
export function cityControlOwnership(mode: CityControlMode): CityControlOwnership {
return {
orbit: mode === "overview",
drive: mode === "drive",
actor: mode === "actor",
aircraft: mode === "aircraft",
};
}
export function createControlModeState(
mode: ControlMode = "overview",
): ControlModeState {
return { mode, revision: 0 };
}
export function cityControlMode(mode: ControlMode): CityControlMode {
if (mode === "drive" || mode === "actor" || mode === "aircraft") return mode;
return "overview";
}
export function controlModeAvailable(
mode: ControlMode,
availability: Readonly<ControlModeAvailability>,
): boolean {
if (availability.insideOffice) {
return mode === "office-overview" || (mode === "office-walk" && availability.officeWalk);
}
if (mode === "overview") return true;
if (mode === "drive") return availability.drive;
if (mode === "actor") return availability.actor;
if (mode === "aircraft") return availability.aircraft;
return false;
}
/** Resolve one requested transition without performing any side effects. */
export function transitionControlMode(
current: Readonly<ControlModeState>,
requested: ControlMode,
availability: Readonly<ControlModeAvailability>,
): ControlModeTransition {
const fallback: ControlMode = availability.insideOffice ? "office-overview" : "overview";
const next = controlModeAvailable(requested, availability) ? requested : fallback;
const accepted = next === requested;
if (next === current.mode) {
return {
previous: current.mode,
state: { ...current },
changed: false,
accepted,
reason: accepted ? "unchanged" : "unavailable",
};
}
return {
previous: current.mode,
state: { mode: next, revision: current.revision + 1 },
changed: true,
accepted,
reason: accepted ? null : "unavailable",
};
}