콘텐츠로 이동

에이전트형 AI 던전 게임

UI 구성을 시작하기 위해 로컬 개발 서버를 배포된 샌드박스로 연결하겠습니다. 다음 명령어를 실행하세요:

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

이 명령은 배포된 runtime-config.json 파일을 가져와 packages/game-ui/public 폴더에 로컬로 저장합니다.

이제 다음 명령으로 개발 서버를 시작할 수 있습니다:

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

브라우저에서 로컬 웹사이트(http://localhost:4200)를 열면 로그인 프롬프트가 표시되고 새 사용자 생성 절차를 따라 진행할 수 있습니다. 완료 후 기본 웹사이트가 표시됩니다:

baseline-website.png

@tanstack/react-router의 기능을 활용해 타입 안전한 새 라우트를 생성해 보겠습니다. 다음 위치에 빈 파일을 생성하세요: packages/game-ui/src/routes/game/index.tsx. 파일이 즉시 업데이트되는 것을 확인할 수 있습니다.

@tanstack/react-router가 자동으로 새 라우트를 구성하며, 생성한 파일에 이미 라우트 경로가 입력되어 있습니다:

import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/game/')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/game/"!</div>
}

이제 http://localhost:4200/game으로 이동하면 새 페이지가 렌더링된 것을 확인할 수 있습니다!

baseline-game.png

기본적으로 새 /game 라우트를 로드하도록 index.tsx 파일도 업데이트하겠습니다. to 필드를 수정할 때 타입 안전한 라우트 목록에서 선택할 수 있는 것을 확인하세요.

import { createFileRoute, Navigate } from '@tanstack/react-router';
export const Route = createFileRoute('/')({
component: () => <Navigate to="/game" />,
});

이제 더 이상 필요 없는 packages/game-ui/src/routes/welcome/ 폴더를 삭제할 수 있습니다.

기본 구성된 레이아웃은 SaaS 스타일 비즈니스 애플리케이션에 더 가깝습니다. 던전 스타일 게임에 어울리도록 레이아웃을 재구성하고 테마를 변경하겠습니다.

packages/game-ui/src에서 다음 변경 사항을 적용하세요:

export default {
applicationName: 'Dungeon Adventure',
};

이제 사용되지 않는 packages/game-ui/src/hooks/useAppLayout.tsx 파일을 삭제하세요.

이제 스토리 에이전트와 상호작용할 클라이언트를 초기화하는 훅을 생성하겠습니다.

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 }[];
}
const generateSessionId = (playerName: string): string => {
const targetLength = 34;
const uuidLength = targetLength - playerName.length;
const randomSegment = crypto
.randomUUID()
.replace(/-/g, '')
.substring(0, uuidLength);
return `${playerName}${randomSegment}`;
};
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',
'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': generateSessionId(
opts.playerName,
),
},
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;
// Parse SSE format - each chunk may contain multiple events
const lines = value.split('\n');
for (const line of lines) {
// SSE events start with "data: "
if (line.startsWith('data: ')) {
const data = line.slice(6); // Remove "data: " prefix
try {
const parsed = JSON.parse(data);
// Extract text from contentBlockDelta events
if (parsed.event?.contentBlockDelta?.delta?.text) {
yield parsed.event.contentBlockDelta.delta.text;
}
if (parsed.event?.messageStop) {
yield '\n';
}
} catch (e) {
// Skip lines that aren't valid JSON (like Python debug output)
continue;
}
}
}
}
},
}),
[url, user?.id_token],
);
};

이 코드는 다음 작업을 수행합니다:

  • runtime-config.json에서 에이전트 ARN 추출
  • ARN으로 AgentCore 런타임 호출 URL 구성
  • 로그인 사용자의 JWT 토큰과 임의 세션 ID로 에이전트 호출
  • 스트리밍된 에이전트 메시지 청크를 소비하기 위한 비동기 이터레이터 반환

API를 호출할 게임 페이지를 생성하고 게임 구현을 완성하겠습니다. 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>
);
}

이제 변경 사항을 적용하면 로컬 개발 서버(http://localhost:4200/)에서 게임을 플레이할 준비가 완료됩니다!

원할 경우 코드를 빌드하여 Cloudfront에 배포할 수도 있습니다.
game-select.png
game-conversation.png

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