Salta ai contenuti

Costruire l'interfaccia utente

Avvia l’intero stack locale — il server di sviluppo game-ui insieme a una Game API locale e un AG-UI Story Agent locale (che a sua volta avvia il server Inventory MCP) — con un solo comando:

Terminal window
pnpm nx dev game-ui

Il target dev su game-ui ha dependsOn su game-api:dev e dungeon_adventure.story:agent-dev, quindi Nx avvia il server locale di ogni progetto in parallelo. Attraverso le connessioni che abbiamo configurato nel Modulo 1, questi a loro volta avviano il server Inventory MCP e DynamoDB Local. Assicurati che il tuo motore di container sia in esecuzione, quindi apri il server di sviluppo in un browser.

Quando hai eseguito il generatore connection per game-ui → story nel Modulo 1, l’integrazione AG-UI del sito web Shadcn è stata generata per te. Vale la pena dare un’occhiata veloce:

  • Directorypackages/game-ui/src/
    • Directorycomponents/
      • AguiProvider.tsx Singolo CopilotKitProvider registrato con ogni agente AG-UI.
      • Directorycopilot/
        • index.tsx Ri-esporta CopilotChat / CopilotSidebar / CopilotPopup con tema.
        • ShadcnAssistantMessage.tsx, ShadcnUserMessage.tsx, ShadcnChatInput.tsx, ShadcnCursor.tsx, copilot.css
    • Directoryhooks/
      • useAguiStoryAgent.tsx Istanzia un HttpAgent @ag-ui/client che punta allo Story Agent distribuito e riempie threadId al minimo di 33 caratteri per l’id di sessione di AgentCore.
    • main.tsx Avvolge <App /> in <AguiProvider>

Tutto ciò che dobbiamo fare è inserire un <CopilotChat agentId="agent" threadId={...} /> in una route. Per maggiori dettagli su come è strutturata l’integrazione, consulta la guida alla connessione React → AG-UI.

Sostituisci packages/game-ui/src/styles.css — questo è l’unico file che modifichiamo per lo stile. Importa i globali Shadcn condivisi, sovrascrive la palette con un tema dungeon illuminato da torce e fa ereditare quei colori a CopilotKit:

@import '../../common/shadcn/src/styles/globals.css';
@source './**/*.{ts,tsx}';
/* Dungeon theme — torch-lit parchment on stone. Applied to `:root` for the
* page and to `[data-copilotkit][data-copilotkit]` for CopilotKit's chat
* surface; CopilotKit ships a same-specificity `[data-copilotkit]` rule that
* resets `--background` back to white, so we bump specificity with the
* doubled selector. */
:root,
[data-copilotkit][data-copilotkit] {
--background: oklch(0.18 0.02 60);
--foreground: oklch(0.92 0.04 85);
--card: oklch(0.22 0.03 60);
--card-foreground: oklch(0.92 0.04 85);
--popover: oklch(0.2 0.02 60);
--popover-foreground: oklch(0.92 0.04 85);
--primary: oklch(0.75 0.15 75);
--primary-foreground: oklch(0.15 0.02 60);
--secondary: oklch(0.28 0.04 60);
--secondary-foreground: oklch(0.92 0.04 85);
--muted: oklch(0.25 0.02 60);
--muted-foreground: oklch(0.7 0.04 85);
--accent: oklch(0.4 0.12 30);
--accent-foreground: oklch(0.95 0.04 85);
--destructive: oklch(0.55 0.22 25);
--border: oklch(0.35 0.03 60);
--input: oklch(0.3 0.03 60);
--ring: oklch(0.75 0.15 75);
--sidebar: oklch(0.15 0.02 60);
--sidebar-foreground: oklch(0.88 0.04 85);
--sidebar-primary: oklch(0.75 0.15 75);
--sidebar-primary-foreground: oklch(0.15 0.02 60);
--sidebar-accent: oklch(0.28 0.04 60);
--sidebar-accent-foreground: oklch(0.92 0.04 85);
--sidebar-border: oklch(0.3 0.03 60);
--sidebar-ring: oklch(0.75 0.15 75);
}
body {
font-family: 'Georgia', 'Cambria', serif;
background:
radial-gradient(circle at 20% 10%, oklch(0.25 0.05 70 / 0.4), transparent 40%),
radial-gradient(circle at 80% 90%, oklch(0.25 0.1 30 / 0.3), transparent 40%),
var(--background);
}
h1, h2, h3 {
letter-spacing: 0.05em;
}

Abbiamo bisogno di due route — una per scegliere un eroe, una per giocare. Entrambe usano componenti shadcn e la chat CopilotKit; non c’è un’interfaccia di chat creata manualmente.

import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@dungeon-adventure/common-shadcn/components/ui/button';
import { Input } from '@dungeon-adventure/common-shadcn/components/ui/input';
import {
Card,
CardContent,
} from '@dungeon-adventure/common-shadcn/components/ui/card';
import { Spinner } from '@dungeon-adventure/common-shadcn/components/ui/spinner';
import { useGameApi } from '../hooks/useGameApi';
import type { IGame } from '@dungeon-adventure/game-api';
const GENRES = ['medieval', 'zombie', 'superhero'] as const;
export const Route = createFileRoute('/')({ component: RouteComponent });
function RouteComponent() {
const [playerName, setPlayerName] = useState('');
const [pending, setPending] = useState<IGame['genre'] | null>(null);
const navigate = useNavigate();
const gameApi = useGameApi();
const saveGame = useMutation(gameApi.games.save.mutationOptions());
const games = useInfiniteQuery(
gameApi.games.query.infiniteQueryOptions(
{ limit: 10 },
{ getNextPageParam: ({ cursor }) => cursor ?? undefined },
),
);
const savedGames = useMemo(
() => games.data?.pages.flatMap((p) => p.items) ?? [],
[games.data],
);
// Auto-fetch subsequent pages when the sentinel at the bottom of the list
// scrolls into view — keeps the homepage a simple infinite scroll without
// a "Load more" button.
const sentinel = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = sentinel.current;
if (!el || !games.hasNextPage) return;
const io = new IntersectionObserver(
(entries) => {
if (
entries.some((e) => e.isIntersecting) &&
!games.isFetchingNextPage
) {
void games.fetchNextPage();
}
},
{ rootMargin: '120px' },
);
io.observe(el);
return () => io.disconnect();
}, [games.hasNextPage, games.isFetchingNextPage, games.fetchNextPage]);
const startGame = async (player: string, genre: IGame['genre']) => {
if (!player.trim()) return;
setPending(genre);
try {
if (!savedGames.find((g) => g.playerName === player)) {
await saveGame.mutateAsync({ playerName: player, genre });
}
await navigate({
to: '/game/$playerName',
params: { playerName: player },
search: { genre },
});
} finally {
setPending(null);
}
};
const busy = pending !== null;
const firstLoad = games.isLoading;
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
<div className="text-center">
<h1 className="bg-gradient-to-r from-amber-300 to-rose-400 bg-clip-text text-5xl font-bold text-transparent">
AI Dungeon Adventure
</h1>
<p className="text-muted-foreground mt-2">
Pick a hero name, choose a genre, begin.
</p>
</div>
<Card>
<CardContent className="flex flex-col gap-4 pt-6">
<Input
placeholder="Your hero's name"
value={playerName}
disabled={busy}
onChange={(e) => setPlayerName(e.target.value)}
/>
<div className="grid grid-cols-3 gap-3">
{GENRES.map((genre) => (
<Button
key={genre}
variant="secondary"
disabled={!playerName.trim() || busy}
onClick={() => startGame(playerName, genre)}
>
{pending === genre && <Spinner />}
{genre[0].toUpperCase() + genre.slice(1)}
</Button>
))}
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-2">
<h2 className="flex items-center gap-2 text-xl font-semibold">
Continue
{(firstLoad || games.isFetching) && <Spinner className="size-4" />}
</h2>
{!firstLoad && savedGames.length === 0 && (
<p className="text-muted-foreground text-sm">
No saved games yet — start a new adventure above.
</p>
)}
{savedGames.map((g) => (
<Button
key={g.playerName}
variant="outline"
className="justify-between"
disabled={busy}
onClick={() => startGame(g.playerName, g.genre)}
>
<span>{g.playerName}</span>
<span className="text-muted-foreground text-sm">
{g.genre[0].toUpperCase() + g.genre.slice(1)}
</span>
</Button>
))}
<div ref={sentinel} aria-hidden className="h-1" />
{games.isFetchingNextPage && (
<div className="flex justify-center py-2">
<Spinner />
</div>
)}
</div>
</div>
);
}

Questo è il selettore del gioco: form per nuovo gioco (shadcn Input + Button + Card) più una lista “Continua” alimentata da useGameApi().games.query con useInfiniteQuery — un <div> sentinella in fondo osservato da un IntersectionObserver chiama automaticamente fetchNextPage() quando viene visualizzato nello scroll, e gli spinner accanto al titolo e sotto la lista mostrano lo stato di caricamento. Avviare un gioco esegue saveGame della coppia (playerName, genre) (così appare la prossima volta) e naviga alla route di gioco.

Una volta salvato, il server di sviluppo su http://localhost:4200/ dovrebbe ora permetterti di iniziare un’avventura e chattare con lo Story Agent.

game-select.png
game-conversation.png

Il tuo gioco è completo e hai testato ogni pezzo localmente. Ora distribuiamolo su AWS così puoi giocarci da qualsiasi luogo.

Terminal window
pnpm build
Terminal window
pnpm nx deploy-sandbox infra

Il target deploy-sandbox distribuisce lo stage sandbox definito in packages/infra/src/main.ts.

La tua prima distribuzione richiederà circa 6 minuti per completarsi mentre attende che tutte le risorse si stabilizzino completamente. Le distribuzioni successive sono più veloci. Per velocizzare l’iterazione durante lo sviluppo puoi optare per la modalità express di CloudFormation passando il flag --express, che completa ogni operazione di risorsa non appena viene applicata la sua configurazione invece di attendere la stabilizzazione completa.

Una volta completata la distribuzione, vedrai output simili ai seguenti:

Terminal window
dungeon-adventure-infra-sandbox-Application
dungeon-adventure-infra-sandbox-Application: deploying... [2/2]
dungeon-adventure-infra-sandbox-Application
Deployment time: 354s
Outputs:
dungeon-adventure-infra-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-infra-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-infra-sandbox-Application.InventoryMcpArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventureventoryMcpServerXXXX-YYYY
dungeon-adventure-infra-sandbox-Application.RuntimeConfigApplicationId = xxxx
dungeon-adventure-infra-sandbox-Application.StoryAgentArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventurecationStoryAgentXXXX-YYYY
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolClientIdXXX = xxxxxxxxxx
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

Naviga al tuo URL CloudFront (GameUIDistributionDomainName dagli output CDK), registrati per un nuovo account e gioca al tuo gioco in esecuzione interamente su AWS!

Hai visto i due estremi dello spettro: tutto locale (dev) e tutto distribuito. Durante lo sviluppo quotidiano è spesso utile mescolare i due — ad esempio, iterare sul codice del sito web contro l’API e l’agente reali distribuiti, o eseguire l’API localmente contro la tabella DynamoDB reale.

La chiave è la variabile d’ambiente RUNTIME_CONFIG_APP_ID. Quando un progetto viene eseguito senza LOCAL_DEV=true, le ricerche di configurazione runtime recuperano la loro configurazione da AWS AppConfig utilizzando questo id applicazione — il valore RuntimeConfigApplicationId dagli output CDK.

Ogni sito web ha un target load-runtime-config che scarica il runtime-config.json distribuito (pool Cognito, endpoint API, ARN agente) nel server di sviluppo locale. Alcune combinazioni utili:

  • Sito web locale → backend distribuito. Scarica la configurazione distribuita una volta, quindi esegui il target serve semplice così l’interfaccia utente comunica con l’API e l’agente distribuiti:

    Terminal window
    pnpm nx load-runtime-config game-ui
    Terminal window
    pnpm nx serve game-ui
  • API locale → tabella DynamoDB reale. Esegui il target serve semplice con RUNTIME_CONFIG_APP_ID impostato sull’id applicazione distribuito:

    Terminal window
    RUNTIME_CONFIG_APP_ID=<RuntimeConfigApplicationId from CDK outputs> pnpm nx serve game-api

Questa flessibilità ti permette di testare esattamente la porzione su cui stai lavorando, contro qualsiasi componente — locale o distribuito — abbia senso.

Congratulazioni. Hai costruito, testato e distribuito il tuo Agentic Dungeon Adventure Game! 🎉🎉🎉