Jeu de Donjon IA
Module 4 : Implémentation de l’interface utilisateur
Pour commencer à construire l’UI, nous devons configurer notre serveur de développement local pour pointer vers notre sandbox déployée. Exécutez la commande suivante :
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
Cette commande récupérera le fichier runtime-config.json
déployé et le stockera localement dans le dossier packages/game-ui/public
.
Maintenant, démarrez le serveur de développement avec :
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
Ouvrez ensuite le site local dans votre navigateur. Vous serez invité à vous connecter et à créer un nouvel utilisateur. Une fois terminé, vous verrez le site de base :

Créer une nouvelle route ‘/game’
Démontrons les capacités de @tanstack/react-router
en créant une route typée. Créez un fichier vide à l’emplacement : packages/game-ui/src/routes/game/index.tsx
. Observez les logs du serveur :
♻️ Regenerating routes...🟡 Updating /Users/dimecha/dungeon-adventure/packages/game-ui/src/routes/game/index.tsx🟡 Updating /Users/dimecha/dungeon-adventure/packages/game-ui/src/routeTree.gen.ts✅ Processed routes in 27ms
Le routeur a automatiquement configuré votre nouvelle route. Le fichier créé contient déjà le chemin :
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/game/')({ component: RouteComponent,})
function RouteComponent() { return <div>Hello "/game/"!</div>}
Naviguez vers http://localhost:4200/game
pour voir la nouvelle page !

Mettons à jour index.tsx
pour charger /game
par défaut. Notez comment le champ to
propose des routes typées :
import { createFileRoute, Navigate } from '@tanstack/react-router';
export const Route = createFileRoute('/')({ component: () => <Navigate to="/game" />,});
Supprimez enfin le dossier packages/game-ui/src/routes/welcome/
.
Mise à jour du layout
Le layout par défaut ressemble plus à une application SaaS qu’à un jeu. Reconfigurons-le avec un thème médiéval.
Modifications à apporter dans 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';import { Outlet } from '@tanstack/react-router';
/** * Définit le layout de l'application et contient la logique de routage */const AppLayout: React.FC = () => { 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: 'Se déconnecter' }], }, ]} /> <Outlet /> </> );};export default AppLayout;
/* Styles du jeu */:root { --primary-color: rgba(252, 214, 112, 1); --secondary-color: rgba(252, 214, 112, 0.8); --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);}
/* Conteneur du jeu */.game-interface { margin: 2rem; min-height: 100%; flex-grow: 1; display: flex; flex-direction: column;}
/* En-tête */.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;}
/* Parties sauvegardées */.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);}
/* Nouvelle partie */.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; border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 8px; font-size: 1rem;}
.name-input:focus,.action-input:focus { outline: none; border-color: var(--primary-color);}
.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);}
/* Zone de messages */.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.assistant { background: var(--secondary-color); border-left: 4px solid var(--primary-color); margin-right: auto; word-wrap: break-word; white-space: pre-wrap; margin: 0 !important;}
.message.user { background: rgba(220, 220, 220, 0.7); border-right: 4px solid rgba(220, 220, 220, 0.7); margin-left: auto;}
/* Zone de saisie */.input-area { padding: 1rem; position: sticky; bottom: 0;}
/* Barre de défilement */.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;}
/* Pour Firefox */.messages-area { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.3) transparent;}
Supprimez les fichiers packages/game-ui/src/components/AppLayout/navitems.ts
et packages/game-ui/src/hooks/useAppLayout.tsx
.
Pages du jeu
Implémentons les pages du jeu qui appelleront nos APIs :
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-schema';
type IGameState = Omit<IGame, 'lastUpdated'> & { actions: IAction[] };
export const Route = createFileRoute('/game/')({ component: RouteComponent,});
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]);
useEffect(() => { if (isLastGameVisible && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, [isFetchingNextPage, hasNextPage, fetchNextPage, isLastGameVisible]);
const playerAlreadyExists = (playerName?: string) => { return !!games?.find((s) => s.playerName === playerName); };
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); } };
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>
<div className="new-game"> <h2>Nouvelle partie</h2> <div className="game-setup"> <FormField errorText={ playerAlreadyExists(playerName) ? `${playerName} existe déjà` : undefined } > <input type="text" placeholder="Entrez votre nom" 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>
{games && games.length > 0 && ( <div className="saved-games"> <h2>Reprendre la partie</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 { useStoryApiClient } from '../../hooks/useStoryApiClient';import type { IAction, IGame } from ':dungeon-adventure/game-api-schema';
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);
const gameApi = useGameApi(); const storyApi = useStoryApiClient(); const saveActionMutation = useMutation( gameApi.actions.save.mutationOptions(), ); const gameActionsQuery = useQuery( gameApi.actions.query.queryOptions({ playerName, limit: 100 }), );
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 storyApi.generateStory({ playerName, genre, actions, })) { content += chunk; setStreamingContent(content); }
return content; }, });
const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); };
useEffect(() => { scrollToBottom(); }, [streamingContent, gameActionsQuery]);
const generateStory = async ({ playerName, genre, actions }: IGameState) => { try { const content = await generateStoryMutation.mutateAsync({ playerName, genre, actions, });
await saveActionMutation.mutateAsync({ playerName, role: 'assistant', content, });
await gameActionsQuery.refetch();
setStreamingContent(''); } catch (error) { console.error('Failed to generate story:', error); } };
const handleSubmitAction = async () => { if (!currentInput.trim()) return;
const userAction: IAction = { playerName, role: 'user' as const, content: currentInput, timestamp: new Date().toISOString(), };
await saveActionMutation.mutateAsync(userAction); await gameActionsQuery.refetch();
setCurrentInput('');
await generateStory({ genre, playerName, actions: [...(gameActionsQuery.data?.items ?? []), userAction], }); };
return ( <div className="game-interface"> <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} className={`message ${ action.role === 'assistant' ? 'assistant' : 'user' }`} > {action.content} </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="Envoyer" actionButtonIconName="send" ariaLabel="Champ de saisie" placeholder="Quelle action entreprenez-vous ?" onAction={handleSubmitAction} /> </div> </div> );}
Votre serveur local (http://localhost:4200/) devrait maintenant afficher le jeu fonctionnel !
Build et Déploiement
Pour builder le projet :
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
Déployez ensuite avec :
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
Après le déploiement, récupérez l’URL Cloudfront dans les sorties du déploiement CDK.


Félicitations. Vous avez construit et déployé votre Dungeon Adventure Game ! 🎉🎉🎉