feat(ui): single-URL game app-shell (in-memory screens/views) #35
@@ -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();
|
||||||
Reference in New Issue
Block a user