feat: bootstrap walking skeleton

This commit is contained in:
ginnoir
2026-06-11 17:06:52 -05:00
commit ec8f857845
44 changed files with 6677 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import { content } from '../content';
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
/** Action list — a start button per action, with a progress bar on the active one. */
export function ActionPanel() {
const activeActionId = useGameStore((state) => state.activeActionId);
const actionProgress = useGameStore((state) => state.actionProgress);
return (
<section aria-label="Actions" className="flex flex-col gap-2">
<h2 className="font-medium text-slate-300 text-sm uppercase tracking-wide">Actions</h2>
{content.actions.map((action) => {
const isActive = action.id === activeActionId;
return (
<button
type="button"
key={action.id}
onClick={() => gameRuntime.startAction(action.id)}
className="relative overflow-hidden rounded-lg border border-slate-700 bg-slate-800/70 px-4 py-3 text-left transition-colors hover:border-amber-500/60 hover:bg-slate-800"
>
{isActive ? (
<div
className="absolute inset-y-0 left-0 bg-amber-500/15"
style={{ width: `${Math.round(actionProgress * 100)}%` }}
aria-hidden="true"
/>
) : null}
<div className="relative flex items-center justify-between">
<span className="font-medium text-slate-100">{action.name}</span>
<span className="text-slate-400 text-xs">{isActive ? 'running…' : 'start'}</span>
</div>
</button>
);
})}
</section>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { gameRuntime } from '../state/runtime';
import { ActionPanel } from './ActionPanel';
import { EventLog } from './EventLog';
import { ResourceBar } from './ResourceBar';
/**
* 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.
*/
export function App() {
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>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { useGameStore } from '../state/store';
/** Event log stub — newest entries at the bottom. */
export function EventLog() {
const log = useGameStore((state) => state.log);
return (
<section aria-label="Event log" className="flex flex-col gap-1">
<h2 className="font-medium text-slate-300 text-sm uppercase tracking-wide">Log</h2>
<ol className="flex max-h-48 flex-col gap-1 overflow-y-auto rounded-lg border border-slate-800 bg-slate-900/60 p-3 text-sm">
{log.length === 0 ? (
<li className="text-slate-500"></li>
) : (
log.map((line, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: log is append-only; index is stable
<li key={index} className="text-slate-400">
{line}
</li>
))
)}
</ol>
</section>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { formatNum } from '../engine/num';
import { useGameStore } from '../state/store';
/** Resource readout — name + human-readable amount for each resource. */
export function ResourceBar() {
const resources = useGameStore((state) => state.resources);
return (
<section
aria-label="Resources"
className="flex flex-wrap gap-3 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
>
{resources.length === 0 ? (
<span className="text-slate-500 text-sm">Loading</span>
) : (
resources.map((resource) => (
<div key={resource.id} className="flex items-baseline gap-2">
<span className="text-slate-400 text-sm">{resource.name}</span>
<span className="font-semibold text-amber-400 tabular-nums">
{formatNum(resource.amount)}
</span>
</div>
))
)}
</section>
);
}