Aller au contenu

Construire l'Interface Utilisateur

Tâche 1 : Configurer le serveur de développement local

Section intitulée « Tâche 1 : Configurer le serveur de développement local »

Pour commencer à développer l’interface utilisateur, nous devons configurer notre serveur de développement local pour pointer vers notre sandbox déployée. Exécutez la commande suivante :

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

Cette commande récupérera le fichier runtime-config.json déployé et le stockera localement dans le dossier packages/game-ui/public.

Pour démarrer le serveur de développement, exécutez la commande suivante :

Terminal window
pnpm nx serve game-ui

Ouvrez le site local dans un navigateur. Vous serez invité à vous connecter et à suivre les étapes pour créer un nouvel utilisateur. Une fois terminé, vous devriez voir le site de base :

baseline-website.png

Démontrons les capacités de @tanstack/react-router en créant une nouvelle route typée. Pour ce faire, créez un fichier vide à l’emplacement suivant : packages/game-ui/src/routes/game/index.tsx. Vous remarquerez que le fichier est immédiatement mis à jour.

Le routeur @tanstack/react-router configure automatiquement votre nouvelle route et le fichier que vous venez de créer contient déjà le chemin de la route :

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

Si vous naviguez vers http://localhost:4200/game, vous verrez votre nouvelle page s’afficher.

baseline-game.png

Mettez à jour le fichier index.tsx pour charger notre nouvelle route /game par défaut. Lorsque vous mettez à jour le champ to, vous avez une liste de routes typées parmi lesquelles choisir.

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

Le layout par défaut configuré est plus similaire à une application SaaS d’entreprise qu’à un jeu. Pour reconfigurer le layout et le rethématiser pour évoquer un style médiéval de donjon, apportez les modifications suivantes dans packages/game-ui/src :

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

Supprimez le fichier packages/game-ui/src/hooks/useAppLayout.tsx qui n’est plus utilisé.

Rappelez-vous que dans le Module 1, nous avons utilisé le générateur connection pour connecter notre interface utilisateur du jeu à l’Agent Narratif. Cela a configuré un client OpenAPI typé pour interagir avec l’agent, ainsi que des hooks et des providers.

Le générateur de connexion a créé les éléments suivants pour nous :

  • Un composant StoryAgentProvider encapsulant notre application dans main.tsx
  • Un hook useStoryAgent pour l’intégration TanStack Query
  • Un hook useStoryAgentClient pour l’accès direct au client
  • Des types TypeScript générés à partir de la spécification OpenAPI de l’agent

Nous utiliserons le hook useStoryAgentClient dans notre composant de jeu pour streamer les réponses narratives de l’agent.

Pour créer les pages du jeu qui appelleront nos APIs et finaliser l’implémentation du jeu, mettez à jour les fichiers suivants dans 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>
);
}

Après ces modifications, votre serveur de développement local (http://localhost:4200/) devrait maintenant afficher votre jeu prêt à être joué.

Vous pouvez également construire & déployer votre code sur Cloudfront si vous préférez.
game-select.png
game-conversation.png

Félicitations. Vous avez construit et déployé votre Jeu d’aventure en donjon agentique ! 🎉🎉🎉