Juego de Mazmorra de IA Agéntica
Módulo 4: Implementación de la interfaz de usuario
Sección titulada «Módulo 4: Implementación de la interfaz de usuario»Para comenzar a construir la interfaz de usuario, necesitamos configurar nuestro servidor de desarrollo local para apuntar a nuestro sandbox desplegado. Ejecuta el siguiente comando:
pnpm nx run @dungeon-adventure/game-ui:load:runtime-config
yarn nx run @dungeon-adventure/game-ui:load:runtime-config
npx nx run @dungeon-adventure/game-ui:load:runtime-config
bunx nx run @dungeon-adventure/game-ui:load:runtime-config
Este comando descargará el runtime-config.json
que está desplegado y lo almacenará localmente en la carpeta packages/game-ui/public
.
Ahora podemos iniciar el servidor de desarrollo con el siguiente comando:
pnpm nx run @dungeon-adventure/game-ui:serve
yarn nx run @dungeon-adventure/game-ui:serve
npx nx run @dungeon-adventure/game-ui:serve
bunx nx run @dungeon-adventure/game-ui:serve
Luego puedes abrir el sitio web local en tu navegador, donde se te pedirá iniciar sesión y seguir las indicaciones para crear un nuevo usuario. Una vez completado, deberías ver el sitio web base:

Crear una nueva ruta ‘/game’
Sección titulada «Crear una nueva ruta ‘/game’»Mostraremos las capacidades de @tanstack/react-router
creando una nueva ruta tipada. Para esto, simplemente crea un archivo vacío en la siguiente ubicación: packages/game-ui/src/routes/game/index.tsx
. Notarás que el archivo se actualiza inmediatamente.
El @tanstack/react-router
ha configurado automáticamente tu nueva ruta y observarás que el archivo que acabas de crear ya está poblado con la ruta:
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/game/')({ component: RouteComponent,})
function RouteComponent() { return <div>Hello "/game/"!</div>}
Ahora, si navegas a http://localhost:4200/game
, ¡verás que tu nueva página se ha renderizado!

Actualicemos también el archivo index.tsx
para cargar nuestra nueva ruta /game
por defecto. Observa cómo al actualizar el campo to
, tienes una lista de rutas tipadas para elegir.
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> );}
Finalmente podemos eliminar la carpeta packages/game-ui/src/routes/welcome/
ya que no es necesaria.
Actualizaciones de diseño
Sección titulada «Actualizaciones de diseño»El diseño predeterminado está más orientado a aplicaciones empresariales estilo SaaS que a un juego. Vamos a reconfigurar el diseño y rediseñar el tema para que se asemeje más a un juego de estilo mazmorra.
Realicemos los siguientes cambios en 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;}
Ahora eliminemos el archivo packages/game-ui/src/hooks/useAppLayout.tsx
ya que no se utiliza.
Integración del Agente de Historia
Sección titulada «Integración del Agente de Historia»A continuación crearemos un hook para inicializar un cliente que interactúe con nuestro Agente de Historia.
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], );};
Esto realiza lo siguiente:
- Recupera el ARN del Agente desde
runtime-config.json
- Construye la URL de invocación de AgentCore Runtime desde el ARN
- Invoca al Agente con el token JWT del usuario autenticado y un ID de sesión aleatorio
- Retorna un iterador asíncrono para consumir fragmentos de mensajes transmitidos
Páginas del juego
Sección titulada «Páginas del juego»Creemos las páginas del juego que llamarán a nuestras APIs y completarán la implementación del juego. Actualiza los siguientes archivos en 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> );}
Al realizar estos cambios, ¡tu servidor de desarrollo local (http://localhost:4200/) debería tener ahora el juego listo para jugar!
Compilar e implementar
Para compilar tu código, ejecuta:
pnpm nx run-many --target build --all
yarn nx run-many --target build --all
npx nx run-many --target build --all
bunx nx run-many --target build --all
Ahora implementa tu aplicación:
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/*
Una vez implementado, navega a tu URL de Cloudfront que puedes encontrar inspeccionando las salidas del despliegue de CDK.


¡Felicidades! Has construido y desplegado tu Juego de Aventuras de Mazmorras con Agentes. 🎉🎉🎉