
はじめに
最近、エージェント 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回のみ使用可能
- ネクストピース表示
- 次に出現するピースをプレビュー表示
- 戦略的なプレイをサポート
操作方法
キー | 機能 |
---|---|
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 は以下のような特徴的なアプローチで実装を提案しました。
- モジュール性
- 機能ごとに関数を分割
- 責務の明確な分離
- 拡張性
- 新機能追加を考慮した設計
- 設定値の変数化
- エラー処理
- 衝突判定による不正な状態の防止
- ゲームオーバー時の適切なリセット
学びと気づき
AI とのゲーム開発を通じて、以下のような気づきがありました
- コード品質
- AIは一貫性のある命名規則を使用
- 適切なコメントと関数の分割
- 実装の効率
- 基本機能から段階的に実装
- 既存のベストプラクティスの活用
- 改善の余地
- スコアシステムの拡張
- エフェクトやサウンドの追加
- モバイル対応のタッチ操作
まとめ
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;
}