top
技術

Image

はじめに

最近、エージェント AI の能力を検証する目的で、Cline (Claude-3.5-sonnet) にゲーム開発をさせてみることにしました。題材として選んだのは、ゲーム開発の古典的な題材であるテトリスです。

テトリスを選んだ理由は以下の通りです:

  • 基本的なルールが明確で理解しやすい
  • グラフィックスの実装が比較的シンプル
  • ゲームロジックの実装が適度な複雑さを持つ
  • 拡張性があり、機能を追加しやすい

今回は、HTML5 Canvas とモダンな JavaScript を使用して、ブラウザで動作するテトリスを実装しました。

Cline とは?

Cline は、Visual Studio Code 上で動作する高度なプログラミング能力を持つエージェント AI です。単なるチャットボットではなく、実際のコードの作成、編集、およびファイル操作が可能で、開発者の意図を理解しながら実装を提案できる特徴があります。様々なモデルの中から好きなものを選択できるようになっており、今回は Claude-3.5-sonnet を選択しました。

Cline には、以下のような役割を担ってもらいました。

  • ゲームの基本設計と実装方針の提案
  • HTML、JavaScript、CSS コードの生成
  • 機能の段階的な実装とデバッグ

実装の概要

使用技術

  • HTML5 Canvas:ゲーム画面の描画
  • モダンJavaScript:ゲームロジックの実装
  • CSS Flexbox:レイアウト設計

ファイル構成

tetris/
├── index.html  # メインのHTML
└── style.css   # スタイルシート

シンプルな構成ながら、必要な機能はすべて実装されています。JavaScript は index.html 内に直接記述されていますが、これはAIが初期実装として提案した形です。

ゲームの機能

基本機能

  • 7種類のテトリミノ
  • ブロックの回転と移動
  • ラインの消去とスコア計算
  • ゲームオーバー判定

特別な機能

  1. ホールド機能
    • 現在のピースを一時保存
    • 必要なタイミングで交換可能
    • 1回の落下につき1回のみ使用可能
  2. ネクストピース表示
    • 次に出現するピースをプレビュー表示
    • 戦略的なプレイをサポート

操作方法

キー機能
Wブロック回転
A左へ移動
D右へ移動
S落下速度上昇
スペース即時落下

技術的な解説

Canvas描画システム

ゲームの描画は Canval API を使用しています。主な描画要素は以下の通りです。

// メインボードの描画
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);

// グリッドの描画
ctx.strokeStyle = '#333';
for(let i = 0; i < COLS; i++) {
    for(let j = 0; j < ROWS; j++) {
        ctx.strokeRect(i * BLOCK_SIZE, j * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
    }
}

ピースの定義と回転

各テトリミノは2次元配列で定義され、回転は行列の転置と反転を組み合わせて実現しています。

const SHAPES = [
    [],
    [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]], // I
    [[2, 0, 0], [2, 2, 2], [0, 0, 0]], // J
    [[0, 0, 3], [3, 3, 3], [0, 0, 0]], // L
    [[4, 4], [4, 4]], // O
    [[0, 5, 5], [5, 5, 0], [0, 0, 0]], // S
    [[0, 6, 0], [6, 6, 6], [0, 0, 0]], // T
    [[7, 7, 0], [0, 7, 7], [0, 0, 0]]  // Z
];

衝突判定と消去処理

ブロックの移動や回転時には、適切な衝突判定を行っています。

function collide(grid, player) {
    const [m, o] = [player.matrix, player.position];
    for (let y = 0; y < m.length; ++y) {
        for (let x = 0; x < m[y].length; ++x) {
            if (m[y][x] !== 0 &&
                (grid[y + o.y] &&
                grid[y + o.y][x + o.x]) !== 0) {
                return true;
            }
        }
    }
    return false;
}

UIデザイン

モダンなダークテーマ

ゲーム画面全体にダークテーマを採用し、シンプルでモダンな外観を実現しています。

body {
    background-color: #000;
    color: #fff;
    font-family: Arial, sans-serif;
}

レスポンシブレイアウト

Flexbox を使用して、画面サイズに応じて適切にレイアウトを調整します。

.game-container {
    display: flex;
    gap: 20px;
}

.info-panel {
    display: flex;
    flex-direction: column;
    gap: 20px;
}

AIとの協働について

Cline の開発アプローチ

Cline は以下のような特徴的なアプローチで実装を提案しました。

  1. モジュール性
    • 機能ごとに関数を分割
    • 責務の明確な分離
  2. 拡張性
    • 新機能追加を考慮した設計
    • 設定値の変数化
  3. エラー処理
    • 衝突判定による不正な状態の防止
    • ゲームオーバー時の適切なリセット

学びと気づき

AI とのゲーム開発を通じて、以下のような気づきがありました

  1. コード品質
    • AIは一貫性のある命名規則を使用
    • 適切なコメントと関数の分割
  2. 実装の効率
    • 基本機能から段階的に実装
    • 既存のベストプラクティスの活用
  3. 改善の余地
    • スコアシステムの拡張
    • エフェクトやサウンドの追加
    • モバイル対応のタッチ操作

まとめ

Cline とのテトリス開発を通じて、AI が実用的なゲーム開発において十分な能力を持っていることが確認できました。基本的な機能だけでなく、ホールド機能やネクストピース表示といった発展的な機能も、適切に実装することができました。

エージェント AI を利用した開発は、かなりのポテンシャルがありそうですね。

今後の開発スタイルのデファクトスタンダードになるのかもしれません。

今回は以上です。それでは!

コード全体

  • index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>テトリス</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="game-container">
        <div class="side-panel">
            <div class="hold-piece">
                <h3>Hold</h3>
                <canvas id="holdPiece" width="100" height="100"></canvas>
            </div>
        </div>
        <canvas id="gameBoard" width="300" height="600"></canvas>
        <div class="info-panel">
            <div class="next-piece">
                <h3>Next</h3>
                <canvas id="nextPiece" width="100" height="100"></canvas>
            </div>
            <div>
                <h3>Score: <span id="score">0</span></h3>
                <h3>Lines: <span id="lines">0</span></h3>
            </div>
            <div class="controls">
                <h4>操作方法:</h4>
                <p>W: ブロック回転</p>
                <p>A: 左へ移動</p>
                <p>D: 右へ移動</p>
                <p>S: 落下速度上昇</p>
                <p>スペース: 即時落下</p>
            </div>
        </div>
    </div>
    <script>
        const COLS = 10;
        const ROWS = 20;
        const BLOCK_SIZE = 30;
        const COLORS = [
            null,
            '#FF0D72', // I
            '#0DC2FF', // J
            '#0DFF72', // L
            '#F538FF', // O
            '#FF8E0D', // S
            '#FFE138', // T
            '#3877FF'  // Z
        ];

        const SHAPES = [
            [],
            [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]], // I
            [[2, 0, 0], [2, 2, 2], [0, 0, 0]], // J
            [[0, 0, 3], [3, 3, 3], [0, 0, 0]], // L
            [[4, 4], [4, 4]], // O
            [[0, 5, 5], [5, 5, 0], [0, 0, 0]], // S
            [[0, 6, 0], [6, 6, 6], [0, 0, 0]], // T
            [[7, 7, 0], [0, 7, 7], [0, 0, 0]]  // Z
        ];

        const canvas = document.getElementById('gameBoard');
        const nextCanvas = document.getElementById('nextPiece');
        const holdCanvas = document.getElementById('holdPiece');
        const ctx = canvas.getContext('2d');
        const nextCtx = nextCanvas.getContext('2d');
        const holdCtx = holdCanvas.getContext('2d');
        const scoreElement = document.getElementById('score');
        const linesElement = document.getElementById('lines');

        let dropCounter = 0;
        let dropInterval = 1000;
        let lastTime = 0;
        let score = 0;
        let lines = 0;
        let holdPiece = null;
        let canHold = true;

        const gameState = {
            position: {x: 0, y: 0},
            matrix: null,
            nextPiece: null,
            grid: createMatrix(COLS, ROWS)
        };

        function createMatrix(w, h) {
            const matrix = [];
            while (h--) {
                matrix.push(new Array(w).fill(0));
            }
            return matrix;
        }

        function collide(grid, player) {
            const [m, o] = [player.matrix, player.position];
            for (let y = 0; y < m.length; ++y) {
                for (let x = 0; x < m[y].length; ++x) {
                    if (m[y][x] !== 0 &&
                        (grid[y + o.y] &&
                        grid[y + o.y][x + o.x]) !== 0) {
                        return true;
                    }
                }
            }
            return false;
        }

        function rotate(matrix) {
            for (let y = 0; y < matrix.length; ++y) {
                for (let x = 0; x < y; ++x) {
                    [
                        matrix[x][y],
                        matrix[y][x],
                    ] = [
                        matrix[y][x],
                        matrix[x][y],
                    ];
                }
            }
            matrix.forEach(row => row.reverse());
        }

        function playerRotate(direction = 1) {
            const pos = gameState.position.x;
            let offset = 1;
            
            // 回転方向に応じて1回または3回回転
            if (direction === 1) {
                rotate(gameState.matrix);
            } else {
                rotate(gameState.matrix);
                rotate(gameState.matrix);
                rotate(gameState.matrix);
            }

            while (collide(gameState.grid, gameState)) {
                gameState.position.x += offset;
                offset = -(offset + (offset > 0 ? 1 : -1));
                if (offset > gameState.matrix[0].length) {
                    if (direction === 1) {
                        rotate(gameState.matrix);
                        rotate(gameState.matrix);
                        rotate(gameState.matrix);
                    } else {
                        rotate(gameState.matrix);
                    }
                    gameState.position.x = pos;
                    return;
                }
            }
        }

        function createPiece(type) {
            return SHAPES[type];
        }

        function drawMatrix(matrix, offset, context = ctx) {
            matrix.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value !== 0) {
                        context.fillStyle = COLORS[value];
                        context.fillRect(x + offset.x,
                                       y + offset.y,
                                       1, 1);
                    }
                });
            });
        }

        function draw() {
            // メインボードのクリア
            ctx.fillStyle = '#000';
            ctx.fillRect(0, 0, canvas.width, canvas.height);

            // グリッドの描画
            ctx.strokeStyle = '#333';
            for(let i = 0; i < COLS; i++) {
                for(let j = 0; j < ROWS; j++) {
                    ctx.strokeRect(i * BLOCK_SIZE, j * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                }
            }

            // スケール設定
            ctx.save();
            ctx.scale(BLOCK_SIZE, BLOCK_SIZE);

            // 固定されたブロックの描画
            drawMatrix(gameState.grid, {x: 0, y: 0});
            // 現在のピースの描画
            if (gameState.matrix) {
                drawMatrix(gameState.matrix, gameState.position);
            }

            ctx.restore();

            // 次のピースのプレビュー
            nextCtx.fillStyle = '#000';
            nextCtx.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
            if (gameState.nextPiece) {
                nextCtx.save();
                nextCtx.scale(BLOCK_SIZE / 1.5, BLOCK_SIZE / 1.5);
                nextCtx.translate(1, 1);
                drawMatrix(gameState.nextPiece, {x: 0, y: 0}, nextCtx);
                nextCtx.restore();
            }

            // ホールドピースの描画
            holdCtx.fillStyle = '#000';
            holdCtx.fillRect(0, 0, holdCanvas.width, holdCanvas.height);
            if (holdPiece) {
                holdCtx.save();
                holdCtx.scale(BLOCK_SIZE / 1.5, BLOCK_SIZE / 1.5);
                holdCtx.translate(1, 1);
                drawMatrix(holdPiece, {x: 0, y: 0}, holdCtx);
                holdCtx.restore();
            }
        }

        function merge(grid, player) {
            player.matrix.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value !== 0) {
                        grid[y + player.position.y][x + player.position.x] = value;
                    }
                });
            });
        }

        function holdCurrentPiece() {
            if (!canHold) return;

            const currentPiece = gameState.matrix;
            if (!holdPiece) {
                holdPiece = currentPiece;
                playerReset();
            } else {
                const temp = holdPiece;
                holdPiece = currentPiece;
                gameState.matrix = temp;
                gameState.position.y = 0;
                gameState.position.x = Math.floor(COLS / 2) - Math.floor(gameState.matrix[0].length / 2);
            }
            canHold = false;
        }

        function playerDrop() {
            gameState.position.y++;
            if (collide(gameState.grid, gameState)) {
                gameState.position.y--;
                merge(gameState.grid, gameState);
                playerReset();
                sweepLines();
                updateScore();
            }
            dropCounter = 0;
        }

        function playerMove(dir) {
            gameState.position.x += dir;
            if (collide(gameState.grid, gameState)) {
                gameState.position.x -= dir;
            }
        }

        function playerReset() {
            if (!gameState.nextPiece) {
                gameState.nextPiece = createPiece(Math.floor(Math.random() * 7) + 1);
            }
            gameState.matrix = gameState.nextPiece;
            gameState.nextPiece = createPiece(Math.floor(Math.random() * 7) + 1);
            gameState.position.y = 0;
            gameState.position.x = Math.floor(COLS / 2) - Math.floor(gameState.matrix[0].length / 2);
            canHold = true;

            if (collide(gameState.grid, gameState)) {
                gameState.grid.forEach(row => row.fill(0));
                score = 0;
                lines = 0;
                holdPiece = null;
                updateScore();
            }
        }

        function sweepLines() {
            let rowCount = 1;
            let cleared = 0;
            
            outer: for (let y = gameState.grid.length - 1; y > 0; --y) {
                for (let x = 0; x < gameState.grid[y].length; ++x) {
                    if (gameState.grid[y][x] === 0) {
                        continue outer;
                    }
                }

                const row = gameState.grid.splice(y, 1)[0].fill(0);
                gameState.grid.unshift(row);
                ++y;
                cleared++;
            }

            if (cleared > 0) {
                lines += cleared;
                score += cleared * rowCount * 100;
                rowCount *= 2;
            }
        }

        function updateScore() {
            scoreElement.textContent = score;
            linesElement.textContent = lines;
        }

        function update(time = 0) {
            const deltaTime = time - lastTime;
            lastTime = time;

            dropCounter += deltaTime;
            if (dropCounter > dropInterval) {
                playerDrop();
            }

            draw();
            requestAnimationFrame(update);
        }

        document.addEventListener('keydown', event => {
            const key = event.key.toLowerCase();
            switch (key) {
                case 'a':
                    playerMove(-1);
                    break;
                case 'd':
                    playerMove(1);
                    break;
                case 's':
                    playerDrop();
                    break;
                case 'w':
                    playerRotate(1);
                    break;
                case ' ':
                    while (!collide(gameState.grid, {
                        matrix: gameState.matrix,
                        position: {
                            x: gameState.position.x,
                            y: gameState.position.y + 1
                        }
                    })) {
                        gameState.position.y++;
                    }
                    playerDrop();
                    break;
            }
        });

        // ゲーム開始
        playerReset();
        update();
    </script>
</body>
</html>

  • style.css
body {
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
    margin: 0;
    background-color: #000;
    color: #fff;
    font-family: Arial, sans-serif;
}

.game-container {
    display: flex;
    gap: 20px;
}

canvas {
    border: 2px solid #fff;
}

.info-panel {
    display: flex;
    flex-direction: column;
    gap: 20px;
}

.next-piece, .hold-piece {
    border: 2px solid #fff;
    padding: 10px;
}

.controls {
    margin-top: 20px;
    padding: 10px;
    border: 1px solid #333;
}

.controls h4 {
    margin: 10px 0;
    color: #888;
}