feat(m1): playable loop — story graph, story panel, queue UI #14

Merged
ginnoir merged 20 commits from feat/m1-playable-loop into main 2026-06-11 19:26:54 -05:00
2 changed files with 88 additions and 11 deletions
Showing only changes of commit fdefe83b89 - Show all commits
+48 -11
View File
@@ -1,27 +1,64 @@
import { useEffect } from 'react';
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
import { ActionPanel } from './ActionPanel';
import { EventLog } from './EventLog';
import { ResourceBar } from './ResourceBar';
import { SettingsDrawer } from './SettingsDrawer';
import { StoryPanel } from './StoryPanel';
/**
* Walking-skeleton view shell. Boots the runtime once on mount; everything else
* renders from the Zustand store the runtime feeds. M1 turns this into a game.
* M1 playable-loop shell. Boots the runtime once on mount; everything else
* renders from the Zustand store the runtime feeds.
*/
export function App() {
const storyHasUnread = useGameStore((s) => s.storyHasUnread);
const settingsOpen = useGameStore((s) => s.settingsOpen);
const setSettingsOpen = useGameStore((s) => s.setSettingsOpen);
useEffect(() => {
void gameRuntime.boot();
}, []);
return (
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-5 px-4 py-8 text-slate-100">
<header>
<h1 className="font-bold text-2xl tracking-tight">Idlegame</h1>
<p className="text-slate-500 text-sm">Walking skeleton M0 scaffold.</p>
</header>
<ResourceBar />
<ActionPanel />
<EventLog />
</main>
<>
<StoryPanel />
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-5 px-4 py-8 text-slate-100">
<header className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<h1 className="font-bold text-2xl tracking-tight">Idlegame</h1>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => gameRuntime.openStoryPanel()}
className="relative rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-1.5 text-sm transition-colors hover:border-amber-500/60"
>
Story
{storyHasUnread ? (
<span
className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-amber-500"
aria-label="Unread story"
/>
) : null}
</button>
<button
type="button"
aria-label="Settings"
aria-expanded={settingsOpen}
onClick={() => setSettingsOpen(!settingsOpen)}
className="rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-1.5 text-sm transition-colors hover:border-amber-500/60"
>
</button>
</div>
</div>
<p className="text-slate-500 text-sm">M1 playable loop</p>
<SettingsDrawer />
</header>
<ResourceBar />
<ActionPanel />
<EventLog />
</main>
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useGameStore } from '../state/store';
import type { ActionDetailMode, StoryOpenMode } from '../state/prefs';
/** Preference panel toggled from the header gear; changes persist immediately. */
export function SettingsDrawer() {
const open = useGameStore((s) => s.settingsOpen);
const prefs = useGameStore((s) => s.prefs);
const setPrefs = useGameStore((s) => s.setPrefs);
if (!open) return null;
return (
<div className="rounded-lg border border-slate-700 bg-slate-900 p-4">
<label className="flex flex-col gap-1 text-sm">
Story opens
<select
value={prefs.storyOpenMode}
onChange={(e) => setPrefs({ storyOpenMode: e.target.value as StoryOpenMode })}
className="rounded border border-slate-600 bg-slate-800 px-2 py-1 text-slate-100"
>
<option value="auto">Automatically</option>
<option value="choices-only">Choices only</option>
<option value="manual">Manually</option>
</select>
</label>
<label className="mt-3 flex flex-col gap-1 text-sm">
Action details
<select
value={prefs.actionDetailMode}
onChange={(e) => setPrefs({ actionDetailMode: e.target.value as ActionDetailMode })}
className="rounded border border-slate-600 bg-slate-800 px-2 py-1 text-slate-100"
>
<option value="inline">Inline subtitles</option>
<option value="hover">Hover tooltips</option>
<option value="info-button">Info button</option>
</select>
</label>
</div>
);
}