Bỏ qua để đến nội dung

Xây dựng giao diện người dùng

Khởi động toàn bộ stack cục bộ — máy chủ phát triển game-ui cùng với Game API cục bộ và AG-UI Story Agent cục bộ (lần lượt khởi động Inventory MCP server) — chỉ với một lệnh:

Terminal window
pnpm nx serve-local game-ui

game-ui:serve-localdependsOn trên game-api:serve-localdungeon_adventure.story:agent-serve-local, vì vậy Nx khởi động máy chủ cục bộ của mọi dự án song song. Mở máy chủ phát triển trong trình duyệt và làm theo các hướng dẫn để tạo người dùng mới.

Nhiệm vụ 2: Nơi CopilotKit đã được kết nối

Phần tiêu đề “Nhiệm vụ 2: Nơi CopilotKit đã được kết nối”

Khi bạn chạy generator connection cho game-ui → story trong Module 1, tích hợp AG-UI của trang web Shadcn đã được tạo cho bạn. Đáng để xem qua nhanh:

  • Thư mụcpackages/game-ui/src/
    • Thư mụccomponents/
      • AguiProvider.tsx Single CopilotKitProvider registered with every AG-UI agent.
      • Thư mụccopilot/
        • index.tsx Re-exports themed CopilotChat / CopilotSidebar / CopilotPopup.
        • ShadcnAssistantMessage.tsx, ShadcnUserMessage.tsx, ShadcnChatInput.tsx, ShadcnCursor.tsx, copilot.css
    • Thư mụchooks/
      • useAguiStoryAgent.tsx Instantiates an @ag-ui/client HttpAgent pointing at the deployed Story Agent and pads threadId to AgentCore’s 33-character minimum session id.
    • main.tsx Wraps <App /> in <AguiProvider>

Tất cả những gì chúng ta cần làm là thêm <CopilotChat agentId="agent" threadId={...} /> vào một route. Để biết thêm chi tiết về cách tích hợp được kết hợp, hãy xem hướng dẫn kết nối React → AG-UI.

Nhiệm vụ 3: Thay đổi giao diện cho hầm ngục

Phần tiêu đề “Nhiệm vụ 3: Thay đổi giao diện cho hầm ngục”

Thay thế packages/game-ui/src/styles.css — đây là file duy nhất chúng ta thay đổi cho việc tạo kiểu. Nó import các global Shadcn được chia sẻ, ghi đè bảng màu thành theme hầm ngục được thắp sáng bởi ngọn đuốc, và làm cho CopilotKit kế thừa các màu đó:

@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;
}

Chúng ta cần hai route — một để chọn nhân vật, một để chơi. Cả hai đều sử dụng các component shadcn và chat CopilotKit; không có giao diện chat tự viết.

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>
);
}

Đây là bộ chọn trò chơi: form trò chơi mới (shadcn Input + Button + Card) cộng với danh sách “Tiếp tục” được cung cấp bởi useGameApi().games.query với useInfiniteQuery — một <div> sentinel ở dưới cùng được theo dõi bởi IntersectionObserver tự động gọi fetchNextPage() khi được cuộn vào tầm nhìn, và các spinner bên cạnh tiêu đề và bên dưới danh sách hiển thị trạng thái đang tải. Bắt đầu một trò chơi sẽ saveGame cặp (playerName, genre) (để nó hiển thị lần sau) và điều hướng đến route chơi.

Sau khi lưu, máy chủ phát triển tại http://localhost:4200/ giờ đây sẽ cho phép bạn bắt đầu một cuộc phiêu lưu và trò chuyện với Story Agent.

Bạn cũng có thể build & deploy code của mình lên CloudFront nếu muốn.
game-select.png
game-conversation.png

Chúc mừng. Bạn đã xây dựng và triển khai Trò chơi Phiêu lưu Hầm ngục với Agent của mình! 🎉🎉🎉