콘텐츠로 이동

UI 구축

작업 1: 모든 것을 로컬에서 실행

섹션 제목: “작업 1: 모든 것을 로컬에서 실행”

전체 로컬 스택 — game-ui 개발 서버와 로컬 Game API, 그리고 로컬 AG-UI Story Agent(이는 다시 Inventory MCP 서버를 부팅합니다) — 을 하나의 명령으로 시작합니다:

Terminal window
pnpm nx serve-local game-ui

game-ui:serve-localgame-api:serve-localdungeon_adventure.story:agent-serve-local에 대한 dependsOn을 가지고 있으므로, Nx는 모든 프로젝트의 로컬 서버를 병렬로 시작합니다. 브라우저에서 개발 서버를 열고 프롬프트에 따라 새 사용자를 생성하세요.

작업 2: CopilotKit이 이미 연결된 위치

섹션 제목: “작업 2: CopilotKit이 이미 연결된 위치”

모듈 1에서 game-ui → story에 대한 connection 제너레이터를 실행했을 때, Shadcn 웹사이트의 AG-UI 통합이 자동으로 생성되었습니다. 간단히 살펴볼 가치가 있습니다:

  • 디렉터리packages/game-ui/src/
    • 디렉터리components/
      • AguiProvider.tsx 모든 AG-UI 에이전트에 등록된 단일 CopilotKitProvider.
      • 디렉터리copilot/
        • index.tsx 테마가 적용된 CopilotChat / CopilotSidebar / CopilotPopup을 재내보냅니다.
        • ShadcnAssistantMessage.tsx, ShadcnUserMessage.tsx, ShadcnChatInput.tsx, ShadcnCursor.tsx, copilot.css
    • 디렉터리hooks/
      • useAguiStoryAgent.tsx 배포된 Story Agent를 가리키는 @ag-ui/client HttpAgent를 인스턴스화하고 threadId를 AgentCore의 33자 최소 세션 ID로 패딩합니다.
    • main.tsx <App /><AguiProvider>로 감쌉니다.

우리가 해야 할 일은 라우트에 <CopilotChat agentId="agent" threadId={...} />를 추가하는 것뿐입니다. 통합이 어떻게 구성되는지에 대한 자세한 내용은 React → AG-UI 연결 가이드를 참조하세요.

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;
}
/* CopilotChat positions its sticky input wrapper with
* `pointer-events: none` (so scroll events can pass through to the message
* list behind it). That inherits into the input too, which means users can't
* click the chat input. Re-enable pointer events for every direct child of
* the sticky wrapper — the input itself, its buttons, and anything else the
* chat surface might render there. */
.copilotKitChat > .cpk\:pointer-events-none > * {
pointer-events: auto;
}

두 개의 라우트가 필요합니다 — 하나는 영웅을 선택하는 것이고, 다른 하나는 플레이하는 것입니다. 둘 다 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

축하합니다. 에이전트 기반 던전 어드벤처 게임을 성공적으로 구축하고 배포하셨습니다! 🎉🎉🎉