AI

【出来上がるまでのプロンプトを公開!】ChatGPTでシューティングゲームは作成できるか?

前回、ちょっした便利ツールを作成するのに生成AIが役に立つという記事を書きました。

今回は唐突ですが、生成AIを使ってシューティングゲームを作っていきたいと思います。

なぜ、シューティングゲームを作ろうと思ったかというと、ラフなポンチ絵(というか落書き)をChatGPTに渡したら簡単にシューティングゲームが作成できたという内容のXのポストを見たからです。

ということで、ipadで超適当に描いた以下のような絵を渡してシューティングゲームを作ってくださいとChatGPT(GPT4)に指示してみました。

ゲームイメージゲームイメージ

プロンプトの内容は、「pythonで以下のようなイメージのシューティングゲームを作成してください。 不明点があれば聞いてください。」です。(今回はあえて、細かい指示、役割などは与えないで大雑把に指示しました。)

本当にこんな絵を元にしてゲームが作成できるのでしょうか?

本記事ではそれなりに遊べるシューティングゲームが出来上がるまでの過程を入力したプロンプトと共に紹介します。

ソース生成

とりあえず動くけれども・・

初回のやり取りは、以下のようになりました。(冒頭のみ)

「日本語で回答してください」と指示しないとこのように英語で回答される事が多々あります。再度、日本語で回答するように指示をしました。

AIは日本語よりも英語が得意です。私は「プロンプトの内容を日本語から英語に翻訳して英語で解釈して回答し、それを日本語に翻訳して回答してください。こちらへの回答は最後の日本語のみとしてください」というようにAI自身には英語で考えさせて、やりとりのみを日本語で行うという指示をやることがあります。

今回、とりあえず動くソースコードを出力させるためには以下のような3回のやりとりが必要でした。(1回は日本語と英語の違いなので実質2回)

No 指示 入力したプロンプト 備考
1 仕様伝達 pythonで以下のようなイメージのシューティングゲームを作成してください。 不明点があれば聞いてください。 ※イメージを添付
2 回答形式指示 再度日本語で回答してください。 英語で返答されたため日本語指示
3 エラー修正指示 実行してもすぐに[Done] exited with code=0 in 0.74 secondsで終了してしまいます。画面も表示されません。

以後は、プロンプトの内容を表にして回答は省略します。

3回目の回答で生成されたソースコードは以下となります。

import pygame
import random

# Pygameの初期化
pygame.init()

# 画面のサイズ
screen_width = 800
screen_height = 600
screen = pygame.display.set_mode((screen_width, screen_height))

# 色の定義
white = (255, 255, 255)
black = (0, 0, 0)
red = (255, 0, 0)

# プレイヤー設定
player_width = 50
player_height = 60
player_speed = 5

# 敵設定
enemy_width = 50
enemy_height = 50
enemy_speed = 3

# ビーム設定
beam_width = 5
beam_height = 10
beam_speed = 7

# プレイヤー画像の読み込み
player_img = pygame.Surface((player_width, player_height))
player_img.fill(white)  # 仮のプレイヤー画像として白い四角を使用

# 敵画像の読み込み
enemy_img = pygame.Surface((enemy_width, enemy_height))
enemy_img.fill(red)  # 仮の敵画像として赤い四角を使用

# ゲームの変数
player_x = screen_width // 2
player_y = screen_height - player_height - 10
player_lives = 3
score = 0

# ビームと敵のリスト
beams = []
enemies = []

# フォント設定
font = pygame.font.Font(None, 36)

# ゲームループ
running = True
clock = pygame.time.Clock()

while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
    
    # プレイヤーの移動
    keys = pygame.key.get_pressed()
    if keys[pygame.K_LEFT] and player_x > 0:
        player_x -= player_speed
    if keys[pygame.K_RIGHT] and player_x < screen_width - player_width:
        player_x += player_speed
    if keys[pygame.K_SPACE]:
        beams.append([player_x + player_width // 2, player_y])
    
    # ビームの位置更新
    for beam in beams:
        beam[1] -= beam_speed
        if beam[1] < 0:
            beams.remove(beam)
    
    # 新しい敵を追加
    if random.randint(1, 20) == 1:
        enemies.append([random.randint(0, screen_width - enemy_width), 0])
    
    # 敵の位置更新
    for enemy in enemies:
        enemy[1] += enemy_speed
        if enemy[1] > screen_height:
            enemies.remove(enemy)
            player_lives -= 1
    
    # 衝突判定
    for enemy in enemies:
        for beam in beams:
            if (enemy[0] < beam[0] < enemy[0] + enemy_width and
                    enemy[1] < beam[1] < enemy[1] + enemy_height):
                enemies.remove(enemy)
                beams.remove(beam)
                score += 10
    
    # 画面の描画
    screen.fill(black)
    
    # プレイヤーの描画
    screen.blit(player_img, (player_x, player_y))
    
    # ビームの描画
    for beam in beams:
        pygame.draw.rect(screen, white, (beam[0], beam[1], beam_width, beam_height))
    
    # 敵の描画
    for enemy in enemies:
        screen.blit(enemy_img, (enemy[0], enemy[1]))
    
    # スコアとライフの描画
    score_text = font.render(f'Score: {score}', True, white)
    lives_text = font.render(f'Lives: {player_lives}', True, white)
    screen.blit(score_text, (10, 10))
    screen.blit(lives_text, (10, 50))
    
    pygame.display.flip()
    clock.tick(60)

pygame.quit()

 

実行して出来上がった「シューティングゲーム」(鉤かっこ付き・・)は以下のようになりました。(遊んだものを録画しました。)

 

※pygameというライブラリを使用しているので、回答にあるようにpip install pygameでpygameをインストールします。

確かにシューティングゲームっぽくはなっていますが、こちらが送った画面イメージとは全く違います。(そもそもスクロール方向が違う・・画面イメージに意味があったのだろうか・・)
その他にもライフ数の減り方などツッコミどころが多々あります。

少なくともXの「こんなイメージを渡したらシューティングゲームが簡単に出来ました!」で見たシューティングゲームの画面とはだいぶ違いました・・

とりあえずシューティングゲームと呼べるものを作成

「結論、流石にそんな一瞬でシューティングゲームはできない。以上終了」でも良いのですが、もう少しまともな状態にすべく以下のようなプロンプトを入力してソースを修正させました。

No 指示 入力したプロンプト 備考
4 仕様伝達 以下の修正をお願いします。
1.縦スクロールではなく横スクロールにしてください。
2.自分の機体は上下左右をカーソルで移動できるようにしてください。
3.敵もビームを撃ってください。
4.LIFEが敵が自分の機体を過ぎ去った場合に減っていますが、LIFEが減るのは以下の場合としてください。
・敵が自分の機体と衝突した場合
・敵のビームが自分の機体に当たった場合
5.ゲームの終了条件は以下のとおりです。 LIFEが0になる。
6.終了になったらGAME OVERと表示してください。
そこでリトライするかゲーム自体を終了するかのボタンを表示して、リトライを押せばもう一度、ゲームを開始して(LIFEは3から開始)、終了ボタンを押すとゲームのプログラム自体を終了してください。
5 仕様伝達
以下の修正してください。
ゲームオーバー時に「GAME OVER」と表示し、リトライまたは終了のボタンを画面上に表示する。 そのボタンをカーソルで選択してスペースを押すことにリトライまたは終了する。
6 仕様伝達 以下の修正をしてください。
(修正前) そのボタンをカーソルで選択してスペースを押すことにリトライまたは終了する。
(修正後) そのボタンをカーソルで選択してスペース、またはリターンを押すことにリトライまたは終了する。
7 エラー修正指示 以下のようなエラーが発生して異常終了する場合があるので原因を突き止めて改善してください。
enemies.remove(enemy) ValueError: list.remove(x): x not in list
修正はされましたが、非常に良くない刹那的な修正方法だったので後々、書き換えました。

このやりとりで生成されたソースコードは以下となります。

import pygame
import random

# Pygameの初期化
pygame.init()

# 画面のサイズ
screen_width = 800
screen_height = 600
screen = pygame.display.set_mode((screen_width, screen_height))

# 色の定義
white = (255, 255, 255)
black = (0, 0, 0)
red = (255, 0, 0)
green = (0, 255, 0)
blue = (0, 0, 255)

# プレイヤー設定
player_width = 50
player_height = 60
player_speed = 5

# 敵設定
enemy_width = 50
enemy_height = 50
enemy_speed = 3
enemy_beam_speed = 5

# ビーム設定
beam_width = 5
beam_height = 10
beam_speed = 7

# プレイヤー画像の読み込み
player_img = pygame.Surface((player_width, player_height))
player_img.fill(white)  # 仮のプレイヤー画像として白い四角を使用

# 敵画像の読み込み
enemy_img = pygame.Surface((enemy_width, enemy_height))
enemy_img.fill(red)  # 仮の敵画像として赤い四角を使用

# ゲームの変数
player_x = 10
player_y = screen_height // 2 - player_height // 2
player_lives = 3
score = 0

# ビームと敵のリスト
beams = []
enemy_beams = []
enemies = []

# フォント設定
font = pygame.font.Font(None, 36)

# ボタンの設定
button_font = pygame.font.Font(None, 48)
retry_button_rect = pygame.Rect(screen_width // 2 - 100, screen_height // 2, 200, 50)
quit_button_rect = pygame.Rect(screen_width // 2 - 100, screen_height // 2 + 60, 200, 50)
selected_button = 'retry'

def show_game_over():
    screen.fill(black)
    game_over_text = font.render("GAME OVER", True, white)
    screen.blit(game_over_text, (screen_width // 2 - 100, screen_height // 2 - 50))

    # リトライボタン
    pygame.draw.rect(screen, green if selected_button == 'retry' else white, retry_button_rect)
    retry_text = button_font.render("Retry", True, black)
    screen.blit(retry_text, (retry_button_rect.x + 50, retry_button_rect.y + 5))

    # 終了ボタン
    pygame.draw.rect(screen, green if selected_button == 'quit' else white, quit_button_rect)
    quit_text = button_font.render("Quit", True, black)
    screen.blit(quit_text, (quit_button_rect.x + 60, quit_button_rect.y + 5))

    pygame.display.flip()

def reset_game():
    global player_x, player_y, player_lives, score, beams, enemy_beams, enemies
    player_x = 10
    player_y = screen_height // 2 - player_height // 2
    player_lives = 3
    score = 0
    beams = []
    enemy_beams = []
    enemies = []

# ゲームループ
running = True
game_over = False
clock = pygame.time.Clock()

while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if game_over:
            if event.type == pygame.KEYDOWN:
                if event.key == pygame.K_UP or event.key == pygame.K_DOWN:
                    selected_button = 'retry' if selected_button == 'quit' else 'quit'
                if event.key == pygame.K_SPACE or event.key == pygame.K_RETURN:
                    if selected_button == 'retry':
                        reset_game()
                        game_over = False
                    elif selected_button == 'quit':
                        running = False
    
    if not game_over:
        # プレイヤーの移動
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT] and player_x > 0:
            player_x -= player_speed
        if keys[pygame.K_RIGHT] and player_x < screen_width - player_width:
            player_x += player_speed
        if keys[pygame.K_UP] and player_y > 0:
            player_y -= player_speed
        if keys[pygame.K_DOWN] and player_y < screen_height - player_height:
            player_y += player_speed
        if keys[pygame.K_SPACE]:
            beams.append([player_x + player_width, player_y + player_height // 2])
        
        # ビームの位置更新
        for beam in beams[:]:
            beam[0] += beam_speed
            if beam[0] > screen_width:
                beams.remove(beam)
        
        # 新しい敵を追加
        if random.randint(1, 20) == 1:
            enemies.append([screen_width, random.randint(0, screen_height - enemy_height)])
        
        # 敵の位置更新
        for enemy in enemies[:]:
            enemy[0] -= enemy_speed
            if random.randint(1, 50) == 1:
                enemy_beams.append([enemy[0], enemy[1] + enemy_height // 2])
            if enemy[0] < 0:
                enemies.remove(enemy)
        
        # 敵ビームの位置更新
        for enemy_beam in enemy_beams[:]:
            enemy_beam[0] -= enemy_beam_speed
            if enemy_beam[0] < 0:
                enemy_beams.remove(enemy_beam)
        
        # 衝突判定
        for enemy in enemies[:]:
            for beam in beams[:]:
                if (enemy[0] < beam[0] < enemy[0] + enemy_width and
                        enemy[1] < beam[1] < enemy[1] + enemy_height):
                    if enemy in enemies and beam in beams:
                        enemies.remove(enemy)
                        beams.remove(beam)
                        score += 10
            if (enemy[0] < player_x + player_width and
                    enemy[0] + enemy_width > player_x and
                    enemy[1] < player_y + player_height and
                    enemy[1] + enemy_height > player_y):
                if enemy in enemies:
                    enemies.remove(enemy)
                player_lives -= 1
                if player_lives == 0:
                    game_over = True
        
        for enemy_beam in enemy_beams[:]:
            if (enemy_beam[0] < player_x + player_width and
                    enemy_beam[0] + beam_width > player_x and
                    enemy_beam[1] < player_y + player_height and
                    enemy_beam[1] + beam_height > player_y):
                if enemy_beam in enemy_beams:
                    enemy_beams.remove(enemy_beam)
                player_lives -= 1
                if player_lives == 0:
                    game_over = True
        
        # 画面の描画
        screen.fill(black)
        
        # プレイヤーの描画
        screen.blit(player_img, (player_x, player_y))
        
        # ビームの描画
        for beam in beams:
            pygame.draw.rect(screen, white, (beam[0], beam[1], beam_width, beam_height))
        
        # 敵の描画
        for enemy in enemies:
            screen.blit(enemy_img, (enemy[0], enemy[1]))
        
        # 敵ビームの描画
        for enemy_beam in enemy_beams:
            pygame.draw.rect(screen, red, (enemy_beam[0], enemy_beam[1], beam_width, beam_height))
        
        # スコアとライフの描画
        score_text = font.render(f'Score: {score}', True, white)
        lives_text = font.render(f'Lives: {player_lives}', True, white)
        screen.blit(score_text, (10, 10))
        screen.blit(lives_text, (10, 50))
        
        pygame.display.flip()
        clock.tick(60)
    else:
        show_game_over()

pygame.quit()

 

実行して出来上がったシューティングゲーム(今度は鉤かっこ付きでなくてもいいでしょう・・)は以下のようになりました。

 

面白いかどうかはさておき、それっぽく遊べるレベルにはなったのではないかと思います。

機体を画像に変更・画像生成

ここからは、当初の目的(AIで簡単なイメージ画像からシューティングゲームが作れるか)からは逸脱して、数回のやりとりでちゃんとしたゲームを作れるかどうかを検証していきたいと思います。

まずは機体が四角図形では味気ないので機体の画像をChatGPTに作成してもらおうとしました。

No 指示 入力したプロンプト 備考
8 仕様伝達/画像生成依頼 プレイヤー画像と敵画像を読み込むようにしてください。またプレイヤー画像と敵画像を作成してください。
9 画像生成依頼
プレイヤー画像、敵画像をそれぞれ1画像ずつ別に作成してください。 またゲームの画面上の仕様(横スクロール)を考慮してプレイヤー画像は右向き、敵画像は左向きにしてください。
10 画像生成依頼 プレイヤー画像のみを作成してください。右向きにしてください。
11 画像生成依頼 プレイヤー画像のみを作成してください。右向きにしてください。機体以外の背景は削除して透過にしてください。 透過風の透過じゃない画像が生成されるだけでした・・
12 画像生成依頼 敵画像のみを作成してください。左向きにしてください。機体以外の背景は削除して透過にしてください。 同上
13 画像生成依頼
再度、敵画像のみを作成してください。左向きにしてください。真横からの絵にしてください。機体以外の背景は削除して透過にしてください。 なお、コードに変更がない場合はコードの再提示は不要です。
同上

以上のやりとりからわかるように、ゲーム用のイメージと指示してもなかなかうまくいかなく、生成される画像は以下のようなものでした。

最終的には、生成した画像から別アプリを使用して1つの機体を切り取って背景を削除して(macのデフォルト機能で可能)、以下のような画像を作成しました。

ゲームイメージ

爆発画像の追加など

さらにビームが当たった場合などの爆発画像を追加しました。以下のようなやり取りになります。

No 指示 入力したプロンプト 備考
14 仕様伝達 自分のビームが敵に当たった場合、敵や敵のビームに自分に当たった場合は、爆発した画像に 入力途中で誤って送信
15 画像生成依頼 爆発画像も生成してください。コードに変更がない場合はコードの再生成は不要です。
16 仕様伝達 爆発画像の位置は当たった場所(てき 入力途中で誤って送信
17 エラー修正指示
爆発エフェクトが画面の右下に表示されてしまいます。 場発エフェクトは、当たった位置に機体と同じ大きさで表示するようにしてください。 またプレイヤーだけでなく敵がビームに当たった場合も表示するようにしてください。l
18 仕様伝達 プレイヤー機がビームまたは敵に当たった場合は3秒スリープしてください。
19 仕様伝達 爆発エフェクトを表示した後にスリープするようにしてください。
20 仕様伝達 敵もしくは敵のビームに当たった場合、その状況で続くのではなく、ライフを減らした上で初期画面に戻ってゲームを再開させてください
21 仕様伝達 ゲーム終了時のRETRYとQUITボタンは、スペース、リターンに加えて、マウスポインタでもクリックできるようにしてください。

以後、区切りが良いところまでソースコード・ゲーム画面は省略します。

BGM・効果音追加

やはり音がないと寂しいので、BGMや爆発音を追加しました。

音の生成もChatGPTにやってもらおうとしましたが、出来ないと言われたので(知ってた)、音源を読み込むような仕組みだけ実装してもらって、音源ファイルはフリー素材から探して追加しました。

No 指示 入力したプロンプト 備考
22 仕様伝達 爆発時に効果音を出してください。
23 音声生成依頼 爆発音のファイルexplosion.wavを作成してください。
出来ないと断られたので、フリー素材から持ってきた。
24 仕様伝達
わかりました。音声はこちらで用意します。 以下の音声を読み込むようにコードを修正してください。 ・プレイヤー機の爆発音 ・敵機の爆発音 ・プレイヤー機のビーム発射音 ・BGM(常に裏で流れている)
25 仕様伝達 ファイルの拡張子はmp3にできますか?
26 仕様伝達 GAME OVERした場合は別のBGMに切り替えてゲームが再開したらbackground_music.mp3に戻ってください。
27 仕様伝達
以下、現状から修正に変更してください。
(現状) ゲームオーバーのBGM(game_over_music.mp3)がライフが0になった場合のみ爆発音の代わりに流れますが、ゲームオーバー画面では、通常のBGM(background_music.mp3)が流れている。
(修正) ライフが0になった場合(ゲームオーバーになった瞬間)は通常の 爆発音、ゲームオーバーでRETRY,QUITを選ぶ画面ではゲームオーバーのBGM(game_over_music.mp3)を流し続けてください。ゲームを再開した場合は通常のBGM(background_music.mp3)が流れている。を流してください。
28 デグレ修正指示 ライフが減らなくなってしまいました。またプレイヤー機の爆発音も流れません。

当たり判定の修正

機体を四角図形から画像に変更したことに伴って当たり判定を修正しました。(何故かこの修正にともなって全然関係ない箇所でデグレが発生しました。)

No 指示 入力したプロンプト 備考
29 仕様伝達
当たり判定を戦闘機が描写されている四角で判定しているため四角の中に描写されている戦闘機にあたっていなくても、四角の角が接触すると当たりと判定されてしまいます。画像の透明部分は当たりとせずに、四角の中の戦闘機が描写されている部分に当たった場合のみ当たりと判定させることはできますか?
30 デグレ修正指示 以下で異常終了するエラーが再発しました。
enemies.remove(enemy) ValueError: list.remove(x): x not in list
31 デグレ修正指示 衝突してもライフが減らなくなるエラーが再発しました。デグレーションが起きないように修正してください。

リファクタリング(定数化、冗長な処理の共通化など)

この時点でソースコードが「あまりにも・・」な感じだったので禁断のリファクタリングに手をつけてしまいました。(思えばこれが破滅への一歩でした・・)

No 指示 入力したプロンプト 備考
32 リファクタリング ソースコードにさらに詳細なコメントを追加してください。(変数の説明など)
33 デグレ修正指示 reset_gameの関数内に以下の不要なコードが追加されました。これのせいで、またライフが減らなくなってしまうのでは? player_lives = 3 score = 0
34 リファクタリング player_lives = 3というコードが何回も出てきます。3は固定なので定数と変数を分けて3を定数化してください。
35 リファクタリング 初期のスコア値(0)や変更されない値(画面の幅、スピードなど)も定数にしてください。
36 リファクタリング 敵にビームが当たった時のスコア10も定数にしてください
37 リファクタリング player_y,player_xの初期値の設定も最初とおreset_gameで2回出てくるので右辺を定数化してください
38 リファクタリング reset_game()の内容と同じ処理が冒頭に記載されています。起動時にもcallするようにして共通化して冒頭の記載は削除してください。
39 リファクタリング 以下の記載が最初とリトライボタン押下時に出てきて冗長です、
player_lives = INITIAL_LIVES # プレイヤーのライフ
score = INITIAL_SCORE # スコア
以下のように修正してください。
reset_gameにinitの引数を与えて 起動時、リトライ時はinit=trueを与えて呼び出して以下を実行
player_lives = INITIAL_LIVES # プレイヤーのライフ
score = INITIAL_SCORE # スコア
ゲームオーバー以外でプレイヤー機が衝突した場合はinit=Falseを与えてライフとスコアの初期化はしない
40 リファクタリング reset_gameへの引数の追加により冒頭の以下の処理は不要では?
# ゲームの変数
player_lives = INITIAL_LIVES # プレイヤーのライフ
score = INITIAL_SCORE # スコア

この時点(40回目のやり取り)で回答されたソースコードは以下のようになります。

import pygame
import random
import time

# Pygameの初期化
pygame.init()

# 画面の設定
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))

# 色の定義
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
RED = (255, 0, 0)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)

# 定数の定義
INITIAL_LIVES = 3  # プレイヤーの初期ライフ
INITIAL_SCORE = 0  # 初期スコア
SCORE_PER_HIT = 10  # 敵にビームが当たった時のスコア
PLAYER_WIDTH = 50
PLAYER_HEIGHT = 60
PLAYER_SPEED = 5
INITIAL_PLAYER_X = 10  # プレイヤーの初期X位置
INITIAL_PLAYER_Y = SCREEN_HEIGHT // 2 - PLAYER_HEIGHT // 2  # プレイヤーの初期Y位置
ENEMY_WIDTH = 50
ENEMY_HEIGHT = 50
ENEMY_SPEED = 3
ENEMY_BEAM_SPEED = 5
BEAM_WIDTH = 5
BEAM_HEIGHT = 10
BEAM_SPEED = 7
EXPLOSION_DURATION = 30  # 爆発が表示されるフレーム数

# 効果音の読み込み
player_explosion_sound = pygame.mixer.Sound('player_explosion.mp3')
enemy_explosion_sound = pygame.mixer.Sound('enemy_explosion.mp3')
beam_fire_sound = pygame.mixer.Sound('beam_fire.mp3')

# BGMの読み込み
BACKGROUND_MUSIC = 'background_music.mp3'
GAME_OVER_MUSIC = 'game_over_music.mp3'

# BGMの再生
pygame.mixer.music.load(BACKGROUND_MUSIC)
pygame.mixer.music.play(-1)  # ループ再生

# プレイヤー画像の読み込み
player_img = pygame.image.load('player.png')
player_img = pygame.transform.scale(player_img, (PLAYER_WIDTH, PLAYER_HEIGHT))

# プレイヤーのマスク作成(衝突判定用)
player_mask = pygame.mask.from_surface(player_img)

# 敵画像の読み込み
enemy_img = pygame.image.load('enemy.png')
enemy_img = pygame.transform.scale(enemy_img, (ENEMY_WIDTH, ENEMY_HEIGHT))

# 敵のマスク作成(衝突判定用)
enemy_mask = pygame.mask.from_surface(enemy_img)

# 爆発画像の読み込み
explosion_img = pygame.image.load('explosion.png')
explosion_img = pygame.transform.scale(explosion_img, (PLAYER_WIDTH, PLAYER_HEIGHT))

# ビームと敵のリスト
beams = []  # プレイヤーのビームを格納するリスト
enemy_beams = []  # 敵のビームを格納するリスト
enemies = []  # 敵を格納するリスト
explosions = []  # 爆発を格納するリスト

# フォント設定
font = pygame.font.Font(None, 36)  # スコアやライフ表示用のフォント

# ボタンの設定
button_font = pygame.font.Font(None, 48)  # ボタン用のフォント
retry_button_rect = pygame.Rect(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2, 200, 50)  # リトライボタンの位置とサイズ
quit_button_rect = pygame.Rect(SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 + 60, 200, 50)  # 終了ボタンの位置とサイズ
selected_button = 'retry'  # 初期選択ボタン

def show_game_over():
    """ゲームオーバー画面を表示する関数"""
    screen.fill(BLACK)
    game_over_text = font.render("GAME OVER", True, WHITE)
    screen.blit(game_over_text, (SCREEN_WIDTH // 2 - 100, SCREEN_HEIGHT // 2 - 50))

    # リトライボタン
    pygame.draw.rect(screen, GREEN if selected_button == 'retry' else WHITE, retry_button_rect)
    retry_text = button_font.render("Retry", True, BLACK)
    screen.blit(retry_text, (retry_button_rect.x + 50, retry_button_rect.y + 5))

    # 終了ボタン
    pygame.draw.rect(screen, GREEN if selected_button == 'quit' else WHITE, quit_button_rect)
    quit_text = button_font.render("Quit", True, BLACK)
    screen.blit(quit_text, (quit_button_rect.x + 60, quit_button_rect.y + 5))

    pygame.display.flip()

def reset_game(init=False):
    """ゲームの状態をリセットする関数"""
    global player_x, player_y, beams, enemy_beams, enemies, explosions, player_lives, score
    player_x = INITIAL_PLAYER_X
    player_y = INITIAL_PLAYER_Y
    beams = []
    enemy_beams = []
    enemies = []
    explosions = []
    if init:
        player_lives = INITIAL_LIVES  # リセット時にライフを元に戻す
        score = INITIAL_SCORE  # リセット時にスコアを元に戻す
    pygame.mixer.music.load(BACKGROUND_MUSIC)
    pygame.mixer.music.play(-1)  # ゲーム再開時に元のBGMを再生

def draw_explosions():
    """爆発エフェクトを描画する関数"""
    for explosion in explosions[:]:
        screen.blit(explosion_img, (explosion[0], explosion[1]))
        explosion[2] -= 1
        if explosion[2] <= 0:
            explosions.remove(explosion)
    pygame.display.flip()

def check_collision(mask1, x1, y1, mask2, x2, y2):
    """マスクを使用して衝突判定を行う関数"""
    offset_x = x2 - x1
    offset_y = y2 - y1
    return mask1.overlap(mask2, (offset_x, offset_y)) is not None

# ゲームループの初期化
running = True
game_over = False
clock = pygame.time.Clock()

# ゲームの初期化
reset_game(init=True)

while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.MOUSEBUTTONDOWN and game_over:
            mouse_pos = event.pos
            if retry_button_rect.collidepoint(mouse_pos):
                reset_game(init=True)
                game_over = False
            elif quit_button_rect.collidepoint(mouse_pos):
                running = False
        if game_over:
            if event.type == pygame.KEYDOWN:
                if event.key == pygame.K_UP or event.key == pygame.K_DOWN:
                    selected_button = 'retry' if selected_button == 'quit' else 'quit'
                if event.key == pygame.K_SPACE or event.key == pygame.K_RETURN:
                    if selected_button == 'retry':
                        reset_game(init=True)
                        game_over = False
                    elif selected_button == 'quit':
                        running = False
    
    if not game_over:
        # プレイヤーの移動
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT] and player_x > 0:
            player_x -= PLAYER_SPEED
        if keys[pygame.K_RIGHT] and player_x < SCREEN_WIDTH - PLAYER_WIDTH:
            player_x += PLAYER_SPEED
        if keys[pygame.K_UP] and player_y > 0:
            player_y -= PLAYER_SPEED
        if keys[pygame.K_DOWN] and player_y < SCREEN_HEIGHT - PLAYER_HEIGHT:
            player_y += PLAYER_SPEED
        if keys[pygame.K_SPACE]:
            beams.append([player_x + PLAYER_WIDTH, player_y + PLAYER_HEIGHT // 2])
            beam_fire_sound.play()  # ビーム発射音を再生
        
        # ビームの位置更新
        for beam in beams[:]:
            beam[0] += BEAM_SPEED
            if beam[0] > SCREEN_WIDTH:
                beams.remove(beam)
        
        # 新しい敵を追加
        if random.randint(1, 20) == 1:
            enemies.append([SCREEN_WIDTH, random.randint(0, SCREEN_HEIGHT - ENEMY_HEIGHT)])
        
        # 敵の位置更新
        for enemy in enemies[:]:
            enemy[0] -= ENEMY_SPEED
            if random.randint(1, 50) == 1:
                enemy_beams.append([enemy[0], enemy[1] + ENEMY_HEIGHT // 2])
            if enemy[0] < 0:
                enemies.remove(enemy)
        
        # 敵ビームの位置更新
        for enemy_beam in enemy_beams[:]:
            enemy_beam[0] -= ENEMY_BEAM_SPEED
            if enemy_beam[0] < 0:
                enemy_beams.remove(enemy_beam)
        
        # 衝突判定
        player_hit = False
        enemies_to_remove = []
        beams_to_remove = []
        enemy_beams_to_remove = []

        for enemy in enemies:
            for beam in beams:
                if check_collision(enemy_mask, enemy[0], enemy[1], pygame.mask.Mask((BEAM_WIDTH, BEAM_HEIGHT), True), beam[0], beam[1]):
                    explosions.append([enemy[0], enemy[1], EXPLOSION_DURATION])
                    enemies_to_remove.append(enemy)
                    beams_to_remove.append(beam)
                    score += SCORE_PER_HIT
                    enemy_explosion_sound.play()  # 敵機の爆発音を再生

            if check_collision(player_mask, player_x, player_y, enemy_mask, enemy[0], enemy[1]):
                explosions.append([enemy[0], enemy[1], EXPLOSION_DURATION])
                enemies_to_remove.append(enemy)
                player_lives -= 1
                player_hit = True
                player_explosion_sound.play()  # プレイヤー機の爆発音を再生
                if player_lives == 0:
                    game_over = True
                    pygame.mixer.music.load(GAME_OVER_MUSIC)
                    pygame.mixer.music.play(-1)  # ゲームオーバー時に別のBGMを再生

        for enemy_beam in enemy_beams:
            if check_collision(player_mask, player_x, player_y, pygame.mask.Mask((BEAM_WIDTH, BEAM_HEIGHT), True), enemy_beam[0], enemy_beam[1]):
                explosions.append([player_x, player_y, EXPLOSION_DURATION])
                enemy_beams_to_remove.append(enemy_beam)
                player_lives -= 1
                player_hit = True
                player_explosion_sound.play()  # プレイヤー機の爆発音を再生
                if player_lives == 0:
                    game_over = True
                    pygame.mixer.music.load(GAME_OVER_MUSIC)
                    pygame.mixer.music.play(-1)  # ゲームオーバー時に別のBGMを再生

        # リスト外で削除
        for enemy in enemies_to_remove:
            if enemy in enemies:
                enemies.remove(enemy)
        for beam in beams_to_remove:
            if beam in beams:
                beams.remove(beam)
        for enemy_beam in enemy_beams_to_remove:
            if enemy_beam in enemy_beams:
                enemy_beams.remove(enemy_beam)

        # 爆発の更新
        draw_explosions()

        if player_hit and not game_over:
            draw_explosions()  # 爆発を描画して更新
            pygame.display.flip()
            time.sleep(3)  # 3秒間スリープ
            reset_game(init=False)

        # 画面の描画
        screen.fill(BLACK)
        
        # プレイヤーの描画
        screen.blit(player_img, (player_x, player_y))
        
        # ビームの描画
        for beam in beams:
            pygame.draw.rect(screen, WHITE, (beam[0], beam[1], BEAM_WIDTH, BEAM_HEIGHT))
        
        # 敵の描画
        for enemy in enemies:
            screen.blit(enemy_img, (enemy[0], enemy[1]))
        
        # 敵ビームの描画
        for enemy_beam in enemy_beams:
            pygame.draw.rect(screen, RED, (enemy_beam[0], enemy_beam[1], BEAM_WIDTH, BEAM_HEIGHT))
        
        # スコアとライフの描画
        score_text = font.render(f'Score: {score}', True, WHITE)
        lives_text = font.render(f'Lives: {player_lives}', True, WHITE)
        screen.blit(score_text, (10, 10))
        screen.blit(lives_text, (10, 50))
        
        pygame.display.flip()
        clock.tick(60)
    else:
        show_game_over()

pygame.quit()

 

出来上がったシューティングゲームは以下のようになりました。

 

ここまで来れば売り物とまではいかないまでも暇つぶしくらいにはなるのではないでしょうか?

ポイントはここまでは全くソースを自分では修正していない事です。40回のプロンプトのやり取り(画像生成やリファクタリングを除外すれば27回)だけでここまで出来ました。(なお時間に換算すると3時間程度です。)

背景画像・ビーム画像の追加

ビームも画像にして、背景も殺風景なので背景画像もつけました。(途中、色々と苦労しています。以下ご参照ください)

No 指示 入力したプロンプト 備考
41 仕様伝達 ビームの形状を敵のビーム、プレイヤーのビームともに横長の楕円にできますか?
42 仕様伝達 BEAM_HEIGHTを5にしてください。
43 仕様伝達 プレイ中の背景に用意した画像を表示するようにしてください。
44 リファクタリング こちらで少し前のソースをリファクタリングしました。
(実際にコードを張り付けたけど省略)このソースを元にしてビームの形状の楕円化と背景画像の読み込みの修正をしてください。
とうとう自分で一部のソースを修正してしまう・・
45 画像生成依頼 背景画像に相応しい800*600の宇宙空間の画像をください。 サイズまでは指定できず(知ってた)
46 仕様伝達 プログラムから背景画像を読み込んで表示する際にゲームの邪魔にならないように画像の色調を薄くできますか?
47 エラー修正指示
set_alphaの値を変えても変化がありません。調べたら,「画像がピクセルごとにα値を持っている場合*1はset_alphaは効果がありません」と記載されているものがありました。このせいですか?
48 エラー修正指示 やありset_alphaの値を変えても何の変化もありません。原因を調べてください。
49 画像生成依頼
効果がないので、プログラム上で背景を薄くすることは諦めます。通常の背景の読み込みに戻してください。そして元から薄い背景画像を生成してください。
50 仕様伝達 ビームの楕円を周辺は薄くして中心は濃くして画像のように表現できますか? 結果できなかった。
51 画像生成依頼 グラデーションは諦めました。ビームも画像を読み込むようにします。プレイヤーのビームと敵のビームの画像を生成してください。

強敵の追加など

敵が弱すぎるので、放射状にビームを撃つような敵が時々出現するようにしました。

No 指示 入力したプロンプト 備考
52 仕様伝達 scoreは右上、livesは左上に配置してください。retryボタンとquitボタンは横並びにしてください。game overの文字は上部中心に表示してください。
53 仕様伝達 BACKGROUND_IMAGE = ‘background.png’ PLAYER_BEAM_IMAGE = “player_beam.png” ENEMY_BEAM_IMAGE = “enemy_beam.png” ファイル名の修正指示
54 仕様伝達 ランダムな感覚でたまに放射状にビームを撃ってくる敵を出現させてください。
55 デグレ修正指示 放射状のビームしか撃ってこなくなりました。通常は普通のビームを撃つようにしてください。
56 デグレ修正指示 今度は放射状のビームが来なくなりました。両立させてください。
57 デグレ修正指示 放射状ビーム来ないよお
58 仕様伝達 放射状にビームが発射されるようになりました。 放射状のビームの画像を進行方向に向けて回転させられますか?

(参考)リファクタリング(ファイル・クラス構成変更)

この時点で外部的にはなかなか良いものが出来上がりましたが、ソースコードは1ファイルに長々とロジックが記載されており、メンテナンス性が非常に悪いものとなっていました。

そこで、ファイルを分割、クラスを作成してかなりドラスティックにソースを修正しようとしましたが、デグったり動作しなくなるばかりで結局全て失敗しました。

なぜ、タイトルに(参考)をつけたかというと、以下のプロンプトで生成したソースは全て失敗だったからです。なので参考程度にそのやり取りをご参照ください・・(最後、こちらもかなりイライラしています笑)

イライラして最後の方のプロンプトはこちらの口調が悪くなっていますが、プロンプトは丁寧な質問・指示の方がより良い回答が得られるという検証結果もあるので、本当はこのような書き方はNGです。内心イライラしていてもプロンプトの指示は丁寧にしましょう。

No 指示 入力したプロンプト 備考
59 リファクタリング
ソースコードがかなり冗長で汚くなってきたのでリファクタリングしたいです。
方針としては、オブジェクト指向に則ってリファクタリングしたいです。 具体的には以下の7つのクラスを作成します。(ファイルはそれぞれ別ファイルにしてください)
・ベース物体クラス ・機体クラス(ベース物体クラスを継承)
・ビームクラス(ベース物体クラスを継承)
・プレイヤー機クラス(機体クラスを継承)
・敵機クラス(機体クラスを継承)
・プレイヤービームクラス(ビームクラスを継承)
・敵ビームクラス(ビームクラスを継承)
ベース機体クラスには座標やイメージ画像情報など共通する項目を保持といったようにそれぞれ共通するプロパティや関数を作成して、差分を子供のクラスで記載するようにしてください。 デグレードが発生しないように注意してください。
60 デグレ修正指示 以下とかの定義がなくなっています。修正してください。 なお、出力するコードは、今後は修正があるファイルのみにしてください。 ENEMY_WIDTH = 50
ENEMY_HEIGHT = 50
ENEMY_SPEED = 3
ENEMY_BEAM_SPEED = 5
BEAM_WIDTH = 20 # 横長の楕円の幅
BEAM_HEIGHT = 5 # 横長の楕円の高さ BEAM_SPEED = 7
61 デグレ修正指示 check_collisionの定義はどこに行った? ちゃんとデグレしないようにしてください。
62 デグレ修正指示
enemy_beam_img、draw_explosions、reset_gameとか他にも色々なくなってるんよ、言われたものだけでなく、定義されてるかどうか自分でちゃんと確かめてコードを生成して!
63 デグレ修正指示 (異常終了のスタックトレースをそのまま貼り付け)
64 デグレ修正指示 画面に機体もビームも何も表示されなくなり、音だけなってます。
65 デグレ修正指示
結局、どこを修正したのですか? 何も変わってないように見受けられます。指摘した『画面に機体もビームも何も表示されなくなり、音だけなってます。』という問題に対してどのような対処をしたのか教えてください。
66 デグレ修正指示 起動した瞬間に爆発音がして終了します。機体もビームも表示されません。

もう一度、原因を確かめて対処してください。

67 デグレ修正指示 全然、改善されません。原因を解明するためのログを仕込んでください。
68 デグレ修正指示 起動しただけで、何も操作してないのに以下のログが出力されます。原因はわかりますか?
(ログ内容)
69 デグレ修正指示 現象が変わりません。以下のログが出ます。そもそもライフが一瞬で3から0まで減ってしまうのはなぜですか?当たった時の3秒のスリープもしません。
(ログ内容)
70 デグレ修正指示 スリープはするようになりましたが、他の状況は変わりません、 ちなみに敵の出現位置は、以下のようになってます。
(800, 545) (800, 16) (800, 522)
この事からおかしいのは、むしろプレイヤー機の位置、もしくは衝突判定なのでは?
衝突判定のタイミングでプレイヤー機の位置もログで出力して、確認してください
71 デグレ修正指示 以下のようなログで、全く座標が違うのに衝突しています。判定がおかしくなったようです。確認してください。
(ログ内容)
72 デグレ修正指示 画像読み込みでリファクタリング前にやってた以下のような機体のスケール変更やってないじゃん!だから画像が大きすぎて衝突しちゃうんでしょ?
enemy_img = pygame.transform.scale(enemy_img, (ENEMY_WIDTH, ENEMY_HEIGHT))
player_img = pygame.transform.scale(player_img, (PLAYER_WIDTH, PLAYER_HEIGHT))
73 デグレ修正指示 いやいや、画像読み込みは各クラスでやってるんだから、そこでやれや
74 デグレ修正指示 いや、そこじゃなくて、元々、BaseObjectでやってたから、そこでやって。。
75 デグレ修正指示 (異常終了のスタックトレースをそのまま貼り付け)
76 デグレ修正指示
player_beam.pyでBEAM_SPEEDが定義されていないし、enemy.pyでENEMY_SPEEDが使われてないし、player.pyでライフとスコアの初期値がハードコーディングなの修正して
77 デグレ修正指示 (異常終了のスタックトレースをそのまま貼り付け)
78 デグレ修正指示 (異常終了のスタックトレースをそのまま貼り付け)
79 デグレ修正指示 (異常終了のスタックトレースをそのまま貼り付け)
80 デグレ修正指示 (異常終了のスタックトレースをそのまま貼り付け)
81 デグレ修正指示 一番最初の起動時のみBGMが鳴りません。ライフが減った後やリトライ時はなります。
82 デグレ修正指示 いやいや、setupを変えるんじゃなくて、reset_gameを最初にも呼ぶようにして。元々そうだったじゃん
83 デグレ修正指示
いやいや、元々 mainでこう書いてたじゃん、この書き方に戻してよ # Pygameの初期化 pygame.init() # 初期設定の呼び出し setup() # ゲームの初期化 reset_game(init=True)
84 デグレ修正指示
mainでreset_gameを呼んだ後の以下が不要です。あとこれらをglobalにしてないのでreset_game内の変数と違う扱いになってる可能性があります。(敵の位置がリセットされません)
beams = [] enemy_beams = [] enemies = [] explosions = []
85 デグレ修正指示
Frame updatedのログは削除してください。また他のログは定数としてデバッグモードを設定しその値がTrueの場合のみ出力するようにしてください。くれぐれもログ以外の箇所は変更せずにデグレードが発生しないようにしてください。
86 リファクタリング 以下はutilで関数化してください。 if DEBUG_MODE: print(“ログ内容”)
87 デグレ修正指示
BaseObjectに elif isinstance(image, pygame.Surface): self.image = pygame.transform.scale(image, (width, height)) というロジックがありますが、pygame.Surfaceを受け取るようなロジックがあればファイル名のstringを受け取るロジックに統一してください。
88 デグレ修正指示
mainから回転させたオブジェクトを設定しているままなのでエラーが発生するようになりました。
rotated_beam_img = pygame.transform.rotate(pygame.image.load(ENEMY_BEAM_IMAGE).convert_alpha(), -math.degrees(radians)) enemy_beams.append(EnemyBeam(enemy.x, enemy.y + ENEMY_HEIGHT // 2, rotated_beam_img, dx, dy)) 敵のビーム特有の処理なのでenemy_beamで回転するかどうかのフラグを受け取って回転する場合は、mainでなく、そこでrotated_beam_img = pygame.transform.rotate(pygame.image.load(ENEMY_BEAM_IMAGE).convert_alpha(), -math.degrees(radians))を実行するようにしてください。
89 デグレ修正指示 これ、エラーになりませんか?
baseobjectにこのinitないよ
super().__init__(x, y, rotated_image, speed_x, speed_y)
90 デグレ修正指示 BaseObjectはimage_pathのstringを受け取るのにダメでしょ、
91 デグレ修正指示
rotated_imageはSurfaceのオブジェクトです。 super().__init__(x, y, rotated_image, speed_x, speed_y)を呼び出しています。 肝心のbaseobjectには __init__(self, x, y, image_path, width, height, speed_x, speed_y=0)がありimage_pathはStringを想定しています。エラーになるでしょ?何回同じこと言わせるのですか?

余談ですがGPT4になってからAIが謝らななくなったような気がします。
誤りを指摘しても、「動かないのはこれが原因ですね(キリッ)」みたいな感じで、いやいやお前がそのコードを提示したんやろと思ってイラつきます(笑)
GPT3.5のではこちらが誤りを指摘するとすぐに「申し訳ありません」とまずは謝罪から入っていたのに、いったい何があったのでしょうか?(今流行りのカスハラを許さないってやつですか?)

所感(教訓)

結局、最後はソースがデグレしまくって動かなくなるというところで終了しました・・

今回、ChatGPTにシューティングゲームを作成させて得た所感(教訓)をいくつか挙げておきます。

  1. とりあえずポンチ絵(落書き)からはろくなものはできなかった。
    Xのポストは話を盛っているか(失礼ですがAI系は多々あります)、もっとちゃんとしたプロンプトを最初から使用しているかのどちらかと思います。(絵のレベルはそれほど変わりなかったはずです・・)
  2. 仕様変更はコツコツと行う。決して一度に色々とやらせてはいけない。
    一度に色々な指示を書くと大抵失敗します。焦らずコツコツと指示していきましょう。
  3. ソースが読めるなら不具合修正は、どのような修正をしたか確認する。
    不具合を不具合で誤魔化すような見た目正常なように修正をする場合があります。そしてそれが後々の修正に響いてデグレが発生したりします。
  4. 後からの大規模なリファクタリング(特にファイル構成を変えるようなもの)は難しい。
    「ソースコードが長すぎる時は分割する」「オブジェクト思考に則って味方機、敵機などはクラス化する」など最初に指示をしておいた方が良いかもしれません。

自力でリファクタリング・修正して完成

このまま動かないコードのまま終わるのも気持ちが悪いので結局、最後は動かなくなったソースコードを全部、自力で修正しました。

PyGameは全く触ったことがありませんでしたが、ChatGPTとのやりとりを通してソースを見てなんとなく理解してきていたので、楽しく作業ができました。調子に乗ってさらに機能も追加しました。(ある特定の時間だけ自分も放射状にビームを撃つことができるなど)

出来上がったシューティングゲームを遊んだものが以下となります。

 

ソースコードはGitHubに上げておきますので良かったらご参考にしてください。

(残念ながら、最初からGitHubで管理していたわけではないので、 ChatGPTとのやりとりと対応したソース履歴にはなっていません。本当なら履歴を紐付けできるようにすれば良かったと思いました。)

音声ファイルはフリーと言えども再配布になってしまうのでGitHubでは管理しておりません。代わりに音声ファイルのフォルダに配布元の一覧表があるので、各自取得してください。(もちろん、好みの音声ファイルを使ってくださっても良いです。)

まとめ

今回は、元々、「落書きから本当にシューティングゲームを作成できるのか?」という意図で検証を始めました。

検証した結果、1、2回のやり取りではまともなゲームは出来ませんでしたが、40回程度(実質30回弱)やりとりを重ねていけば(時間換算で3時間程度)、それなりのものは出来上がりました。

最終的にリファクタリングさせようとして失敗しましたが、このようなやりとりも含めてAIにソースコードの生成をさせる雰囲気は伝えられたのではないでしょうか?(最後にこちらがイラついてるところとかも笑)

今回の収穫として、私はPyGameも知らなければ、ゲームプログラミングというものにも馴染みがありませんでしたが、なんとなくわかるようになりました。
ゲームプログラミングは難しいイメージがありましたが、フレームを無限ループさせて、座標を足し引きして物体を動かすことの繰り返しなので、機能追加とかは簡単に出来ました。(この後、JavaScriptでもシューティングゲーム作成を試みましたが、言語は違えどロジックとしては同じなのでこちらも簡単に理解できました。)

自分自身のプログラミングの勉強のためにまずはAIにソースを吐き出させて、それを自分で読んで修正しながら学ぶというのも良いかもしれません。