Ir al contenido

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:

Terminal window
pnpm nx run 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.

Para iniciar el servidor de desarrollo, ejecuta el siguiente comando:

Terminal window
pnpm nx serve game-ui

Abre 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:

baseline-website.png

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.

baseline-game.png

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

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

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 StoryAgentProvider que envuelve nuestra aplicación en main.tsx
  • Un hook useStoryAgent para integración con TanStack Query
  • Un hook useStoryAgentClient para 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.

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 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>
);
}

Al realizar estos cambios, tu servidor de desarrollo local (http://localhost:4200/) debería tener ahora el juego listo para jugar.

También puedes compilar e implementar tu código en Cloudfront si lo prefieres.
game-select.png
game-conversation.png

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