Skip to content

UIの構築

タスク1: すべてをローカルで実行

Section titled “タスク1: すべてをローカルで実行”

フルローカルスタック — game-ui開発サーバー、ローカルGame API、ローカルAG-UI Story Agent(これがInventory MCPサーバーを起動します)— を1つのコマンドで起動します:

Terminal window
pnpm nx serve-local game-ui

game-ui:serve-localgame-api:serve-localdungeon_adventure.story:agent-serve-localdependsOnしているため、Nxは各プロジェクトのローカルサーバーを並行して起動します。ブラウザで開発サーバーを開き、新規ユーザー作成のプロンプトに従います。

タスク2: CopilotKitが既に配線されている場所

Section titled “タスク2: CopilotKitが既に配線されている場所”

モジュール1game-ui → storyconnectionジェネレータを実行したとき、ShadcnウェブサイトのAG-UI統合が自動生成されました。簡単に確認してみましょう:

  • Directorypackages/game-ui/src/
    • Directorycomponents/
      • AguiProvider.tsx すべてのAG-UIエージェントに登録された単一のCopilotKitProvider
      • Directorycopilot/
        • index.tsx テーマ化されたCopilotChat / CopilotSidebar / CopilotPopupを再エクスポート。
        • ShadcnAssistantMessage.tsx, ShadcnUserMessage.tsx, ShadcnChatInput.tsx, ShadcnCursor.tsx, copilot.css
    • Directoryhooks/
      • useAguiStoryAgent.tsx デプロイされたStory Agentを指す@ag-ui/clientHttpAgentをインスタンス化し、threadIdをAgentCoreの最小セッションID長である33文字にパディング。
    • main.tsx <App /><AguiProvider>でラップ

必要なのは、ルートに<CopilotChat agentId="agent" threadId={...} />を配置するだけです。統合の詳細については、React → AG-UI接続ガイドを参照してください。

タスク3: ダンジョン用のスタイル変更

Section titled “タスク3: ダンジョン用のスタイル変更”

packages/game-ui/src/styles.cssを置き換えます。これはスタイリングのために変更する唯一のファイルです。共有のShadcnグローバルをインポートし、パレットを松明で照らされたダンジョンテーマに上書きし、CopilotKitがそれらの色を継承するようにします:

@import '../../common/shadcn/src/styles/globals.css';
@source './**/*.{ts,tsx}';
/* Dungeon theme — torch-lit parchment on stone. Applied to `:root` for the
* page and to `[data-copilotkit][data-copilotkit]` for CopilotKit's chat
* surface; CopilotKit ships a same-specificity `[data-copilotkit]` rule that
* resets `--background` back to white, so we bump specificity with the
* doubled selector. */
:root,
[data-copilotkit][data-copilotkit] {
--background: oklch(0.18 0.02 60);
--foreground: oklch(0.92 0.04 85);
--card: oklch(0.22 0.03 60);
--card-foreground: oklch(0.92 0.04 85);
--popover: oklch(0.2 0.02 60);
--popover-foreground: oklch(0.92 0.04 85);
--primary: oklch(0.75 0.15 75);
--primary-foreground: oklch(0.15 0.02 60);
--secondary: oklch(0.28 0.04 60);
--secondary-foreground: oklch(0.92 0.04 85);
--muted: oklch(0.25 0.02 60);
--muted-foreground: oklch(0.7 0.04 85);
--accent: oklch(0.4 0.12 30);
--accent-foreground: oklch(0.95 0.04 85);
--destructive: oklch(0.55 0.22 25);
--border: oklch(0.35 0.03 60);
--input: oklch(0.3 0.03 60);
--ring: oklch(0.75 0.15 75);
--sidebar: oklch(0.15 0.02 60);
--sidebar-foreground: oklch(0.88 0.04 85);
--sidebar-primary: oklch(0.75 0.15 75);
--sidebar-primary-foreground: oklch(0.15 0.02 60);
--sidebar-accent: oklch(0.28 0.04 60);
--sidebar-accent-foreground: oklch(0.92 0.04 85);
--sidebar-border: oklch(0.3 0.03 60);
--sidebar-ring: oklch(0.75 0.15 75);
}
body {
font-family: 'Georgia', 'Cambria', serif;
background:
radial-gradient(circle at 20% 10%, oklch(0.25 0.05 70 / 0.4), transparent 40%),
radial-gradient(circle at 80% 90%, oklch(0.25 0.1 30 / 0.3), transparent 40%),
var(--background);
}
h1, h2, h3 {
letter-spacing: 0.05em;
}

2つのルートが必要です。1つはヒーローを選ぶため、もう1つはプレイするためです。どちらもshadcnコンポーネントとCopilotKitチャットを使用します。手作りのチャットUIはありません。

import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from ':dungeon-adventure/common-shadcn/components/ui/button';
import { Input } from ':dungeon-adventure/common-shadcn/components/ui/input';
import {
Card,
CardContent,
} from ':dungeon-adventure/common-shadcn/components/ui/card';
import { Spinner } from ':dungeon-adventure/common-shadcn/components/ui/spinner';
import { useGameApi } from '../hooks/useGameApi';
import type { IGame } from ':dungeon-adventure/game-api';
const GENRES = ['medieval', 'zombie', 'superhero'] as const;
export const Route = createFileRoute('/')({ component: RouteComponent });
function RouteComponent() {
const [playerName, setPlayerName] = useState('');
const [pending, setPending] = useState<IGame['genre'] | null>(null);
const navigate = useNavigate();
const gameApi = useGameApi();
const saveGame = useMutation(gameApi.games.save.mutationOptions());
const games = useInfiniteQuery(
gameApi.games.query.infiniteQueryOptions(
{ limit: 10 },
{ getNextPageParam: ({ cursor }) => cursor ?? undefined },
),
);
const savedGames = useMemo(
() => games.data?.pages.flatMap((p) => p.items) ?? [],
[games.data],
);
// Auto-fetch subsequent pages when the sentinel at the bottom of the list
// scrolls into view — keeps the homepage a simple infinite scroll without
// a "Load more" button.
const sentinel = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = sentinel.current;
if (!el || !games.hasNextPage) return;
const io = new IntersectionObserver(
(entries) => {
if (
entries.some((e) => e.isIntersecting) &&
!games.isFetchingNextPage
) {
void games.fetchNextPage();
}
},
{ rootMargin: '120px' },
);
io.observe(el);
return () => io.disconnect();
}, [games.hasNextPage, games.isFetchingNextPage, games.fetchNextPage]);
const startGame = async (player: string, genre: IGame['genre']) => {
if (!player.trim()) return;
setPending(genre);
try {
if (!savedGames.find((g) => g.playerName === player)) {
await saveGame.mutateAsync({ playerName: player, genre });
}
await navigate({
to: '/game/$playerName',
params: { playerName: player },
search: { genre },
});
} finally {
setPending(null);
}
};
const busy = pending !== null;
const firstLoad = games.isLoading;
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
<div className="text-center">
<h1 className="bg-gradient-to-r from-amber-300 to-rose-400 bg-clip-text text-5xl font-bold text-transparent">
AI Dungeon Adventure
</h1>
<p className="text-muted-foreground mt-2">
Pick a hero name, choose a genre, begin.
</p>
</div>
<Card>
<CardContent className="flex flex-col gap-4 pt-6">
<Input
placeholder="Your hero's name"
value={playerName}
disabled={busy}
onChange={(e) => setPlayerName(e.target.value)}
/>
<div className="grid grid-cols-3 gap-3">
{GENRES.map((genre) => (
<Button
key={genre}
variant="secondary"
disabled={!playerName.trim() || busy}
onClick={() => startGame(playerName, genre)}
>
{pending === genre && <Spinner />}
{genre[0].toUpperCase() + genre.slice(1)}
</Button>
))}
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-2">
<h2 className="flex items-center gap-2 text-xl font-semibold">
Continue
{(firstLoad || games.isFetching) && <Spinner className="size-4" />}
</h2>
{!firstLoad && savedGames.length === 0 && (
<p className="text-muted-foreground text-sm">
No saved games yet — start a new adventure above.
</p>
)}
{savedGames.map((g) => (
<Button
key={g.playerName}
variant="outline"
className="justify-between"
disabled={busy}
onClick={() => startGame(g.playerName, g.genre)}
>
<span>{g.playerName}</span>
<span className="text-muted-foreground text-sm">
{g.genre[0].toUpperCase() + g.genre.slice(1)}
</span>
</Button>
))}
<div ref={sentinel} aria-hidden className="h-1" />
{games.isFetchingNextPage && (
<div className="flex justify-center py-2">
<Spinner />
</div>
)}
</div>
</div>
);
}

これはゲームピッカーです: 新規ゲームフォーム(shadcnのInput + Button + Card)と、useInfiniteQueryを使用したuseGameApi().games.queryによって供給される「続きから」リスト — 下部のセンチネル<div>IntersectionObserverによって監視され、ビューにスクロールされると自動的にfetchNextPage()を呼び出し、見出しの横とリストの下のスピナーがローディング状態を表示します。ゲームを開始すると(playerName, genre)ペアをsaveGameし(次回表示されるように)、プレイルートに移動します。

保存すると、http://localhost:4200/の開発サーバーで冒険を開始し、Story Agentとチャットできるようになります。

CloudFrontへのコードのビルド&デプロイも可能です
game-select.png
game-conversation.png

おめでとうございます。エージェント型ダンジョンアドベンチャーゲームの構築とデプロイが完了しました! 🎉🎉🎉