feat(ui): app shell with nav rail
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+27
-47
@@ -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
|
||||
<AppShell
|
||||
nav={<NavRail />}
|
||||
center={renderActivePanel()}
|
||||
right={activePanel === 'play' ? <RightRail /> : undefined}
|
||||
/>
|
||||
) : 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ActionPanel } from './ActionPanel';
|
||||
|
||||
export function PlayPanel() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ActionPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user