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

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

Khởi động toàn bộ stack local — máy chủ dev game-ui cùng với Game API local và AG-UI Story Agent local (lần lượt khởi động máy chủ Inventory MCP) — bằng một lệnh duy nhất:

Terminal window
pnpm nx dev game-ui

Target dev trên game-uidependsOn trên game-api:devdungeon_adventure.story:agent-dev, vì vậy Nx khởi động máy chủ local của mọi dự án song song. Thông qua các kết nối mà chúng ta thiết lập trong Module 1, chúng lần lượt khởi động máy chủ Inventory MCP và DynamoDB Local. Đảm bảo container engine của bạn đang chạy, sau đó mở máy chủ dev trong trình duyệt.

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 website 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ột <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, xem hướng dẫn kết nối React → AG-UI.

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

Phần tiêu đề “Nhiệm vụ 3: Thay đổi style 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 styling. Nó import các global Shadcn được chia sẻ, ghi đè bảng màu thành chủ đề hầm ngục được thắp sáng bằng đuốc, và làm cho CopilotKit kế thừa những 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;
}

Chúng ta cần hai route — một để chọn hero, 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ự tạo.

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 new-game (shadcn Input + Button + Card) cộng với danh sách “Continue” đượ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 heading và bên dưới danh sách hiển thị trạng thái loading. Bắt đầu một trò chơi 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ủ dev tại http://localhost:4200/ bây giờ 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.

game-select.png
game-conversation.png

Trò chơi của bạn đã hoàn thành và bạn đã kiểm tra mọi phần ở local. Bây giờ hãy triển khai nó lên AWS để bạn có thể chơi từ bất kỳ đâu.

Terminal window
pnpm build
Terminal window
pnpm nx deploy-sandbox infra

Target deploy-sandbox triển khai stage sandbox được định nghĩa trong packages/infra/src/main.ts.

Lần triển khai đầu tiên của bạn sẽ mất khoảng 6 phút để hoàn thành vì nó đợi tất cả các tài nguyên ổn định hoàn toàn. Các lần triển khai tiếp theo sẽ nhanh hơn. Để tăng tốc độ lặp lại trong quá trình phát triển, bạn có thể chọn CloudFormation express mode bằng cách truyền flag --express, điều này hoàn thành mỗi thao tác tài nguyên ngay khi cấu hình của nó được áp dụng thay vì đợi ổn định hoàn toàn.

Sau khi triển khai hoàn tất, bạn sẽ thấy các output tương tự như sau:

Terminal window
dungeon-adventure-infra-sandbox-Application
dungeon-adventure-infra-sandbox-Application: deploying... [2/2]
dungeon-adventure-infra-sandbox-Application
Deployment time: 354s
Outputs:
dungeon-adventure-infra-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-infra-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-infra-sandbox-Application.InventoryMcpArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventureventoryMcpServerXXXX-YYYY
dungeon-adventure-infra-sandbox-Application.RuntimeConfigApplicationId = xxxx
dungeon-adventure-infra-sandbox-Application.StoryAgentArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventurecationStoryAgentXXXX-YYYY
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolClientIdXXX = xxxxxxxxxx
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

Điều hướng đến CloudFront URL của bạn (GameUIDistributionDomainName từ CDK outputs), đăng ký tài khoản mới và chơi trò chơi của bạn chạy hoàn toàn trên AWS!

Nhiệm vụ 6: Kết hợp các component local và đã triển khai

Phần tiêu đề “Nhiệm vụ 6: Kết hợp các component local và đã triển khai”

Bạn đã thấy hai đầu của phổ: mọi thứ local (dev) và mọi thứ đã triển khai. Trong quá trình phát triển hàng ngày, thường hữu ích khi kết hợp cả hai — ví dụ, lặp lại code website với API và agent thực đã triển khai, hoặc chạy API ở local với bảng DynamoDB thực.

Chìa khóa là biến môi trường RUNTIME_CONFIG_APP_ID. Khi một dự án chạy không có LOCAL_DEV=true, các tra cứu runtime config lấy cấu hình của chúng từ AWS AppConfig sử dụng application id này — giá trị RuntimeConfigApplicationId từ CDK outputs của bạn.

Mỗi website có một target load-runtime-config tải xuống runtime-config.json đã triển khai (Cognito pool, API endpoints, agent ARN) vào máy chủ dev local. Một số kết hợp hữu ích:

  • Website local → backend đã triển khai. Pull config đã triển khai một lần, sau đó chạy target serve thuần túy để UI giao tiếp với API và agent đã triển khai:

    Terminal window
    pnpm nx load-runtime-config game-ui
    Terminal window
    pnpm nx serve game-ui
  • API local → bảng DynamoDB thực. Chạy target serve thuần túy với RUNTIME_CONFIG_APP_ID được đặt thành application id đã triển khai:

    Terminal window
    RUNTIME_CONFIG_APP_ID=<RuntimeConfigApplicationId from CDK outputs> pnpm nx serve game-api

Tính linh hoạt này cho phép bạn kiểm tra chính xác phần bạn đang làm việc, với bất kỳ component nào — local hoặc đã triển khai — có ý nghĩa.

Chúc mừng. Bạn đã xây dựng, kiểm tra và triển khai Agentic Dungeon Adventure Game của mình! 🎉🎉🎉