EY-Office ブログ

シンプルな画面ならAIに作ってもらった方が良いかも?

先週のRoo Codeの記事の続きです。先週作ったReactアプリは1画面でしたが、今回はこのブログに良く出てくるジャンケンの「対戦結果」と「対戦成績」を表示出来るアプリを作ってもらいました。先週上手く行ったRoo Code + Gemini 2.5 Proのみ試しました。

AI-code-generate Bing Image Creatorが生成した画像を使っています

プロンプト

画面が増えた分、プロンプトの長くなりました。先週の2倍くらいあります。AIに伝わりやすいように、先週より少し丁寧に書きました。

# ジャンケンを行うReactアプリを作ってくさい

## 概要
- 人間はグー、チョキ、パーのボタンを押し、コンピューターは乱数でジャンケンの手を作り対戦します
- 勝敗(勝ち、負け、引き分け)はコンピューターが判定します

## 画面仕様
- 画面には対戦結果と対戦成績の2つがあります
- 対戦結果はパス/scoresに、対戦成績はパス/statusに対応します
- 両画面ともに
   - タイトルとメニューの表示されているアプリケーションバー
   - グー、チョキ、パーのボタンが並ぶコンポーネント
   - 対戦結果または対戦成績の表示
- 対戦結果の画面は
   - 対戦の結果(人間の手、コンピューターの手、勝敗)を表形式で表示します
   - 人間が勝った場合は表示は青色にしてください
   - 人間が負けた場合は表示は赤色にしてください
   - 引き分けの場合は黒色にしてください
- 対戦成績の画面は
   - 対戦の成績(人間が勝った数、負けた数、引き分けの数)を表形式で表示します
   - 人間が勝った場合は表示は青色にしてください
   - 人間が負けた場合は表示は赤色にしてください
   - 引き分けの場合は黒色にしてください

## 機能仕様
- 対戦の結果はデータベースに保存してください
   - データベースの扱いはPrisma ORMを使ってください
   - データベースはsqlite3を使ってください

## 環境
- フレームワークはNext.js App routerを使ってください
- React Server componetsを使ってください
- TypeScript を使ってください
- スタイリングはTailwind CSSを使ってください
- コメントは日本語で書いてください

画面とパス(URL)の関連、ルーティングの説明は雑ですが、伝わったようです。

出来たアプリ

最初に生成されたアプリの画面は以下のようになりました。もちろん動作は完璧でした。

ただし、対戦結果と対戦成績で表のデザインが違うので 対戦成績のテーブル表示のデザインを対戦結果と同じにしてください と指定して直してもらいました。
また、前回同様にGemini 2.5 Proはコンポーネントを細かく分けてはくれないので、ジャンケンボタンのコンポーネントと対戦結果表示、対戦成績表示コンポーネントを分割してもらいました。

スマフォ表示

CSSにはTailwindCSSを使っているのでスマフォ画面サイズでも表示できるのでは? と思い表示したところ日時が長すぎて画面がはみ出してますね。

そこで、スマフォで対戦成績を表示するさいは、日時を日付と時間を2行で表示し日時カラムの幅を狭くしてください と指示したところ、以下のように改善されました。

しかし、PCで表示した際にも日付と時間を2行で表示されていたので、

PCでの日時は1行にしてください
PCでの日時表示は日付の表示と時間の表示フォントを同じ大きさにしてください。さらにPCでは秒も表示してください

と指定して、PCで表示した際の表示を元にもどしました。

生成されたコード

生成されたコードには日本語のコメントが付いているので、説明しなくても良いファイルが多いかなと思います。

  • ファイル構成
└── src
    ├── actions
    │   └── janken.ts
    ├── app
    │   ├── favicon.ico
    │   ├── globals.css
    │   ├── layout.tsx
    │   ├── page.tsx
    │   ├── scores
    │   │   └── page.tsx
    │   └── status
    │       └── page.tsx
    ├── components
    │   ├── JankenButtons.tsx
    │   ├── ScoresTable.tsx
    │   └── StatusTable.tsx
    └── lib
        ├── janken.ts
        └── prisma.ts
  • src/app/scores/page.tsx
// src/app/scores/page.tsx
import { getScores } from '@/actions/janken';
import { JankenButtons } from '@/components/JankenButtons';
import { ScoresTable } from '@/components/ScoresTable'; // Import the new component

// ページのメタデータを設定
export const metadata = {
  title: '対戦結果 | ジャンケンアプリ',
};

/**
 * 対戦結果を表示するページコンポーネント (Server Component)
 */
export default async function ScoresPage() {
  // サーバーサイドで対戦結果を取得
  const scores = await getScores();

  // The getResultColor function is removed as it's now inside ScoresTable

  return (
    <div>
      <h1 className="text-2xl font-bold mb-6 text-center">対戦結果</h1>

      {/* ジャンケンボタンコンポーネント */}
      <JankenButtons />

      {/* 対戦結果テーブルコンポーネント */}
      <ScoresTable scores={scores} />
    </div>
  );
}
  • src/app/status/page.tsx
// src/app/status/page.tsx
import { getStatus } from '@/actions/janken';
import { JankenButtons } from '@/components/JankenButtons';
import { StatusTable } from '@/components/StatusTable'; // Import the new component
// ページのメタデータを設定
export const metadata = {
  title: '対戦成績 | ジャンケンアプリ',
};

/**
 * 対戦成績を表示するページコンポーネント (Server Component)
 */
export default async function StatusPage() {
  // サーバーサイドで対戦成績を取得
  const status = await getStatus();

  // The getResultColor function is removed as it's now inside StatusTable

  return (
    <div>
      <h1 className="text-2xl font-bold mb-6 text-center">対戦成績</h1>

      {/* ジャンケンボタンコンポーネント */}
      <JankenButtons />

      {/* 対戦成績テーブルコンポーネント */}
      <StatusTable status={status} />
    </div>
  );
}
  • app/page.tsx
// src/app/page.tsx
import { redirect } from 'next/navigation';

export default function HomePage() {
  // /scoresページにリダイレクト
  redirect('/scores');

  // リダイレクトされるため、以下の内容は通常表示されない
  // return (
  //   <div>
  //     <h1>ジャンケンアプリへようこそ!</h1>
  //     <p>メニューから「対戦結果」または「対戦成績」を選んでください。</p>
  //   </div>
  // );
}
  • app/layout.tsx
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import Link from 'next/link'; // Linkコンポーネントをインポート
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'ジャンケンアプリ', // アプリケーションのタイトル
  description: 'Next.jsで作られたジャンケンアプリ',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    // 言語を日本語に設定
    <html lang="ja">
      <body className={inter.className}>
        {/* アプリケーションバー */}
        <header className="bg-blue-600 text-white p-4 shadow-md">
          <div className="container mx-auto flex justify-between items-center">
            {/* タイトル */}
            <h1 className="text-xl font-bold">
              <Link href="/">ジャンケンアプリ</Link> {/* ホームへのリンク */}
            </h1>
            {/* ナビゲーションメニュー */}
            <nav>
              <ul className="flex space-x-4">
                <li>
                  <Link href="/scores" className="hover:text-blue-200">
                    対戦結果
                  </Link>
                </li>
                <li>
                  <Link href="/status" className="hover:text-blue-200">
                    対戦成績
                  </Link>
                </li>
              </ul>
            </nav>
          </div>
        </header>

        {/* メインコンテンツ */}
        <main className="container mx-auto p-4">{children}</main>
      </body>
    </html>
  );
}
  • src/components/JankenButtons.tsx

ここでは、React18で導入され、React19で改良された useTransition を使い、Server Functionによるジャンケン処理中に 処理中… を表示しています。またエラー処理等も出来ていますね。素晴らしい!

// src/components/JankenButtons.tsx
'use client'; // クライアントコンポーネントとしてマーク

import { Hand, Hands } from '@/lib/janken';
import { playJanken } from '@/actions/janken';
import { useState, useTransition } from 'react'; // useTransitionフックをインポート

/**
 * ジャンケンの手を選択するボタンを表示するコンポーネント
 */
export const JankenButtons = () => {
  // useTransitionフックを使用して、サーバーアクション実行中の状態を管理
  const [isPending, startTransition] = useTransition();
  const [message, setMessage] = useState<string | null>(null); // メッセージ表示用

  // ボタンクリック時の処理
  const handleClick = (hand: Hand) => {
    setMessage(null); // 前回のメッセージをクリア
    startTransition(async () => {
      try {
        await playJanken(hand);
        setMessage(`${hand}」を出しました!結果を確認してください。`);
      } catch (error) {
        console.error('ジャンケンの実行に失敗しました:', error);
        setMessage('エラーが発生しました。もう一度試してください。');
      }
    });
  };

  return (
    <div className="mt-6 text-center">
      <h2 className="text-lg font-semibold mb-4">手を選んでください:</h2>
      <div className="flex justify-center space-x-4">
        {Hands.map((hand) => (
          <button
            key={hand}
            onClick={() => handleClick(hand)}
            // サーバーアクション実行中はボタンを無効化
            disabled={isPending}
            className={`px-6 py-3 rounded-lg text-white font-bold shadow-md transition duration-150 ease-in-out
                        ${
                          isPending
                            ? 'bg-gray-400 cursor-not-allowed' // 無効時のスタイル
                            : 'bg-indigo-500 hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2' // 通常時のスタイル
                        }
                       `}
          >
            {hand}
          </button>
        ))}
      </div>
      {/* サーバーアクション実行中のインジケーター */}
      {isPending && <p className="mt-4 text-gray-600">処理中...</p>}
      {/* 結果メッセージ */}
      {message && <p className="mt-4 text-green-600">{message}</p>}
    </div>
  );
};
  • src/components/ScoresTable.tsx

日時表示のスマフォ対応ですが、スマフォならblockで日付と時間が2行で表示され、PCの場合はsm:inlineが適応され日付と時間が1行で表示されます。さらにスマフォの場合はフォントサイズや色を微調整していますね。

// src/components/ScoresTable.tsx
import { Score } from '@prisma/client'; // Assuming Score type is from Prisma
import { Result } from '@/lib/janken'; // Result enumをインポート

type ScoresTableProps = {
  scores: Score[];
};

// 結果に応じたテキスト色を返すヘルパー関数
const getResultColor = (result: string) => {
  switch (result) {
    case Result.WIN:
      return 'text-blue-600'; // 勝ち: 青
    case Result.LOSE:
      return 'text-red-600'; // 負け: 赤
    case Result.DRAW:
      return 'text-gray-800'; // 引き分け: 黒 (濃いグレー)
    default:
      return 'text-black'; // デフォルト: 黒
  }
};

export function ScoresTable({ scores }: ScoresTableProps) {
  return (
    <div className="mt-8 overflow-x-auto">
      <table className="min-w-full bg-white border border-gray-200">
        <thead>
          <tr className="bg-gray-100 border-b">
            {/* Adjust padding for smaller screens */}
            <th className="py-3 px-2 sm:px-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
              日時
            </th>
            <th className="py-3 px-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
              あなたの手
            </th>
            <th className="py-3 px-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
              相手の手
            </th>
            <th className="py-3 px-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
              結果
            </th>
          </tr>
        </thead>
        <tbody className="divide-y divide-gray-200">
          {scores.length === 0 ? (
            <tr>
              <td colSpan={4} className="py-4 px-4 text-center text-gray-500">
                まだ対戦結果がありません。
              </td>
            </tr>
          ) : (
            scores.map((score) => (
              <tr key={score.id} className="hover:bg-gray-50">
                {/* Format date/time on two lines, adjust padding, remove nowrap */}
                {/* Apply sm:whitespace-nowrap to prevent wrapping on larger screens */}
                <td className="py-4 px-2 sm:px-4 text-sm text-gray-700 sm:whitespace-nowrap">
                  {/* Use block by default, inline on sm screens */}
                  <span className="block sm:inline">{new Date(score.createdAt).toLocaleDateString('ja-JP')}</span>
                  {/* Use block by default, inline on sm screens, add margin for spacing */}
                  {/* Remove text-xs/text-gray-500 for sm screens, add seconds to time */}
                  <span className="block sm:inline sm:ml-1 text-xs sm:text-sm text-gray-500 sm:text-gray-700">{new Date(score.createdAt).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</span>
                </td>
                <td className="py-4 px-4 whitespace-nowrap text-sm text-gray-700">
                  {score.humanHand}
                </td>
                <td className="py-4 px-4 whitespace-nowrap text-sm text-gray-700">
                  {score.computerHand}
                </td>
                <td
                  className={`py-4 px-4 whitespace-nowrap text-sm font-medium ${getResultColor(
                    score.result
                  )}`}
                >
                  {score.result}
                </td>
              </tr>
            ))
          )}
        </tbody>
      </table>
    </div>
  );
}
  • src/components/StatusTable.tsx
// src/components/StatusTable.tsx

type Status = {
  wins: number;
  losses: number;
  draws: number;
};

type StatusTableProps = {
  status: Status;
};

// 結果に応じたテキスト色を返すヘルパー関数
const getResultColor = (resultType: 'win' | 'lose' | 'draw') => {
  switch (resultType) {
    case 'win':
      return 'text-blue-600'; // 勝ち: 青
    case 'lose':
      return 'text-red-600'; // 負け: 赤
    case 'draw':
      return 'text-gray-800'; // 引き分け: 黒 (濃いグレー)
    default:
      return 'text-black'; // デフォルト: 黒
  }
};

export function StatusTable({ status }: StatusTableProps) {
  return (
    <div className="mt-8 overflow-x-auto">
      <table className="min-w-full bg-white border border-gray-200">
        <thead>
          <tr className="bg-gray-100 border-b">
            <th className="py-3 px-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
              項目
            </th>
            <th className="py-3 px-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
              回数
            </th>
          </tr>
        </thead>
        <tbody className="divide-y divide-gray-200">
          <tr className="hover:bg-gray-50">
            <td className="py-4 px-4 whitespace-nowrap text-sm text-gray-700">
              勝ち
            </td>
            <td
              className={`py-4 px-4 whitespace-nowrap text-sm font-medium ${getResultColor(
                'win'
              )}`}
            >
              {status.wins}</td>
          </tr>
          <tr className="hover:bg-gray-50">
            <td className="py-4 px-4 whitespace-nowrap text-sm text-gray-700">
              負け
            </td>
            <td
              className={`py-4 px-4 whitespace-nowrap text-sm font-medium ${getResultColor(
                'lose'
              )}`}
            >
              {status.losses}</td>
          </tr>
          <tr className="hover:bg-gray-50">
            <td className="py-4 px-4 whitespace-nowrap text-sm text-gray-700">
              引き分け
            </td>
            <td
              className={`py-4 px-4 whitespace-nowrap text-sm font-medium ${getResultColor(
                'draw'
              )}`}
            >
              {status.draws}</td>
          </tr>
        </tbody>
      </table>
    </div>
  );
}
  • src/actions/janken.ts

Server Functionのモジュールです。コメントにはサーバーアクション(Server Action)と書かれていますが、これはServer Functionの登場時はServer Actionという名称で、後からServer Functionに変更されたからです。AIに聞いてみたところネット上の情報は80〜90%がServer Actionのようです。

内容は常識的ですね

  • ジャンケンの実行後はrevalidatePath()で再表示を指示しています
  • 対戦成績はGROUP BYで取得してますが、結果を取り出すところは、もう少しスマートなコードに出来そうですね
// src/actions/janken.ts
'use server'; // サーバーアクションとしてマーク

import { revalidatePath } from 'next/cache';
import { Hand, Result, getComputerHand, judge } from '@/lib/janken';
import { prisma } from '@/lib/prisma';

/**
 * ジャンケンを実行し、結果をデータベースに保存するサーバーアクション
 * @param humanHand 人間の選択した手
 */
export const playJanken = async (humanHand: Hand) => {
  const computerHand = getComputerHand(); // コンピューターの手を決定
  const result = judge(humanHand, computerHand); // 勝敗を判定

  // データベースに結果を保存
  await prisma.score.create({
    data: {
      humanHand: humanHand,
      computerHand: computerHand,
      result: result,
    },
  });

  // 結果表示ページと成績表示ページのキャッシュを更新
  revalidatePath('/scores');
  revalidatePath('/status');
};

/**
 * 全ての対戦結果を取得するサーバーアクション
 * @returns Promise<Score[]> 対戦結果の配列
 */
export const getScores = async () => {
  return await prisma.score.findMany({
    orderBy: {
      createdAt: 'desc', // 作成日時の降順で取得
    },
  });
};

/**
 * 対戦成績(勝ち、負け、引き分けの数)を取得するサーバーアクション
 * @returns Promise<{ wins: number; losses: number; draws: number }> 対戦成績
 */
export const getStatus = async () => {
  const results = await prisma.score.groupBy({
    by: ['result'], // 結果ごとにグループ化
    _count: {
      result: true, // 各結果の数をカウント
    },
  });

  // 結果を集計
  let wins = 0;
  let losses = 0;
  let draws = 0;

  results.forEach((item) => {
    if (item.result === Result.WIN) {
      wins = item._count.result;
    } else if (item.result === Result.LOSE) {
      losses = item._count.result;
    } else if (item.result === Result.DRAW) {
      draws = item._count.result;
    }
  });

  return { wins, losses, draws };
};
  • lib/janken.ts
// src/lib/janken.ts

/**
 * ジャンケンの手
 */
export enum Hand {
  ROCK = 'グー',
  SCISSORS = 'チョキ',
  PAPER = 'パー',
}

/**
 * ジャンケンの手の配列
 */
export const Hands = [Hand.ROCK, Hand.SCISSORS, Hand.PAPER];

/**
 * ジャンケンの結果
 */
export enum Result {
  WIN = '勝ち',
  LOSE = '負け',
  DRAW = '引き分け',
}

/**
 * コンピューターの手をランダムに決定する
 * @returns Hand コンピューターの手
 */
export const getComputerHand = (): Hand => {
  const randomIndex = Math.floor(Math.random() * Hands.length);
  return Hands[randomIndex];
};

/**
 * ジャンケンの勝敗を判定する
 * @param humanHand 人間の手
 * @param computerHand コンピューターの手
 * @returns Result 勝敗
 */
export const judge = (humanHand: Hand, computerHand: Hand): Result => {
  if (humanHand === computerHand) {
    return Result.DRAW;
  } else if (
    (humanHand === Hand.ROCK && computerHand === Hand.SCISSORS) ||
    (humanHand === Hand.SCISSORS && computerHand === Hand.PAPER) ||
    (humanHand === Hand.PAPER && computerHand === Hand.ROCK)
  ) {
    return Result.WIN;
  } else {
    return Result.LOSE;
  }
};
  • lib/prisma.ts
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';

// PrismaClientのインスタンスを作成
// グローバルスコープにインスタンスを保持することで、開発中のホットリロード時に
// 新しいインスタンスが大量に生成されるのを防ぐ
const globalForPrisma = global as unknown as { prisma: PrismaClient | undefined };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    // オプションでログ設定などを追加可能
    // log: ['query'],
  });

// 本番環境以外では、グローバルオブジェクトにPrismaClientインスタンスを格納する
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

まとめ

今週は、先週に比べると少し長いプロンプトを与えてコードを生成してもらいましたが、予想以上に良いコードを生成していて驚きました。

また、対戦結果と対戦成績の表示レイアウトの統一や、スマフォ対応を自然言語で指示してみました。スマフォ対応は直ぐには出来ませんでしたが、新人や理解力の低いプログラマーに頼んだ場合も同じようになりそうですね。😅

自然言語のプロンプトで伝えられるレベルの画面や処理なら、AIに頼んでもじゅうぶんな結果が得られそうですね。

ただし、複雑な画面や処理になると、自然言語では人間にも正しく伝わらない事がありますね。今後は自然言語を補足し設計をAIに伝える新たなアプローチが出て来るんですかね。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ