Skip to content

AIダンジョンゲーム

モジュール4: UI実装

UIの構築を開始するには、ローカル開発サーバーをデプロイされたサンドボックスに向ける設定が必要です。次のコマンドを実行してください:

Terminal window
pnpm nx run @dungeon-adventure/game-ui:load:runtime-config

このコマンドはデプロイ済みのruntime-config.jsonを取得し、packages/game-ui/publicフォルダ内にローカル保存します。

開発サーバーは次のコマンドで起動できます:

Terminal window
pnpm nx run @dungeon-adventure/game-ui:serve

その後、ブラウザでローカルウェブサイト(http://localhost:4200)を開くと、ログインを求められ、新規ユーザー作成のプロセスを経てベースラインウェブサイトが表示されます:

baseline-website.png

新しい’/game’ルートの作成

@tanstack/react-routerの機能を活用して型安全な新しいルートを作成します。次の場所に空ファイルを作成してください: packages/game-ui/src/routes/game/index.tsx。開発サーバーのログに注目します:

Terminal window
♻️ 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

@tanstack/react-routerが自動的に新しいルートを設定し、作成したファイルにルートパスが生成されます:

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

http://localhost:4200/game にアクセスすると、新しいページが表示されます!

baseline-game.png

index.tsxファイルを更新してデフォルトで新しい/gameルートを読み込むように変更します。toフィールドを更新する際、型安全なルートのリストから選択できます。

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

最後に不要になったpackages/game-ui/src/routes/welcome/フォルダを削除します。

レイアウト更新

デフォルトのレイアウトはSaaS型ビジネスアプリケーション向けです。ダンジョンスタイルのゲームに適したテーマとレイアウトに再構成します。

packages/game-ui/srcに以下の変更を加えます:

packages/game-ui/src/config.ts
export default {
applicationName: 'Dungeon Adventure',
};

不要になったpackages/game-ui/src/components/AppLayout/navitems.tspackages/game-ui/src/hooks/useAppLayout.tsxファイルを削除します。

ゲームページ

APIを呼び出してゲーム実装を完成させるゲームページを作成します:

packages/game-ui/src/routes/game/index.tsx
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,
});
// refが画面に表示されているか確認するフック
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>新規ゲーム開始</h2>
<div className="game-setup">
<FormField
errorText={
playerAlreadyExists(playerName)
? `${playerName} は既に存在します`
: undefined
}
>
<input
type="text"
placeholder="プレイヤー名を入力"
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>ゲームを続ける</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>
);
}

変更を加えると、ローカル開発サーバー(http://localhost:4200/)でゲームをプレイできる状態になります!

Cloudfrontへのコードビルド&デプロイも可能です。
game-select.png
game-conversation.png

おめでとうございます。Dungeon Adventure Gameの構築とデプロイが完了しました! 🎉🎉🎉