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

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:

Terminal window
pnpm nx run @dungeon-adventure/game-ui:load:runtime-config

Lệ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:

Terminal window
pnpm nx run @dungeon-adventure/game-ui:serve

Mở 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:

baseline-website.png

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ị.

baseline-game.png

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" />,
});

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.

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',
};

Xóa file packages/game-ui/src/hooks/useAppLayout.tsx vì nó không được sử dụng.

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.

Để 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 screen
export 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>
);
}

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.

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