ChatGPTでマインスイーパーを作ってみた

以前の記事で、ChatGPTにコードを書いてもらいましたが、あくまでも補助的なものでした。つまりプログラムを書く際に、ドキュメントを調べる手間を省くためのものです。

しかし、仕様を与えてアプリそのものを書くこともできる様です。既に多くの方がChatGPTでゲームを作っておられます。私もどの程度のことができるのか試してみたいと思います。

対象ゲームの選定

最初なので、シンプルなゲームが良いとは思います。五目並べもシンプルですが、AI側にも思考ロジックが必要なものは難しそうです。また、ソリティアのようにカードを移動させるなど、UIが複雑なのも面倒そうです。

結果的に、マインスイーパーを選択しました。動作確認をしながら、機能を追加していくという作業で、おおよそ2時間くらいでできました。

出来上がったアプリはこんな感じです。(動作すると思いますので、遊んでみてください)

有名なゲームなので、遊び方はよく知られていますが、簡単に説明すると以下の通りです。

  • セルに隠れている10個の爆弾を避けて、全てのセルを開ければゲームクリアです。
  • 爆弾を開けた時点で、ゲームオーバーです。
  • 表示されている数字は隣接するセルにある爆弾の数を表しています。
  • ユーザが「爆弾がある」と思うセルには「旗」を立てます。
  • Tap(クリック)でセルを開け、Double Tap(ダブルクリック)で旗を立てます。

MineSweeper コードサンプル

マインスイーパー自体の仕様についての細かい指示は不要で、「flutterでマインスイーパーのコードを書いて」というだけで、ほぼ原型が出来上がりました。本来は、上記に記載したような遊び方の説明が必要だと思っていたのですが、あまりにも有名なゲームなので不要だった様です。

その後は、「ゲーム終了時に全ての爆弾を表示して」とか「リプレイできるようにダイアログを出して」「セルを立体的に表示して」など機能を追加していき、最終的に出来上がったコードは以下の様になります。

import 'package:flutter/material.dart';
import 'dart:math';

void main() {
  runApp(const MaterialApp(
    home: MyApp(),
  ));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Minesweeper'),
      ),
      body: SafeArea(
        child: Container(
          alignment: Alignment.center,
          margin: const EdgeInsets.all(20),
          child: const GameBoard(),
        ),
      ),
    );
  }
}

class GameBoard extends StatefulWidget {
  const GameBoard({super.key});

  @override
  // ignore: library_private_types_in_public_api
  _GameBoardState createState() => _GameBoardState();
}

class _GameBoardState extends State<GameBoard> {
  final int rowCount = 9;
  final int columnCount = 9;
  final int mineCount = 10;

  late GameState gameState;

  @override
  void initState() {
    super.initState();
    gameState = GameState(rowCount, columnCount, mineCount);
    gameState.flagCount = 0;
  }

  void showGameWonDialog(BuildContext context) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('成功!'),
        content: const Text('もう一度プレイしますか?'),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
              setState(() {
                gameState = GameState(rowCount, columnCount, mineCount);
              });
            },
            child: const Text('リプレイ'),
          ),
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
            },
            child: const Text('キャンセル'),
          ),
        ],
      ),
    );
  }

  void showGameOverDialog(BuildContext context) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('ゲームオーバー'),
        content: const Text('リプレイしますか?'),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
              setState(() {
                gameState = GameState(rowCount, columnCount, mineCount);
              });
            },
            child: const Text('リプレイ'),
          ),
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
            },
            child: const Text('キャンセル'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
      Expanded(
        child: SizedBox(
          width: 300,
          child: GridView.builder(
            gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: gameState.columnCount,
            ),
            itemBuilder: (context, index) {
              final row = index ~/ gameState.columnCount;
              final column = index % gameState.columnCount;
              final cell = gameState.cellAt(row, column);

              return GestureDetector(
                onTap: () {
                  setState(() {
                    gameState.tapCell(row, column);
                    if (gameState.isGameOver) {
                      showGameOverDialog(context);
                    } else if (gameState.isGameWon) {
                      showGameWonDialog(context);
                    }
                  });
                },
                onDoubleTap: () {
                  setState(() {
                    gameState.toggleFlag(row, column);
                  });
                },
                child: _buildCell(cell),
              );
            },
            itemCount: gameState.rowCount * gameState.columnCount,
          ),
        ),
      ),
      Text(('Flag数/爆弾数 : ${gameState.flagCount}/${gameState.mineCount}'),
          style: const TextStyle(fontSize: 20)),
    ]);
  }

  Widget _buildCell(Cell cell) {
    return Container(
      decoration: BoxDecoration(
        color: cell.isRevealed ? Colors.grey[350] : Colors.grey[200],
        border: Border.all(color: Colors.grey, width: 0.5),
        boxShadow: cell.isRevealed
            ? [
                const BoxShadow(
                    color: Colors.black38,
                    blurRadius: 2,
                    offset: Offset(-2, -2))
              ]
            : [
                const BoxShadow(
                    color: Colors.white, blurRadius: 2, offset: Offset(-2, -2)),
                const BoxShadow(
                    color: Colors.black38,
                    blurRadius: 2,
                    offset: Offset(-2, -2)),
              ],
      ),
      child: _buildCellContent(cell),
    );
  }

  Widget _buildCellContent(Cell cell) {
    if (gameState.isGameOver && cell.isMine) {
      return const Center(child: Text('💣', style: TextStyle(fontSize: 20)));
    }

    if (!cell.isRevealed) {
      if (cell.isFlagged) {
        return const Center(child: Text('🚩', style: TextStyle(fontSize: 20)));
      } else {
        return const SizedBox.shrink();
      }
    }

    if (cell.isEmpty) {
      return const SizedBox.shrink();
    }

    return Center(
      child: Text(
        '${cell.mineCount}',
        style: TextStyle(
          fontSize: 20,
          color: _getTextColor(cell.mineCount),
        ),
      ),
    );
  }

  Color _getTextColor(int mineCount) {
    switch (mineCount) {
      case 1:
        return Colors.blue;
      case 2:
        return Colors.green;
      case 3:
        return Colors.red;
      case 4:
        return Colors.purple;
      case 5:
        return Colors.yellow;
      case 6:
        return Colors.cyan;
      case 7:
        return Colors.black;
      case 8:
        return Colors.grey;
      default:
        return Colors.black;
    }
  }
}

class GameState {
  final int rowCount;
  final int columnCount;
  final int mineCount;
  late List<List<Cell>> _cells;
  bool isGameOver = false;

  int flagCount = 0;
  GameState(this.rowCount, this.columnCount, this.mineCount) {
    _initializeCells();
    _placeMines();
    _calculateMineCounts();
  }

  void _initializeCells() {
    _cells = List.generate(rowCount, (row) {
      return List.generate(columnCount, (column) {
        return Cell();
      });
    });
  }

  Cell cellAt(int row, int column) {
    return _cells[row][column];
  }

  void _placeMines() {
    int placedMines = 0;
    final random = Random();

    while (placedMines < mineCount) {
      final row = random.nextInt(rowCount);
      final column = random.nextInt(columnCount);
      final cell = _cells[row][column];

      if (!cell.isMine) {
        cell.isMine = true;
        placedMines++;
      }
    }
  }

  void _calculateMineCounts() {
    for (int row = 0; row < rowCount; row++) {
      for (int column = 0; column < columnCount; column++) {
        final cell = _cells[row][column];
        if (cell.isMine) continue;

        int count = 0;
        for (int r = row - 1; r <= row + 1; r++) {
          for (int c = column - 1; c <= column + 1; c++) {
            if (_isValidCoordinate(r, c) && _cells[r][c].isMine) {
              count++;
            }
          }
        }
        cell.mineCount = count;
      }
    }
  }

  bool _isValidCoordinate(int row, int column) {
    return row >= 0 && row < rowCount && column >= 0 && column < columnCount;
  }

  bool get isGameWon {
    int unrevealedSafeCells = 0;
    for (int row = 0; row < rowCount; row++) {
      for (int col = 0; col < columnCount; col++) {
        if (!(_cells[row][col].isMine) && !(_cells[row][col].isRevealed)) {
          unrevealedSafeCells++;
        }
      }
    }
    return unrevealedSafeCells == 0;
  }

  Cell getCell(int row, int column) {
    return _cells[row][column];
  }

  void tapCell(int row, int column) {
    if (isGameOver) return;

    final cell = _cells[row][column];
    if (cell.isRevealed) return;

    cell.isRevealed = true;

    if (cell.isMine) {
      isGameOver = true;
      return;
    }

    if (cell.isEmpty) {
      _revealEmptyCells(row, column);
    }
  }

  void _revealEmptyCells(int row, int column) {
    for (int r = row - 1; r <= row + 1; r++) {
      for (int c = column - 1; c <= column + 1; c++) {
        if (_isValidCoordinate(r, c)) {
          final cell = _cells[r][c];
          if (!cell.isRevealed) {
            cell.isRevealed = true;
            if (cell.isEmpty) {
              _revealEmptyCells(r, c);
            }
          }
        }
      }
    }
  }

  void toggleFlag(int row, int column) {
    if (isGameOver) return;

    final cell = _cells[row][column];
    if (cell.isRevealed) return;

    if (cell.isFlagged) {
      flagCount--;
    } else {
      flagCount++;
    }
    cell.isFlagged = !cell.isFlagged;
  }
}

class Cell {
  bool isMine = false;
  bool isRevealed = false;
  bool isFlagged = false;
  int mineCount = 0;

  bool get isEmpty => mineCount == 0;
}

コメントはほとんどありませんが、なくても読みやすいコードだと思います。正直、このコードを2時間で書き上げる自信は私にはありません。

使い方によっては、新しいゲームの仕様自体も作れるように思いますが、それには指示する人間側のセンスは必要ですね。