Salta ai contenuti

Costruisci l'UI

Per iniziare a costruire l’interfaccia utente, dobbiamo configurare il server di sviluppo locale per puntare alla sandbox distribuita. Esegui il seguente comando:

Terminal window
pnpm nx run game-ui:load:runtime-config

Questo comando scaricherà il file runtime-config.json distribuito e lo memorizzerà localmente nella cartella packages/game-ui/public.

Per avviare il server di sviluppo, esegui il seguente comando:

Terminal window
pnpm nx serve game-ui

Apri il sito locale nel browser, dove ti verrà chiesto di effettuare l’accesso e seguire le istruzioni per creare un nuovo utente. Una volta completato, dovresti vedere il sito base:

baseline-website.png

Mostriamo le capacità di @tanstack/react-router creando una nuova route type-safe. Per fare questo, crea un file vuoto in questa posizione: packages/game-ui/src/routes/game/index.tsx. Noterai che il file viene aggiornato immediatamente.

Il @tanstack/react-router configura automaticamente la tua nuova route e il file appena creato è già popolato con il percorso della route:

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

Se navighi su http://localhost:4200/game, vedrai renderizzata la tua nuova pagina.

baseline-game.png

Aggiorna il file index.tsx per caricare la nuova route /game come predefinita. Quando aggiorni il campo to, hai a disposizione un elenco di route type-safe tra cui scegliere.

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

Il layout predefinito configurato è più simile a un’applicazione aziendale in stile SaaS che a un gioco. Per riconfigurare il layout e applicare un tema più consono a un gioco in stile dungeon, apporta le seguenti modifiche a packages/game-ui/src:

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

Elimina il file packages/game-ui/src/hooks/useAppLayout.tsx poiché non viene utilizzato.

Ricorda che nel Modulo 1, abbiamo utilizzato il generatore connection per connettere la nostra Game UI allo Story Agent. Questo ha configurato un client OpenAPI type-safe per interagire con l’agente, insieme a hook e provider.

Il generatore di connessione ha creato per noi quanto segue:

  • Un componente StoryAgentProvider che avvolge la nostra app in main.tsx
  • Un hook useStoryAgent per l’integrazione con TanStack Query
  • Un hook useStoryAgentClient per l’accesso diretto al client
  • Tipi TypeScript generati dalla specifica OpenAPI dell’agente

Utilizzeremo l’hook useStoryAgentClient nel nostro componente di gioco per ricevere in streaming le risposte della storia dall’agente.

Per creare le pagine del gioco che chiameranno le nostre API e completeranno l’implementazione del gioco, aggiorna i seguenti file in 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>
);
}

Dopo aver apportato queste modifiche, il server di sviluppo locale (http://localhost:4200/) dovrebbe ora avere il gioco pronto per essere giocato.

Puoi anche compilare e distribuire il codice su Cloudfront se preferisci.
game-select.png
game-conversation.png

Complimenti. Hai costruito e distribuito il tuo Gioco di Avventura Agentico nel Dungeon! 🎉🎉🎉