Xây dựng giao diện người dùng
Nhiệm vụ 1: Cấu hình máy chủ phát triển cục bộ
Phần tiêu đề “Nhiệm vụ 1: Cấu hình máy chủ phát triển cục bộ”Để bắt đầu xây dựng giao diện người dùng, chúng ta cần cấu hình máy chủ phát triển cục bộ để trỏ đến sandbox đã triển khai của chúng ta. Để thực hiện điều này, hãy chạy lệnh sau:
pnpm nx run @dungeon-adventure/game-ui:load:runtime-configyarn nx run @dungeon-adventure/game-ui:load:runtime-confignpx nx run @dungeon-adventure/game-ui:load:runtime-configbunx nx run @dungeon-adventure/game-ui:load:runtime-configLệnh này sẽ tải xuống file runtime-config.json đã được triển khai và lưu trữ nó cục bộ trong thư mục packages/game-ui/public.
Để khởi động máy chủ phát triển, hãy chạy lệnh sau:
pnpm nx run @dungeon-adventure/game-ui:serveyarn nx run @dungeon-adventure/game-ui:servenpx nx run @dungeon-adventure/game-ui:servebunx nx run @dungeon-adventure/game-ui:serveMở trang web cục bộ của bạn trong trình duyệt, nơi bạn sẽ được nhắc đăng nhập và làm theo các hướng dẫn để tạo người dùng mới. Sau khi hoàn tất, bạn sẽ thấy trang web cơ bản:
Nhiệm vụ 2: Tạo route ‘/game’ mới
Phần tiêu đề “Nhiệm vụ 2: Tạo route ‘/game’ mới”Hãy giới thiệu các khả năng của @tanstack/react-router bằng cách tạo một route an toàn kiểu mới.
Để thực hiện điều này, hãy tạo một file trống tại vị trí sau: packages/game-ui/src/routes/game/index.tsx. Bạn sẽ nhận thấy file được cập nhật ngay lập tức.
@tanstack/react-router tự động cấu hình route mới của bạn và file bạn vừa tạo đã được điền sẵn với đường dẫn route:
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/game/')({ component: RouteComponent,})
function RouteComponent() { return <div>Hello "/game/"!</div>}Nếu bạn điều hướng đến http://localhost:4200/game, bạn sẽ thấy trang mới của bạn đã được hiển thị.
Cập nhật file index.tsx để tải route /game mới của chúng ta theo mặc định. Khi bạn cập nhật trường to, bạn có danh sách các route an toàn kiểu để chọn.
import { createFileRoute, Navigate } from '@tanstack/react-router';
export const Route = createFileRoute('/')({ component: () => <Navigate to="/game" />,});import { ContentLayout, Header, SpaceBetween, Container,} from '@cloudscape-design/components';import { createFileRoute } from '@tanstack/react-router';import { createFileRoute, Navigate } from '@tanstack/react-router';
export const Route = createFileRoute('/')({ component: RouteComponent, component: () => <Navigate to="/game" />,});
function RouteComponent() { return ( <ContentLayout header={<Header>Welcome</Header>}> <SpaceBetween size="l"> <Container>Welcome to your new React website!</Container> </SpaceBetween> </ContentLayout> );}Bạn có thể xóa thư mục packages/game-ui/src/routes/welcome/ vì nó không còn cần thiết nữa.
Nhiệm vụ 3: Cập nhật layout
Phần tiêu đề “Nhiệm vụ 3: Cập nhật layout”Layout mặc định được cấu hình giống với ứng dụng kinh doanh kiểu SaaS hơn là một trò chơi.
Để cấu hình lại layout và thay đổi theme để giống với trò chơi kiểu hầm ngục hơn, hãy thực hiện các thay đổi sau đối với packages/game-ui/src:
export default { applicationName: 'Dungeon Adventure',};import { useAuth } from 'react-oidc-context';import * as React from 'react';import Config from '../../config';import { TopNavigation } from '@cloudscape-design/components';
/** * Defines the App layout and contains logic for routing. */const AppLayout: React.FC<React.PropsWithChildren> = ({ children }) => { const { user, removeUser, signoutRedirect, clearStaleState } = useAuth();
return ( <> <TopNavigation identity={{ href: '/', title: Config.applicationName, }} utilities={[ { type: 'menu-dropdown', text: `${user?.profile?.['cognito:username']}`, iconName: 'user-profile-active', onItemClick: (e) => { if (e.detail.id === 'signout') { removeUser(); signoutRedirect({ post_logout_redirect_uri: window.location.origin, extraQueryParams: { redirect_uri: window.location.origin, response_type: 'code', }, }); clearStaleState(); } }, items: [{ id: 'signout', text: 'Sign out' }], }, ]} /> { children } </> );};export default AppLayout;/* Game styles */:root { --primary-color: rgba(252, 214, 112, 1); --secondary-color: rgba(252, 214, 112, 0.8); --inventory-color: rgba(144, 238, 144, 0.85); --inventory-border: rgba(34, 139, 34, 1); --user-color: rgba(135, 206, 250, 0.85); --user-border: rgba(30, 144, 255, 1); --background-dark: #161d26; --background-light: #2a2c3c; --text-light: #e1e1e6; --text-dark: #1f2937;}
div#root { min-height: 100vh; display: flex; flex-direction: column;}
html,body { margin: 0; padding: 0; min-height: 100vh; width: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: var(--background-dark); color: var(--text-dark);}
/* Game container and interface */.game-interface { margin: 2rem; min-height: 100%; flex-grow: 1; display: flex; flex-direction: column;}
/* Header styles */.game-header { text-align: center; margin-bottom: 2rem; padding: 1rem;}
.game-header h1 { font-size: 2.5rem; font-weight: bold; background: linear-gradient(45deg, #ffd700, #ff6b6b); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); margin: 0;}
/* Saved games section */.saved-games { margin-bottom: 2rem;}
.saved-games h2,.new-game h2 { font-size: 1.5rem; margin-bottom: 1rem; color: var(--text-light);}
.game-list { display: flex; flex-direction: column; gap: 0.5rem;}
.game-session { background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; padding: 1rem; cursor: pointer; transition: all 0.3s ease; width: 100%; text-align: left;}
.game-session:hover { transform: translateY(-2px); background: rgba(255, 255, 255, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);}
.player-name { font-weight: 600; margin-bottom: 0.25rem; color: var(--text-light);}
.genre-name { font-size: 0.875rem; color: rgba(255, 255, 255, 0.7);}
/* New game section */.new-game { margin-top: 2rem;}
.game-setup { display: flex; flex-direction: column; gap: 1rem;}
span[data-style='generating'] { color: rgba(252, 214, 112, 1) !important;}
.name-input,.action-input { width: 100%; padding: 0.75rem; /* background: rgba(255, 255, 255, 0.1); */ border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 8px; /* color: var(--text-light); */ font-size: 1rem;}
.name-input:focus,.action-input:focus { outline: none; border-color: var(--primary-color); /* background: rgba(255, 255, 255, 0.15); */}
.genre-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;}
.genre-button { background: rgba(255, 255, 255, 0.1); border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 8px; padding: 1rem; color: var(--text-light); font-weight: 600; cursor: pointer; transition: all 0.3s ease;}
.genre-button:hover { background: rgba(252, 214, 112, 1); color: var(--text-dark); border-color: var(--primary-color); transform: translateY(-2px);}
/* Messages area */.messages-area { flex: 1; overflow-y: auto; margin-bottom: 1rem; align-content: flex-end;}
.messages-container { display: flex; flex-direction: column; gap: 1rem; padding: 1rem;}
.message { padding: 1rem; border-radius: 8px; max-width: 80%; line-height: 1.5;}
.message-header { font-weight: 600; margin-bottom: 0.5rem; font-size: 1.1rem;}
.message-content { word-wrap: break-word; white-space: pre-wrap;}
.message.assistant { background: var(--secondary-color); border-left: 4px solid var(--primary-color); margin-right: auto; margin: 0 !important;}
.message.assistant .message-header { color: var(--text-dark);}
.message.user { background: var(--user-color); border-right: 4px solid var(--user-border); margin-left: auto;}
.message.user .message-header { color: var(--text-dark);}
.inventory-overlay { position: fixed; top: 1rem; right: 1rem; background: var(--inventory-color); border: 4px solid var(--inventory-border); border-radius: 8px; padding: 1rem; max-width: 250px; z-index: 1000; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);}
.inventory-header { font-weight: 600; margin-bottom: 0.5rem; color: var(--text-dark); font-size: 1.1rem;}
.inventory-items { display: flex; flex-direction: column; gap: 0.25rem;}
.inventory-item { color: var(--text-dark); padding: 0.25rem 0; font-size: 0.95rem;}
/* Input area */.input-area { padding: 1rem; position: sticky; bottom: 0;}
/* Scrollbar styling */.messages-area::-webkit-scrollbar { width: 6px;}
.messages-area::-webkit-scrollbar-track { background: transparent;}
.messages-area::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, 0.3); border-radius: 3px;}
/* For Firefox */.messages-area { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.3) transparent;}Xóa file packages/game-ui/src/hooks/useAppLayout.tsx vì nó không được sử dụng.
Nhiệm vụ 4: Tích hợp Story Agent
Phần tiêu đề “Nhiệm vụ 4: Tích hợp Story Agent”Hãy tạo một hook để khởi tạo client cho việc tương tác với Story Agent của chúng ta.
import { useAuth } from 'react-oidc-context';import { useRuntimeConfig } from './useRuntimeConfig';import { useMemo } from 'react';
export interface GenerateStoryInput { playerName: string; genre: string; actions: { role: string; content: string }[];}
export const useStoryAgent = () => { const { agentArn } = useRuntimeConfig(); const region = agentArn.split(':')[3]; const url = `https://bedrock-agentcore.${region}.amazonaws.com/runtimes/${encodeURIComponent(agentArn)}/invocations?qualifier=DEFAULT`;
const { user } = useAuth();
return useMemo( () => ({ generateStory: async function* ( opts: GenerateStoryInput, ): AsyncIterableIterator<string> { const response = await fetch(url, { headers: { Authorization: `Bearer ${user?.id_token}`, 'Content-Type': 'application/json', }, method: 'POST', body: JSON.stringify(opts), });
const reader = response.body ?.pipeThrough(new TextDecoderStream()) .getReader();
if (!reader) return;
while (true) { const { value, done } = await reader.read();
if (done) return; yield value; } }, }), [url, user?.id_token], );};Hook này:
- Truy xuất Agent ARN từ
runtime-config.json, - Xây dựng URL gọi AgentCore Runtime từ ARN,
- Gọi Agent với JWT token của người dùng đã đăng nhập và một session ID ngẫu nhiên, và
- Trả về một async iterator để tiêu thụ các chunk thông điệp agent được stream.
Nhiệm vụ 5: Tạo các trang Game
Phần tiêu đề “Nhiệm vụ 5: Tạo các trang Game”Để tạo các trang Game sẽ gọi API của chúng ta và hoàn thiện việc triển khai trò chơi, hãy cập nhật các file sau trong packages/game-ui/src/routes/game:
import { FormField, Spinner } from '@cloudscape-design/components';import { useInfiniteQuery, useMutation } from '@tanstack/react-query';import { createFileRoute, useNavigate } from '@tanstack/react-router';import { createRef, LegacyRef, MutableRefObject, useEffect, useMemo, useState,} from 'react';import { useGameApi } from '../../hooks/useGameApi';import { IAction, IGame } from ':dungeon-adventure/game-api';
type IGameState = Omit<IGame, 'lastUpdated'> & { actions: IAction[] };
export const Route = createFileRoute('/game/')({ component: RouteComponent,});
// hook to check if a ref is visible on the screenexport function useIsVisible(ref: MutableRefObject<any>) { const [isIntersecting, setIntersecting] = useState(false);
useEffect(() => { const observer = new IntersectionObserver(([entry]) => setIntersecting(entry.isIntersecting), );
ref.current && observer.observe(ref.current); return () => { observer.disconnect(); }; }, [ref]);
return isIntersecting;}
function RouteComponent() { const [playerName, setPlayerName] = useState(''); const navigate = useNavigate(); const ref = createRef(); const isLastGameVisible = useIsVisible(ref);
const gameApi = useGameApi(); const saveGameMutation = useMutation(gameApi.games.save.mutationOptions());
const { data: gamesPages, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery( gameApi.games.query.infiniteQueryOptions( { limit: 10 }, { getNextPageParam: ({ cursor }) => cursor }, ), ); const games = useMemo(() => { return gamesPages?.pages.flatMap((page) => page.items) || []; }, [gamesPages]);
// Fetch more games if the last game is visible and there are more games useEffect(() => { if (isLastGameVisible && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, [isFetchingNextPage, hasNextPage, fetchNextPage, isLastGameVisible]);
const playerAlreadyExists = (playerName?: string) => { return !!games?.find((s) => s.playerName === playerName); };
// create a new game const handleStartGame = async ( playerName: string, genre: IGameState['genre'], ) => { if (playerAlreadyExists(playerName)) { return; } try { await saveGameMutation.mutateAsync({ playerName, genre, });
await handleLoadGame(playerName, genre); } catch (error) { console.error('Failed to start game:', error); } };
// load an existing game const handleLoadGame = async ( playerName: string, genre: IGameState['genre'], ) => { await navigate({ to: '/game/$playerName', params: { playerName }, search: { genre }, }); };
return ( <div className="game-interface"> <header className="game-header"> <h1>AI Dungeon Adventure</h1> </header>
{/* New Game Section */} <div className="new-game"> <h2>Start New Game</h2> <div className="game-setup"> <FormField errorText={ playerAlreadyExists(playerName) ? `${playerName} already exists` : undefined } > <input type="text" placeholder="Enter your name" className="name-input" onChange={(e) => setPlayerName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { const input = e.currentTarget; handleStartGame(input.value, 'medieval'); } }} /> </FormField> <div className="genre-grid"> {(['zombie', 'superhero', 'medieval'] as const).map((genre) => ( <button key={genre} className="genre-button" onClick={() => { const playerName = document.querySelector('input')?.value; if (playerName) { handleStartGame(playerName, genre); } }} > {genre.charAt(0).toUpperCase() + genre.slice(1)} </button> ))} </div> </div> </div>
{/* Saved Games Section */} {games && games.length > 0 && ( <div className="saved-games"> <h2>Continue Game</h2> <div className="game-list"> {games.map((game, idx) => ( <button key={game.playerName} ref={ idx === games.length - 1 ? (ref as LegacyRef<HTMLButtonElement>) : undefined } onClick={() => handleLoadGame(game.playerName, game.genre)} className="game-session" > <div className="player-name">{game.playerName}</div> <div className="genre-name"> {game.genre.charAt(0).toUpperCase() + game.genre.slice(1)} </div> </button> ))} {isFetchingNextPage && <Spinner data-style="generating" size="big" />} </div> </div> )} </div> );}import { PromptInput, Spinner } from '@cloudscape-design/components';import { useMutation, useQuery } from '@tanstack/react-query';import { createFileRoute } from '@tanstack/react-router';import { useEffect, useRef, useState } from 'react';import { useGameApi } from '../../hooks/useGameApi';import { useStoryAgent } from '../../hooks/useStoryAgent';import type { IAction, IGame } from ':dungeon-adventure/game-api';
type IGameState = Omit<IGame, 'lastUpdated'> & { actions: IAction[] };
export const Route = createFileRoute('/game/$playerName')({ component: RouteComponent, validateSearch: (search: Record<string, unknown>) => { return { genre: search.genre as IGameState['genre'], }; },});
function RouteComponent() { const { playerName } = Route.useParams(); const { genre } = Route.useSearch();
const [currentInput, setCurrentInput] = useState(''); const [streamingContent, setStreamingContent] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
// Get genre-specific icon for assistant messages const getAssistantIcon = () => { switch (genre) { case 'zombie': return '🧟'; case 'superhero': return '🦸'; case 'medieval': return '⚔️'; default: return '📖'; } };
const gameApi = useGameApi(); const storyAgent = useStoryAgent(); const saveActionMutation = useMutation( gameApi.actions.save.mutationOptions(), ); const gameActionsQuery = useQuery( gameApi.actions.query.queryOptions({ playerName, limit: 100 }), ); const inventoryQuery = useQuery( gameApi.inventory.query.queryOptions({ playerName, limit: 100 }), );
// no actions - therefore must be a new game - generate initial story useEffect(() => { if ( !gameActionsQuery.isLoading && gameActionsQuery.data?.items && gameActionsQuery.data?.items.length === 0 ) { generateStory({ playerName, genre, actions: [], }); } }, [gameActionsQuery.data?.items, gameActionsQuery.isLoading]);
const generateStoryMutation = useMutation({ mutationFn: async ({ playerName, genre, actions }: IGameState) => { let content = ''; for await (const chunk of storyAgent.generateStory({ playerName, genre, actions, })) { content += chunk; // make chunks available to render in a streaming fashion setStreamingContent(content); }
return content; }, });
// scroll to the last message const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); };
// scroll to the bottom whenever gameActionsQuery is fetched or whenever streaming content changes useEffect(() => { scrollToBottom(); }, [streamingContent, gameActionsQuery]);
// progress the story const generateStory = async ({ playerName, genre, actions }: IGameState) => { try { const content = await generateStoryMutation.mutateAsync({ playerName, genre, actions, });
// Save assistant's response await saveActionMutation.mutateAsync({ playerName, role: 'assistant', content, });
await gameActionsQuery.refetch(); setStreamingContent('');
await inventoryQuery.refetch(); } catch (error) { console.error('Failed to generate story:', error); } };
// progress the story when the user submits input const handleSubmitAction = async () => { if (!currentInput.trim()) return;
const userAction: IAction = { playerName, role: 'user' as const, content: currentInput, timestamp: new Date().toISOString(), };
// Save user action await saveActionMutation.mutateAsync(userAction); await gameActionsQuery.refetch();
setCurrentInput('');
// Generate response await generateStory({ genre, playerName, actions: [...(gameActionsQuery.data?.items ?? []), userAction], }); };
return ( <div className="game-interface"> {inventoryQuery.data?.items && inventoryQuery.data.items.length > 0 && ( <div className="inventory-overlay"> <div className="inventory-header"> 📦 Inventory{' '} {inventoryQuery.isFetching ? ( <Spinner data-style="generating" size="normal" /> ) : null} </div> <div className="inventory-items"> {inventoryQuery.data.items.map((item, idx) => ( <div key={idx} className="inventory-item"> {item.emoji ?? null} {item.itemName}{' '} {item.quantity > 1 && `(x${item.quantity})`} </div> ))} </div> </div> )}
<div className="messages-area"> <div className="messages-container"> {gameActionsQuery.data?.items .concat( streamingContent.length > 0 ? [ { playerName, role: 'assistant', content: streamingContent, timestamp: new Date().toISOString(), }, ] : [], ) .map((action, i) => ( <div key={i}> <div className={`message ${ action.role === 'assistant' ? 'assistant' : 'user' }`} > <div className="message-header"> {action.role === 'assistant' ? `${getAssistantIcon()} Story` : `⭐️ ${playerName}`} </div> <div className="message-content">{action.content}</div> </div> </div> ))} {generateStoryMutation.isPending && streamingContent.length === 0 && ( <Spinner data-style="generating" size="big" /> )} <div ref={messagesEndRef} /> </div> </div> <div className="input-area"> <PromptInput onChange={({ detail }) => setCurrentInput(detail.value)} value={currentInput} actionButtonAriaLabel="Send message" actionButtonIconName="send" ariaLabel="Default prompt input" placeholder="What do you do?" onAction={handleSubmitAction} /> </div> </div> );}Sau khi bạn thực hiện những thay đổi này, máy chủ phát triển cục bộ của bạn (http://localhost:4200/) giờ đây sẽ có trò chơi sẵn sàng để chơi.
Build và Deploy
Nhiệm vụ 6: Build và deploy
Phần tiêu đề “Nhiệm vụ 6: Build và deploy”Build code của bạn
Phần tiêu đề “Build code của bạn”Để build code của bạn, hãy chạy lệnh sau:
pnpm nx run-many --target build --allyarn nx run-many --target build --allnpx nx run-many --target build --allbunx nx run-many --target build --allDeploy ứng dụng của bạn
Phần tiêu đề “Deploy ứng dụng của bạn”Để deploy ứng dụng của bạn, hãy chạy lệnh sau:
pnpm nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox/*yarn nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox/*npx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox/*bunx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox/*Sau khi deploy, hãy điều hướng đến URL Cloudfront của bạn. Để tìm URL này, hãy kiểm tra các đầu ra deploy của CDK.

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! 🎉🎉🎉