Construir la UI
Tarea 1: Configurar el servidor de desarrollo local
Sección titulada «Tarea 1: Configurar el servidor de desarrollo local»Para comenzar a construir la interfaz de usuario, necesitaremos configurar nuestro servidor de desarrollo local para apuntar a nuestro sandbox desplegado. Para esto, ejecuta el siguiente comando:
pnpm nx run game-ui:load:runtime-configyarn nx run game-ui:load:runtime-confignpx nx run game-ui:load:runtime-configbunx nx run game-ui:load:runtime-configEste comando descargará el runtime-config.json que está desplegado y lo almacenará localmente en la carpeta packages/game-ui/public.
Para iniciar el servidor de desarrollo, ejecuta el siguiente comando:
pnpm nx serve game-uiyarn nx serve game-uinpx nx serve game-uibunx nx serve game-uiAbre 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:
Tarea 2: Crear una nueva ruta ‘/game’
Sección titulada «Tarea 2: Crear una nueva ruta ‘/game’»Mostraremos las capacidades de @tanstack/react-router creando una nueva ruta tipada.
Para esto, 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 configura automáticamente tu nueva ruta y 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>}Si navegas a http://localhost:4200/game, verás que tu nueva página se ha renderizado.
Actualiza el archivo index.tsx para cargar nuestra nueva ruta /game por defecto. Cuando actualices el campo to, tendrás 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> );}Tarea 3: Actualizar el diseño
Sección titulada «Tarea 3: Actualizar el diseño»El diseño predeterminado configurado es más similar a una aplicación empresarial estilo SaaS que a un juego.
Para reconfigurar el diseño y rediseñar el tema para que se parezca más a un juego de estilo mazmorra, realiza 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;}Elimina el archivo packages/game-ui/src/hooks/useAppLayout.tsx ya que no se utiliza.
Tarea 4: Integración del Agente de Historia
Sección titulada «Tarea 4: Integración del Agente de Historia»Recuerda que en el Módulo 1, usamos el generador connection para conectar nuestra Game UI al Agente de Historia. Esto configuró un cliente OpenAPI tipado para interactuar con el agente, junto con hooks y providers.
El generador de conexión creó lo siguiente para nosotros:
- Un componente
StoryAgentProviderque envuelve nuestra aplicación enmain.tsx - Un hook
useStoryAgentpara integración con TanStack Query - Un hook
useStoryAgentClientpara acceso directo al cliente - Tipos TypeScript generados desde la especificación OpenAPI del agente
Usaremos el hook useStoryAgentClient en nuestro componente de juego para transmitir respuestas de historia desde el agente.
Tarea 5: Crear las páginas del juego
Sección titulada «Tarea 5: Crear las páginas del juego»Para crear 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 { useStoryAgentClient } from '../../hooks/useStoryAgentClient';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 = useStoryAgentClient(); 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.invoke({ playerName, genre, actions, })) { content += chunk.content; // 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
Tarea 6: Compilar e implementar
Sección titulada «Tarea 6: Compilar e implementar»Compilar tu código
Sección titulada «Compilar tu código»Para compilar tu código, ejecuta el siguiente comando:
pnpm buildyarn buildnpm run buildbun buildImplementar tu aplicación
Sección titulada «Implementar tu aplicación»Para implementar tu aplicación, ejecuta el siguiente comando:
pnpm nx deploy infra dungeon-adventure-infra-sandbox/*yarn nx deploy infra dungeon-adventure-infra-sandbox/*npx nx deploy infra dungeon-adventure-infra-sandbox/*bunx nx deploy infra dungeon-adventure-infra-sandbox/*Una vez implementado, navega a tu URL de Cloudfront. Para encontrar esta URL, inspecciona las salidas del despliegue de CDK.

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