feat(m1): PR3 T3.0 — shell UI, action kinds, story tab #16

Merged
ginnoir merged 26 commits from feat/m1-progression into main 2026-06-11 22:52:29 -05:00
8 changed files with 432 additions and 48 deletions
Showing only changes of commit ee79872d4d - Show all commits
+53
View File
@@ -0,0 +1,53 @@
export function AboutPanel() {
return (
<div className="max-w-2xl space-y-6">
<div>
<h2 className="font-bold text-slate-100 text-xl tracking-tight">About Idlegame</h2>
<p className="text-slate-400 text-sm">A text-fantasy RPG incremental experience.</p>
</div>
<div className="space-y-6 text-slate-300">
<section className="space-y-2">
<h3 className="font-semibold text-slate-200 text-sm tracking-wide uppercase">
How to Play
</h3>
<p className="text-sm leading-relaxed text-slate-400">
Idlegame is driven by actions and choices. Select actions from the{' '}
<strong className="text-amber-400">Play</strong> screen to execute them. Some actions
are timed and can be queued. Once completed, they reward you with resources, unlock new
deeds, or advance the chronicle.
</p>
</section>
<section className="space-y-2">
<h3 className="font-semibold text-slate-200 text-sm tracking-wide uppercase">
The Chronicle
</h3>
<p className="text-sm leading-relaxed text-slate-400">
As you perform actions, you will unlock narrative points of interest. Head to the{' '}
<strong className="text-amber-400">Story</strong> tab to make crucial choices and read
the history of your journey.
</p>
</section>
<section className="space-y-2">
<h3 className="font-semibold text-slate-200 text-sm tracking-wide uppercase">
Technical Specs
</h3>
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<dt className="text-slate-500">Version</dt>
<dd className="font-mono text-slate-300">0.1.0 (M1 Milestone)</dd>
<dt className="text-slate-500">Engine</dt>
<dd className="text-slate-300">Pure TypeScript State Machine</dd>
<dt className="text-slate-500">Framework</dt>
<dd className="text-slate-300">React + Zustand + Tailwind CSS</dd>
<dt className="text-slate-500">Target Platform</dt>
<dd className="text-slate-300">Responsive Web & PWA</dd>
</dl>
</div>
</section>
</div>
</div>
);
}
+28 -48
View File
@@ -1,65 +1,45 @@
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';
import { AboutPanel } from './AboutPanel';
import { AppShell } from './AppShell';
import { NavRail } from './NavRail';
import { PlayPanel } from './PlayPanel';
import { RightRail } from './RightRail';
import { SettingsPanel } from './SettingsPanel';
import { StoryView } from './StoryView';
/**
* 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);
const activePanel = useGameStore((s) => s.activePanel);
useEffect(() => {
void gameRuntime.boot();
}, []);
const renderActivePanel = () => {
switch (activePanel) {
case 'play':
return <PlayPanel />;
case 'story':
return <StoryView />;
case 'settings':
return <SettingsPanel />;
case 'about':
return <AboutPanel />;
default:
return <PlayPanel />;
}
};
return (
<>
<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"
aria-label={storyHasUnread ? 'Story, unread' : 'Story'}
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-hidden
/>
) : 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>
</>
<AppShell
nav={<NavRail />}
center={renderActivePanel()}
right={activePanel === 'play' ? <RightRail /> : undefined}
/>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react';
interface AppShellProps {
nav: ReactNode;
center: ReactNode;
right?: ReactNode;
}
export function AppShell({ nav, center, right }: AppShellProps) {
return (
<div className="grid min-h-dvh grid-cols-[auto_1fr] md:grid-cols-[14rem_1fr_auto] text-slate-100">
<div className="border-slate-800 border-r bg-slate-950">{nav}</div>
<main className="overflow-y-auto p-6">{center}</main>
{right ? (
<aside className="hidden w-72 border-slate-800 border-l bg-slate-950 p-6 md:block">
{right}
</aside>
) : null}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { gameRuntime } from '../state/runtime';
import { type ActivePanel, useGameStore } from '../state/store';
export function NavRail() {
const activePanel = useGameStore((s) => s.activePanel);
const storyHasUnread = useGameStore((s) => s.storyHasUnread);
const navItems: { id: ActivePanel; label: string; icon: React.ReactNode }[] = [
{
id: 'play',
label: 'Play',
icon: (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"
/>
</svg>
),
},
{
id: 'story',
label: 'Story',
icon: (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
),
},
{
id: 'settings',
label: 'Settings',
icon: (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
},
{
id: 'about',
label: 'About',
icon: (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
},
];
return (
<div className="flex h-full flex-col p-4">
<div className="mb-8 flex items-center justify-between px-2">
<span className="bg-gradient-to-r from-amber-400 via-amber-200 to-amber-500 bg-clip-text font-extrabold text-lg text-transparent tracking-wider">
IDLEGAME
</span>
<span className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] font-semibold text-slate-400 tracking-wide">
M1
</span>
</div>
<nav className="flex flex-1 flex-col gap-1.5">
{navItems.map((item) => {
const isActive = activePanel === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => gameRuntime.setActivePanel(item.id)}
className={`relative flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200 ${
isActive
? 'border border-amber-500/30 bg-amber-500/10 text-amber-400'
: 'border border-transparent text-slate-400 hover:bg-slate-900/60 hover:text-slate-200'
}`}
>
{item.icon}
<span>{item.label}</span>
{item.id === 'story' && storyHasUnread ? (
<span
className="absolute top-1/2 right-3 h-2 w-2 -translate-y-1/2 animate-pulse rounded-full bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.6)]"
aria-hidden="true"
/>
) : null}
</button>
);
})}
</nav>
<div className="mt-auto border-slate-800/85 border-t pt-4 text-center">
<span className="text-[11px] text-slate-600">senpai edition</span>
</div>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { ActionPanel } from './ActionPanel';
export function PlayPanel() {
return (
<div className="space-y-6">
<ActionPanel />
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { EventLog } from './EventLog';
import { ResourceBar } from './ResourceBar';
export function RightRail() {
return (
<div className="flex h-full flex-col gap-6">
<div>
<h2 className="mb-3 font-semibold text-slate-400 text-xs tracking-wider uppercase">
Resources
</h2>
<ResourceBar />
</div>
<div className="flex-1 border-slate-900 border-t pt-4">
<h2 className="mb-3 font-semibold text-slate-400 text-xs tracking-wider uppercase">Log</h2>
<EventLog />
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { ActionDetailMode, StoryOpenMode } from '../state/prefs';
import { useGameStore } from '../state/store';
export function SettingsPanel() {
const prefs = useGameStore((s) => s.prefs);
const setPrefs = useGameStore((s) => s.setPrefs);
return (
<div className="max-w-xl space-y-6">
<div>
<h2 className="font-bold text-slate-100 text-xl tracking-tight">Game Settings</h2>
<p className="text-slate-400 text-sm">Configure your gameplay and UI preferences.</p>
</div>
<div className="space-y-4">
{/* Story Open Mode */}
<div className="flex flex-col gap-2 rounded-xl border border-slate-800 bg-slate-900/40 p-5">
<div className="flex flex-col">
<span className="font-semibold text-slate-200 text-sm">Story Navigation</span>
<span className="text-slate-400 text-xs">
How should the game navigate when story events are unlocked?
</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
{(['auto', 'choices-only', 'manual'] as StoryOpenMode[]).map((mode) => {
const labels: Record<StoryOpenMode, string> = {
auto: 'Automatically',
'choices-only': 'Choices Only',
manual: 'Manually',
};
const isActive = prefs.storyOpenMode === mode;
return (
<button
key={mode}
type="button"
onClick={() => setPrefs({ storyOpenMode: mode })}
className={`cursor-pointer rounded-lg border px-3 py-2 text-xs font-medium transition-all duration-200 ${
isActive
? 'border-amber-500/40 bg-amber-500/10 text-amber-400'
: 'border-slate-800 bg-slate-950/20 text-slate-400 hover:border-slate-700 hover:text-slate-200'
}`}
>
{labels[mode]}
</button>
);
})}
</div>
</div>
{/* Action Detail Mode */}
<div className="flex flex-col gap-2 rounded-xl border border-slate-800 bg-slate-900/40 p-5">
<div className="flex flex-col">
<span className="font-semibold text-slate-200 text-sm">Action Details</span>
<span className="text-slate-400 text-xs">
Where should action requirements and details be shown?
</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
{(['inline', 'hover', 'info-button'] as ActionDetailMode[]).map((mode) => {
const labels: Record<ActionDetailMode, string> = {
inline: 'Inline',
hover: 'Hover Tooltips',
'info-button': 'Info Button',
};
const isActive = prefs.actionDetailMode === mode;
return (
<button
key={mode}
type="button"
onClick={() => setPrefs({ actionDetailMode: mode })}
className={`cursor-pointer rounded-lg border px-3 py-2 text-xs font-medium transition-all duration-200 ${
isActive
? 'border-amber-500/40 bg-amber-500/10 text-amber-400'
: 'border-slate-800 bg-slate-950/20 text-slate-400 hover:border-slate-700 hover:text-slate-200'
}`}
>
{labels[mode]}
</button>
);
})}
</div>
</div>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
export function StoryView() {
const story = useGameStore((s) => s.story);
const storyLog = useGameStore((s) => s.storyLog);
const hasChoices = story.choices.length > 0;
return (
<div className="grid h-full grid-cols-1 gap-8 lg:grid-cols-3">
{/* Story Log Section */}
<div className="flex max-h-[30vh] flex-col overflow-y-auto border-slate-800 lg:max-h-[calc(100vh-6rem)] lg:border-r lg:pr-6">
<h2 className="mb-4 text-xs font-semibold text-slate-400 tracking-wider uppercase">
Story Log
</h2>
{storyLog.length === 0 ? (
<p className="text-sm text-slate-500 italic">The chronicle is empty...</p>
) : (
<ul className="space-y-3 text-sm text-slate-300">
{storyLog.map((entry) => (
<li
key={`${entry.nodeId}:${entry.choiceLabel ?? ''}:${entry.prose}`}
className="border-slate-900 border-b pb-2 last:border-0"
>
{entry.choiceLabel ? (
<span className="font-medium text-amber-400/80">[{entry.choiceLabel}] </span>
) : null}
<span className="leading-relaxed">{entry.prose}</span>
</li>
))}
</ul>
)}
</div>
{/* Active Node Section */}
<div className="flex min-h-[300px] flex-col justify-between rounded-xl border border-slate-800/60 bg-slate-900/40 p-6 shadow-inner lg:col-span-2">
<div className="flex flex-1 flex-col justify-center">
<p className="text-lg text-slate-100 leading-relaxed whitespace-pre-wrap">
{story.currentProse ||
'The path ahead is shrouded in mist. Begin an action to unveil your story.'}
</p>
</div>
{story.currentProse && (
<div className="mt-6 border-slate-800/80 border-t pt-6">
{hasChoices ? (
<div className="flex flex-col gap-2">
{story.choices.map((choice) => (
<button
key={choice.id}
type="button"
disabled={choice.disabled}
title={choice.disabled ? (choice.disabledReason ?? undefined) : undefined}
onClick={() => gameRuntime.applyStoryChoice(choice.id)}
className="w-full cursor-pointer rounded-lg border border-slate-700 bg-slate-950/40 px-4 py-3 text-left text-slate-200 transition-all duration-200 hover:border-amber-500/60 hover:bg-slate-900/60 disabled:cursor-not-allowed disabled:opacity-40"
>
{choice.label}
</button>
))}
</div>
) : (
<button
type="button"
onClick={() => gameRuntime.continueStory()}
className="cursor-pointer rounded-lg bg-amber-600 px-6 py-2.5 font-medium text-slate-100 transition-colors hover:bg-amber-500"
>
Continue
</button>
)}
</div>
)}
</div>
</div>
);
}