feat(ui): single-URL game app-shell (in-memory screens/views) #35

Merged
developer merged 8 commits from feature/ui-app-shell into development 2026-05-23 20:18:09 +00:00
Showing only changes of commit 182beebcd6 - Show all commits
+193
View File
@@ -0,0 +1,193 @@
// App-shell navigation state.
//
// The game UI is a single-URL app-shell (served at `/game/`): there are no
// per-screen or per-view routes, so the address bar never changes. Two rune
// singletons hold what the URL used to encode:
//
// - `appScreen` — the top-level screen (login / lobby / lobby-create /
// game) and the active game id. It replaces the `goto`-based redirects
// and the `[id]` route param.
// - `activeView` — the in-game view (map / table / report / battle / mail /
// designer-science) and its sub-parameters. It replaces the URL params the
// old route wrappers read.
//
// Both live in this one module so they can share a single `sessionStorage`
// snapshot (persisted here) without a circular import. The snapshot is read
// once at construction to seed the initial render and rewritten on every
// mutation; `restoredGameId` lets the boot path validate a restored game
// before loading it (a cancelled/removed game falls back to lobby — see the
// dispatcher). Screen-level browser history (Back → lobby) is layered on top
// in the shell via SvelteKit shallow routing; this module is the source of
// truth, history only mirrors it.
export type AppScreen = "login" | "lobby" | "lobby-create" | "game";
export type GameView =
| "map"
| "table"
| "report"
| "battle"
| "mail"
| "designer-science";
/** In-game view plus the sub-parameters the old route segments carried. */
export interface GameViewState {
view: GameView;
/** Table entity slug when `view === "table"` (e.g. `planets`, `sciences`). */
tableEntity?: string;
/** Selected battle when `view === "battle"`; empty string = list/none. */
battleId?: string;
/** Viewed turn for the battle view; 0 = current. */
turn?: number;
/** Science id when `view === "designer-science"`; absent = new-science form. */
scienceId?: string;
}
const STORAGE_KEY = "galaxy-app-nav";
const APP_SCREENS: readonly AppScreen[] = [
"login",
"lobby",
"lobby-create",
"game",
];
const GAME_VIEWS: readonly GameView[] = [
"map",
"table",
"report",
"battle",
"mail",
"designer-science",
];
const DEFAULT_VIEW: GameViewState = { view: "map" };
interface NavSnapshot {
screen: AppScreen;
gameId: string | null;
game: GameViewState;
}
function readSnapshot(): NavSnapshot | null {
if (typeof sessionStorage === "undefined") return null;
let raw: string | null;
try {
raw = sessionStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
if (raw === null) return null;
try {
const parsed = JSON.parse(raw) as Partial<NavSnapshot> | null;
if (parsed === null || typeof parsed !== "object") return null;
const screen = APP_SCREENS.includes(parsed.screen as AppScreen)
? (parsed.screen as AppScreen)
: "lobby";
const gameId =
typeof parsed.gameId === "string" && parsed.gameId.length > 0
? parsed.gameId
: null;
return { screen, gameId, game: sanitizeView(parsed.game) };
} catch {
return null;
}
}
function sanitizeView(value: unknown): GameViewState {
if (value === null || typeof value !== "object") return { ...DEFAULT_VIEW };
const v = value as Partial<GameViewState>;
const view = GAME_VIEWS.includes(v.view as GameView)
? (v.view as GameView)
: "map";
const out: GameViewState = { view };
if (typeof v.tableEntity === "string") out.tableEntity = v.tableEntity;
if (typeof v.battleId === "string") out.battleId = v.battleId;
if (typeof v.turn === "number" && Number.isFinite(v.turn) && v.turn >= 0) {
out.turn = Math.trunc(v.turn);
}
if (typeof v.scienceId === "string") out.scienceId = v.scienceId;
return out;
}
function persist(): void {
if (typeof sessionStorage === "undefined") return;
const snapshot: NavSnapshot = {
screen: appScreen.screen,
gameId: appScreen.gameId,
game: activeView.state,
};
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
// Storage full / disabled / private-mode quota — navigation still
// works in memory; only refresh-restore is lost.
}
}
const initial = readSnapshot();
/**
* AppScreenStore owns the top-level screen and the active game id. Anonymous
* vs authenticated gating is applied by the dispatcher on top of `screen`.
*/
class AppScreenStore {
#screen = $state<AppScreen>(initial?.screen ?? "lobby");
#gameId = $state<string | null>(initial?.gameId ?? null);
/** The game id captured from a restored snapshot, for boot-time validation. */
readonly restoredGameId: string | null = initial?.gameId ?? null;
get screen(): AppScreen {
return this.#screen;
}
get gameId(): string | null {
return this.#gameId;
}
/**
* go switches the top-level screen. Entering a game requires a `gameId`;
* leaving a game clears it. Persists the snapshot. History wiring (Back →
* lobby) is added by the shell, which observes `screen`.
*/
go(screen: AppScreen, options: { gameId?: string } = {}): void {
this.#screen = screen;
if (screen === "game") {
if (options.gameId !== undefined) this.#gameId = options.gameId;
} else {
this.#gameId = null;
}
persist();
}
}
/**
* ActiveViewStore owns the in-game view and its sub-parameters. It is only
* meaningful while `appScreen.screen === "game"`.
*/
class ActiveViewStore {
#state = $state<GameViewState>(initial?.game ?? { ...DEFAULT_VIEW });
get state(): GameViewState {
return this.#state;
}
get view(): GameView {
return this.#state.view;
}
/** Replace the active in-game view and its sub-parameters. Persists. */
select(view: GameView, params: Omit<GameViewState, "view"> = {}): void {
this.#state = { view, ...params };
persist();
}
/** Reset to the default view (map). Used when entering a fresh game. */
reset(): void {
this.#state = { ...DEFAULT_VIEW };
persist();
}
}
export const appScreen = new AppScreenStore();
export const activeView = new ActiveViewStore();