ChatGPTでブラウザゲームを制作することが出来るのは結構知られていますが、一体どの程度のクオリティの物を作れるのか。挑戦してみる事にしました。
ChatGPTが制作したシューティングゲームは以下のURLからプレイいただけます。
https://aokikotori.com/shoot/
リリースに至るまでの流れは以下の通りです。
ChatGPTへの発注書
以下の要件でWebブラウザで動作するシューティングゲームを作ってください。
・ゲーム画面
HTMLとCSSを使って、ゲーム画面を作成してください。
幅800px・高さ640pxとします。
・配置するキャラクター
プレイヤー:操作する
敵:弾を発射し、プレイヤーを妨害する
ボス:弾を発射し、プレイヤーの前に立ちふさがる
アイテム:取ると弾の威力が変わる
・基本動作
スペースキー:弾を撃つ
方向キー:移動する
・ゲームのルール
プレイヤーが敵の放つ弾に当たるとライフが1減ります。
ライフが0になるか、プレイヤーが敵に触れるとゲームオーバーにしてください。
・ボスの出現
敵を何匹か倒したら、ボスを出現させてください。ボスはプレイヤーの攻撃に対する耐性が高く、何回か弾を当てる必要がある様にしてください。
ボスを倒すとゲームクリア。次のステージへと進めます。
※ 動作確認環境はGoogle Chromeで行います。
デバッグ
上記の発注書を元に、ChatGPT(GPT-4)との長きやり取りが行われました。
不具合発生時どこがおかしいのか、ある程度分かる部分もあるのですが、「あえて」私は手を出さずに、ChatGPTのデバッグを信じる事にしました。不具合の修正および仕様の追加の殆どは、AI任せというわけです。
つまり
プログラムのプの字ぐらいしか知らない人が、ChatGPTの手を借りてシューティングゲームを完成させられるのか
その実験なのですからね。
1回目の納品における修正依頼
・敵が弾を発射しません。攻撃する様にしてください。
・ボスが弱すぎます。ボスは行動パターンを変更してください。
・カーソルキーは左右押しっぱなしで、スムーズに動かせる様にしてください。
2回目の納品における修正依頼
・プレイヤーの弾がキーを押していないのに出続けてしまいます。
・パワーアップアイテムがある時と無い時で動作に違いがありません。
・ゲームクリア画面でフリーズします。
3回目の納品における修正依頼
・弾が出続ける不具合が直っていませんので、修正お願いします。
・ゲームクリア画面でフリーズする不具合が直っていませんので、修正お願いします。
・プレイヤーの大きさが変わっています。
・ボスが大きすぎるのでサイズを小さく調整してください。
4回目の納品における修正依頼
エラーが出ました。
Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
at new Player ((index):36:6)
at (index):120:20
5回目の納品における修正依頼
エラーが出ました。
Uncaught ReferenceError: Entity is not defined
at (index):34:23
6回目の納品における修正依頼
カーソルを押した瞬間にエラーが出ます。
(index):196 Uncaught TypeError: player.move is not a function
at gameLoop ((index):196:16)
7回目の納品における修正依頼
コードを忘れている気がします。以下のコードを元に開発をお願いします。
(6回目の納品時のソースを全文を貼り付ける)
8回目の納品における修正依頼
・パワーアップアイテムが消えました。実装し直してください。
・敵が直進しかしないので、動きにバリエーションを持たせてください。
9回目の納品における修正依頼
・プレイヤーが弾を撃てなくなりました。
10回目の納品における修正依頼
・プレイヤーが弾を撃てない不具合が修正されていません。
11回目の納品における修正依頼
(別の仕様を修正し始めたので)
・いえ。そもそも弾を撃てないのです。スペースキーが反応しません。
12回目の納品における修正依頼
・変わっていません。コードを再確認してください。
(8回目の納品時のソースを全文貼り付ける)
13回目の納品における修正依頼
・弾に当たってゲームオーバーになる時とならない時があります。
14回目の納品における修正依頼
・プレイヤーの弾が下向きに、敵の弾が上向きに出ます。逆にしてください。
15回目の納品における修正依頼
・spawnBullet()関数がそもそも見当たりません。
16回目の納品における修正依頼
・fireBullet()関数も無い様です。一度今のコードを貼ってみますね。
(勝手に関数を作り始めたので、コードを貼り付ける)
17回目の納品における修正依頼
・スペースキーを押した時に、ゲームは継続しますが以下のエラーが発生します。
Uncaught ReferenceError: spaceKeyPressed is not defined
at HTMLDocument.
18回目の納品における修正依頼
・次のステージの難易度が、前のステージと変わらない様です。
19回目の納品における修正依頼
・スタート時に下記のエラーが発生してフリーズします。
Uncaught TypeError: Cannot read properties of undefined (reading 'isEnemyBullet')
at gameLoop ((index):183:16)
20回目の納品における修正依頼
・ゲームがスタートしません。弾は撃てますが動けませんし敵の出現もありません。
21回目の納品における修正依頼
・以下のソースコードにおいて、2ステージ目のボスが消えています。場所がリセットされていないものと思われます。
(一度し切り直し。New Chatでソースコードを貼り付ける)
22回目の納品における修正依頼
・ボスをやっつけても、消えないシンボルが残り続けます。フラッシュして消滅する仕様を追加出来ますか。
23回目の納品における修正依頼
・ボスが下方向にしか弾を撃たないので、まずプレイヤーに当たりません。一度に撃てる弾数を増やし、射出方向をランダムに出来ますか。
24回目の納品における修正依頼
・プレイヤーが弾を撃てなくなりました。
25回目の納品における修正依頼
・ありがとう!プレイヤー、敵キャラ、ボスキャラ、パワーアップアイテムをそれぞれ、オリジナルの画像にすることは出来ますか。
(必要なソースコードのみ教えてもらい、画像はフリー素材などから筆者が用意)
26回目の納品における修正依頼
・操作マニュアルを作成してください。
完成
この1〜26回目の間にも何度も以下の状況に見舞われました。
・途中でコードが切れる
・フリーズする
・エラーが発生する
・関数の名前が変わっている etc..
合計で80回近くのやり取りが行われていると思います。
それでもどうにか無事、ゲームのβ版をリリースする事が出来る段階になりました。
ChatGPTが制作したシューティングゲーム、
名付けて「Shoot!」は以下からプレイいただけます。
https://aokikotori.com/shoot/
ソースコード
次の4つの画像を準備してください。
player.png:プレイヤーキャラ
enemy.png:敵キャラ
boss.png:ボスキャラ
powerup.png:アイテム
デモではフリー素材を編集して使いました。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>シューティングゲーム</title>
<style>
body {
font-family: Arial, sans-serif;
}
canvas {
display: block;
margin: 0 auto;
background-color: #000;
}
</style>
</head>
<body>
<canvas id="game" width="800" height="640"></canvas>
<script>
// キャンバスの設定
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
// ボタン要素を取得
const startButton = document.getElementById('startButton');
// ゲームが開始されたかどうかを示す変数
let gameStarted = false;
// ゲームを開始する関数
function startGame() {
gameStarted = true;
startButton.style.display = 'none'; // スタートボタンを非表示にする
gameLoop();
}
// クリックイベントリスナーを追加
startButton.addEventListener('click', startGame);
// ゲームオブジェクトの基本クラス
class GameObject {
constructor(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
// オブジェクトの描画
draw(color) {
ctx.fillStyle = color;
ctx.fillRect(this.x, this.y, this.width, this.height);
// ステージ数を表示
ctx.font = "16px Arial";
ctx.fillStyle = "white";
ctx.fillText("Stage: " + currentStage, 90, 25);
}
}
// プレイヤークラス
const playerImage = new Image();
playerImage.src = 'player.png';
class Player extends GameObject {
constructor(x, y) {
super(x, y, 30, 30);
this.lives = 3;
this.poweredUp = false;
}
draw() {
ctx.drawImage(playerImage, this.x, this.y, this.width, this.height);
}
// プレイヤーの移動
move(dx) {
this.x += dx;
if (this.x < 0) this.x = 0;
if (this.x > canvas.width - this.width) this.x = canvas.width - this.width;
}
}
// 弾クラス
class Bullet extends GameObject {
constructor(x, y, width, height, vx, vy, isEnemyBullet = false) {
super(x, y, width, height);
this.vx = vx;
this.vy = vy;
this.isEnemyBullet = isEnemyBullet;
}
// 弾の更新
update() {
this.x += this.vx;
this.y += this.vy;
}
}
// 敵クラス
const enemyImage = new Image();
enemyImage.src = 'enemy.png';
class Enemy extends GameObject {
constructor(x, y) {
super(x, y, 30, 30);
this.speed = 2;
this.shootCounter = 0;
this.moveDirection = Math.random() < 0.5 ? -1 : 1;
}
draw() {
ctx.drawImage(enemyImage, this.x, this.y, this.width, this.height);
}
// 敵の更新
update() {
this.y += this.speed;
this.x += Math.sin(this.y / 50) * this.moveDirection * 2;
this.shootCounter++;
if (this.shootCounter >= 120) {
bullets.push(new Bullet(this.x + this.width / 2 - 2.5, this.y + this.height, 4, 10, 0, 4, true));
this.shootCounter = 0;
}
}
}
// ボスクラス
const bossImage = new Image();
bossImage.src = 'boss.png';
class Boss extends GameObject {
constructor(x, y) {
super(x, y, 80, 80);
this.speed = 1;
this.health = 20; // ボスのHP
this.shootCounter = 0;
this.defeated = false;
this.flashCounter = 0;
}
draw() {
if (!this.defeated || (this.defeated && this.flashCounter < 30 && this.flashCounter % 2 === 0)) {
ctx.drawImage(bossImage, this.x, this.y, this.width, this.height);
}
if (this.defeated && this.flashCounter < 30) {
this.flashCounter++;
}
}
// ボスの更新
update() {
if (this.y < canvas.height / 4) {
this.y += this.speed;
}
this.shootCounter++;
if (this.shootCounter >= 60) {
bullets.push(new Bullet(this.x + this.width / 2 - 2.5, this.y + this.height, 4, 10, 0, 4, true));
this.shootCounter = 0;
}
}
}
// パワーアップクラス
const powerUpImage = new Image();
powerUpImage.src = 'powerup.png';
class PowerUp extends GameObject {
constructor(x, y) {
super(x, y, 30, 30);
this.speed = 2;
}
draw() {
ctx.drawImage(powerUpImage, this.x, this.y, this.width, this.height);
}
update() {
this.y += this.speed;
}
}
// キャラクターやオブジェクトの初期化
const player = new Player(canvas.width / 2 - 15, canvas.height - 50);
const bullets = [];
let enemies = [];
const boss = new Boss(canvas.width / 2 - 40, -100);
const powerUps = [];
// ゲームの状態
let gameOver = false;
let gameCleared = false;
let bossAppeared = false;
let enemySpawnCounter = 0;
let bossSpawnThreshold = 20;
let powerUpDuration = 0;
let currentStage = 1;
let difficultyFactor = 1;
let enemySpawnTimer = 0;
let clearFrameCounter = 0;
let frameCounter = 0;
// キー入力の状態
let leftKeyPressed = false;
let rightKeyPressed = false;
let spaceKeyPressed = false;
// 敵を生成
function spawnEnemy() {
const x = Math.random() * (canvas.width - 30);
enemies.push(new Enemy(x, -30));
}
// パワーアップを生成
function spawnPowerUp(x, y) {
powerUps.push(new PowerUp(x, y));
}
// 衝突判定
function detectCollision(obj1, obj2) {
const left1 = obj1.x;
const left2 = obj2.x;
const right1 = obj1.x + obj1.width;
const right2 = obj2.x + obj2.width;
const top1 = obj1.y;
const top2 = obj2.y;
const bottom1 = obj1.y + obj1.height;
const bottom2 = obj2.y + obj2.height;
if (left1 < right2 && right1 > left2 && top1 < bottom2 && bottom1 > top2) {
return true;
}
return false;
}
// プレイヤーの残機を表示
function drawLives() {
ctx.fillStyle = '#0f0';
ctx.font = '18px sans-serif';
ctx.fillText(`Lives: ${player.lives}`, 10, 25);
}
// ゲームループ
function gameLoop() {
frameCounter++;
if (!gameStarted) {
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
player.draw('#0f0');
drawLives();
for (const bullet of bullets) {
bullet.update();
bullet.draw(bullet.isEnemyBullet ? '#f00' : '#ff0');
}
for (const powerUp of powerUps) {
powerUp.update();
powerUp.draw('#0ff');
}
bullets.forEach((bullet, index) => {
if (bullet.isEnemyBullet && detectCollision(bullet, player)) {
bullets.splice(index, 1);
index--;
player.lives--;
if (player.lives <= 0) {
gameOver = true;
}
} else if (!bullet.isEnemyBullet) {
for (let enemyIndex = 0; enemyIndex < enemies.length; enemyIndex++) {
const enemy = enemies[enemyIndex];
if (detectCollision(bullet, enemy)) {
bullets.splice(index, 1);
index--;
if (Math.random() < 0.05) {
spawnPowerUp(enemy.x, enemy.y);
}
enemies.splice(enemyIndex, 1);
bossSpawnThreshold--;
break;
}
}
if (bossAppeared && detectCollision(bullet, boss)) {
bullets.splice(index, 1);
index--;
boss.health--;
if (boss.health <= 0) {
gameCleared = true;
}
}
}
if (bullet.y < 0 || bullet.y > canvas.height) {
bullets.splice(index, 1);
index--;
}
});
powerUps.forEach((powerUp, index) => {
if (detectCollision(powerUp, player)) {
powerUps.splice(index, 1);
player.poweredUp = true;
powerUpDuration = 300;
}
});
for (const enemy of enemies) {
enemy.update();
enemy.draw('#f00');
if (detectCollision(enemy, player)) {
player.lives--;
if (player.lives <= 0) {
gameOver = true;
}
}
}
// ボスの弾を生成
if (bossAppeared && !boss.defeated && frameCounter % 30 === 0) {
const numBullets = 3;
const playerAngle = Math.atan2(player.y - boss.y, player.x - boss.x);
const angleSpread = Math.PI / 2;
for (let i = 0; i < numBullets; i++) {
const angle = playerAngle - angleSpread / 2 + i * (angleSpread / (numBullets - 1));
const speed = 1 + Math.random() * 3;
bullets.push(new Bullet(boss.x + boss.width / 2 - 2, boss.y + boss.height, 4, 10, Math.cos(angle) * speed, Math.sin(angle) * speed, true));
}
}
if (bossAppeared) {
boss.update();
boss.draw('#a0a');
if (detectCollision(boss, player)) {
player.lives--;
if (player.lives <= 0) {
gameOver = true;
}
}
}
enemySpawnCounter++;
if (enemySpawnCounter >= (powerUpDuration > 0 ? 30 : 60)) {
enemySpawnCounter = 0;
if (!bossAppeared && bossSpawnThreshold > 0) {
spawnEnemy();
}
}
if (bossSpawnThreshold <= 0 && !bossAppeared) {
bossAppeared = true;
}
if (powerUpDuration > 0) {
powerUpDuration--;
if (powerUpDuration === 0) {
player.poweredUp = false;
}
}
if (leftKeyPressed) {
player.move(-5);
}
if (rightKeyPressed) {
player.move(5);
}
// ボスの撃破判定
if (boss.health <= 0 && !boss.defeated) {
boss.defeated = true;
setTimeout(() => {
gameCleared = true;
}, 3000); // ボスがフラッシュし終わるまで3秒待つ
}
if (enemies.length === 0 || enemySpawnTimer <= 0) {
const x = Math.random() * (canvas.width - 30);
const y = Math.random() * (canvas.height / 2 - 30);
const enemy = new Enemy(x, y);
enemy.speed *= difficultyFactor;
enemies.push(enemy);
// ステージが進むごとに敵が早く出現する
enemySpawnTimer = (100 / difficultyFactor);
} else {
enemySpawnTimer--;
}
if (gameOver) {
ctx.fillStyle = '#f00';
ctx.font = '48px sans-serif';
ctx.fillText('GAME OVER', canvas.width / 2 - 140, canvas.height / 2);
return;
}
if (gameCleared) {
ctx.fillStyle = '#0f0';
ctx.font = '48px sans-serif';
ctx.fillText('CLEAR', canvas.width / 2 - 70, canvas.height / 2 - 50);
setTimeout(() => {
gameCleared = false;
}, 3000);
}
if (boss.defeated) {
clearFrameCounter++;
// 点滅時間が終了したら次のステージに移行します。
if (clearFrameCounter >= 3 * 60) {
currentStage++;
difficultyFactor = 1 + currentStage * 0.1;
player.lives = 3;
boss.health = 10 + currentStage * 10;
bossSpawnThreshold = 20 + currentStage * 10;
bossAppeared = false;
clearFrameCounter = 0;
// ボスの初期位置をリセット
boss.x = canvas.width / 2 - 40;
boss.y = -100;
boss.defeated = false;
boss.flashCounter = 0;
gameCleared = false;
enemies = [];
}
}
requestAnimationFrame(gameLoop);
}
gameLoop();
document.addEventListener('keydown', (event) => {
if (event.key === 'ArrowLeft') {
leftKeyPressed = true;
} else if (event.key === 'ArrowRight') {
rightKeyPressed = true;
//} else if (event.key === ' ' && !gameOver && !gameCleared) {
} else if (event.key === ' ' && !gameOver) {
if (!spaceKeyPressed) {
spaceKeyPressed = true;
if (player.poweredUp) {
bullets.push(
new Bullet(player.x + player.width / 2 - 12.5, player.y, 4, 10, 0, -8)
);
bullets.push(
new Bullet(player.x + player.width / 2 + 7.5, player.y, 4, 10, 0, -8)
);
} else {
bullets.push(
new Bullet(player.x + player.width / 2 - 2.5, player.y, 4, 10, 0, -8)
);
}
}
}
});
document.addEventListener('keyup', (event) => {
if (event.key === 'ArrowLeft') {
leftKeyPressed = false;
} else if (event.key === 'ArrowRight') {
rightKeyPressed = false;
} else if (event.key === ' ') {
spaceKeyPressed = false;
}
});
</script>
</body>
</html>
反省点
2023年3月21日現在、3時間で25回という質問制限に耐えつつ、約20時間程度で満足のいくものが完成しました。
いきなり多機能で依頼するのでは無く、「とりあえず必要最低限のものを作る」ところから始めた方が良さそうです。
シューティングゲームの場合は
・プレイヤーキャラを方向キーで左右に動かす
・プレイヤーキャラがスペースキーで弾を発射する
まずはここから始めた方が良いと思いました。
修正依頼に関しては、不具合の詳細を出来るだけ深く深く書くのが望ましい様に感じます。
また、分かる人は、コードの「この部分が怪しい」という文も添えておくと、より的確にバグを潰してくれるかも知れませんね。一発でエラーを直してくれることは結構少ないので、1つのエラーに3〜4回のやり取りは発生しがちです。
そして最も重要なのは、まともに動作しているバージョンを残すこと。
ひたすら1つのコードファイルに上書きして行くと、動かなくなった時が大変です。どこを書き換えたのか、分からなくなってしまう恐れがあります。ある程度動くものが出来たらバックアップし、バージョン名で管理しておきましょう。