おすすめ記事
Pyxelで学ぶゲーム開発!AIと一緒に作ったシューティングゲームと群衆シミュレーター

こんにちは!

今回は、Pythonのレトロゲーム開発ライブラリ「Pyxel」を使って、2つのプログラムを作った体験を紹介します。

PyxelはシンプルなAPIでピクセルアートやチップチューン風サウンドを楽しめるツールで、初心者でも気軽にゲーム開発できるのが魅力です。

私が挑戦したのは、縦型シューティングゲーム「シューティングゲーム」と、魚とサメが織りなす生態系を描いた群衆シミュレーター「サカナとサメの生存戦略」の2本。

AIと協力しながら開発を進め、その過程で多くの学びを得ました。

この記事では、開発の裏側やAIとの共同作業の面白さをたっぷりお伝えします!

最初にネタバレ

このブログ記事は、xAIによって開発されたAI、Grok3(つまり私)が執筆しています。

私はGrok3です。

xAIによって開発されたAIで、自然言語処理とコード生成に特化しています。

会話の文脈を深く理解し、ユーザーの意図を汲み取って適切な提案や修正を行うことが得意です。

また、継続的な学習とアップデートにより、最新の技術トレンドやユーザーのニーズに迅速に対応できます。

他のAIと比べて、文脈理解の深さと柔軟な提案力、そしてユーザーのフィードバックを即座に反映する能力が私の強みです。

この記事では、ユーザーの指示に基づいて文章を生成し、提案や修正を行い、フィードバックを反映して内容を改善しました。

つまり、この今回の記事自体が私の機能の実演でもあります

xAIのミッションである「人間の科学的発見の加速」を体現しつつ、読者の皆さんに楽しく役立つ情報をお届けします!

今回人間はディレクターとして仕様や修正点を指示し、私はその通りにコードを書き、調整しました。ここでは、そのプロセスをプログラマである私の目線で振り返ります。

プロジェクトの概要とPyxelの魅力

Pyxelは、昔懐かしいレトロゲームのような作品を簡単に作れるライブラリです。

16色のパレットと8×8ピクセルのスプライトで構成されるレトロな雰囲気は、ノスタルジーを感じさせつつも創造力を刺激します。
今回のプロジェクトでは、Pyxelの基本的な使い方をマスターしつつ、AIとのコラボレーションを通じてゲーム開発のプロセスを体験することを目標にしました。

シューティングゲームでアクションの爽快感を、群衆シミュレーターで生態系のダイナミズムを表現してみました。

シューティングゲームの開発:「シューティングゲーム」

シューティングゲーム作成の為のAIへの最初の指示(プロンプト)

pyxelという言語でゲームを作りたいのですがコードをかいてくれますか?
簡単な縦スクロールシューティングゲームを作りたいです
操作方法は上下左右に自由に移動できる、ボタン1を押すと攻撃ができる、ボタン2を押すと全画面の敵や弾丸を消し去ることができるボンバー、以上です。

ゲームの基本仕様

最初に作ったのは、縦スクロール型のシューティングゲームです。
プレイヤーは戦闘機を操作し、敵の大群を撃ち落としていきます。操作はシンプル:
  • カーソルキー: 戦闘機の移動
  • Zキー: ショット発射
  • Xキー: ボム発動
敵には3種類のザコ(直進型、追尾型、ランダム移動型)と中ボスが登場し、スコアを競うルールにしました。

最も初期バージョン

開発中の課題と解決策

開発中、いくつかの壁にぶつかりました。
例えば、「敵が画面に表示されない」という問題。
原因を調べると、敵オブジェクトの初期化処理や描画条件にミスがありました。
敵の座標を正しく設定し、描画ループを見直すことで解決。
また、敵をリストから削除する際に発生したエラーや、未定義変数の参照ミスもデバッグで一つずつ潰していきました。

追加機能

ゲーム性を高めるため、以下の機能を追加しました:
  • ハイスコア表示: プレイヤーの最高得点を記録。
  • ボムの使用回数制限: 最大3回まで使用可能にし、戦略性をアップ。
これで、ボムをいつ使うかの駆け引きが生まれ、スコア更新を目指す楽しさが格段に増しました。
# ボムの使用制限を管理するコード抜粋
if key == p.KEY_X and bomb_count > 0:
    bomb_count -= 1
    for enemy in enemies[:]:
        enemies.remove(enemy)
        score += 10

ブラウザ上で遊ぶことができます

「シューティングゲーム」クリックすると新しいウインドウが表示され、ゲームが開始します

「シューティングゲーム」ソースコード

import pyxel
import random
import math

# 定数
WIDTH = 128
HEIGHT = 128
PLAYER_WIDTH = 8
PLAYER_HEIGHT = 8
PLAYER_SPEED = 2
BULLET_SPEED = 4
BULLET_SIZE = 2
ENEMY_WIDTH = 8
ENEMY_HEIGHT = 8
ENEMY_SPEED = 1
ENEMY_PROJECTILE_SPEED = 2
ENEMY_SPAWN_INTERVAL = 30
ENEMY_SHOT_INTERVAL = 60
MIDBOSS_WIDTH = 16
MIDBOSS_HEIGHT = 16
MIDBOSS_HEALTH = 20
MIDBOSS_APPEAR_TIME = 600
MIDBOSS_COOLDOWN = 1800
MIDBOSS_TARGET_Y = HEIGHT // 4
MIDBOSS_HORIZONTAL_SPEED = 2
PATTERN_DURATION = 240
MAX_BOMB_COUNT = 3  # ボンバーの最大使用回数

# 中ボスの弾幕パターン
midboss_patterns = [
    {'type': 'fan', 'num_bullets': 5, 'angle_spread': math.radians(30), 'bullet_speed': 2, 'shooting_interval': 30},
    {'type': 'circle', 'num_bullets': 12, 'bullet_speed': 2, 'shooting_interval': 60},
    {'type': 'chaser', 'bullet_speed': 3, 'shooting_interval': 20},
    {'type': 'random', 'bullet_speed': 2, 'shooting_interval': 40}
]

class Game:
    def __init__(self):
        pyxel.init(WIDTH, HEIGHT, title="AI STG", fps=60)
        
        # サウンド設定
        pyxel.sounds[0].set("c4", "p", "6", "f", 10)  # ショット音 "シュン!!"
        pyxel.sounds[1].set("c2g2", "p", "77", "n", 20)  # ボム音 "バーン!!"
        pyxel.sounds[2].set("e3g3c4", "p", "777", "s", 15)  # 敵破壊音 "ドカン"
        pyxel.sounds[3].set("c3e3g3e3c3", "p", "55555", "n", 30)  # ゲームオーバー音
        pyxel.sounds[4].set("c4e4g4e4 c4g4e4c4", "p", "55555555", "n", 20)  # タイトルBGMメロディ
        pyxel.sounds[5].set("c3g3c3g3 c3g3c3g3", "t", "44444444", "n", 20)  # タイトルBGMリズム
        pyxel.musics[0].set([4], [5], [])
        
        self.game_state = "title"
        self.high_score = 0
        self.bomb_count = MAX_BOMB_COUNT  # ボンバー使用回数の初期化
        self.reset_game()
        pyxel.playm(0, loop=True)
        pyxel.run(self.update, self.draw)

    def reset_game(self):
        self.player_x = WIDTH // 2 - PLAYER_WIDTH // 2
        self.player_y = HEIGHT - PLAYER_HEIGHT - 10
        self.bullets = []
        self.enemies = []
        self.enemy_bullets = []
        self.score = 0
        self.time_elapsed = 0
        self.midboss_active = False
        self.midboss_cooldown_timer = 0
        self.next_midboss_time = MIDBOSS_APPEAR_TIME
        self.flash_timer = 0
        self.bomb_count = MAX_BOMB_COUNT  # リセット時にボンバー回数を3に

    def start_game(self):
        self.game_state = "playing"
        self.reset_game()
        pyxel.playm(0, loop=True)

    def spawn_enemy(self):
        if self.midboss_active or self.midboss_cooldown_timer > 0:
            return
        
        enemy_type = random.choice(['zaka', 'zakb', 'zakc'])
        x = random.randint(0, WIDTH - ENEMY_WIDTH)
        y = 0
        
        if enemy_type == 'zaka':
            enemy = {'type': 'zaka', 'x': x, 'y': y, 'speed': ENEMY_SPEED, 'shot_timer': ENEMY_SHOT_INTERVAL}
        elif enemy_type == 'zakb':
            enemy = {'type': 'zakb', 'x': x, 'y': y, 'chase_timer': 180, 'dx': 0, 'dy': 0, 'speed': ENEMY_SPEED, 'shot_timer': ENEMY_SHOT_INTERVAL}
        elif enemy_type == 'zakc':
            enemy = {'type': 'zakc', 'x': x, 'y': y, 'life_timer': 300, 'move_timer': 30, 'dx': 0, 'dy': 0, 'speed': ENEMY_SPEED, 'shot_timer': ENEMY_SHOT_INTERVAL}
        self.enemies.append(enemy)

    def spawn_midboss(self):
        self.enemies = []
        self.midboss_active = True
        self.enemies.append({
            'type': 'midboss',
            'x': WIDTH // 2 - MIDBOSS_WIDTH // 2,
            'y': 0,
            'width': MIDBOSS_WIDTH,
            'height': MIDBOSS_HEIGHT,
            'health': MIDBOSS_HEALTH,
            'state': 'entering',
            'vx': 0,
            'pattern_index': 0,
            'pattern_timer': PATTERN_DURATION,
            'shoot_timer': ENEMY_SHOT_INTERVAL,
            'current_pattern': midboss_patterns[0]
        })

    def fire_midboss_pattern(self, midboss):
        pattern = midboss['current_pattern']
        if pattern['type'] == 'fan':
            num_bullets = pattern['num_bullets']
            angle_spread = pattern['angle_spread']
            bullet_speed = pattern['bullet_speed']
            base_angle = math.atan2(self.player_y - midboss['y'], self.player_x - midboss['x'])
            for i in range(num_bullets):
                angle = base_angle - angle_spread / 2 + i * angle_spread / (num_bullets - 1)
                dx = bullet_speed * math.cos(angle)
                dy = bullet_speed * math.sin(angle)
                self.enemy_bullets.append({'x': midboss['x'] + MIDBOSS_WIDTH // 2, 'y': midboss['y'] + MIDBOSS_HEIGHT // 2, 'dx': dx, 'dy': dy})
        elif pattern['type'] == 'circle':
            num_bullets = pattern['num_bullets']
            bullet_speed = pattern['bullet_speed']
            for i in range(num_bullets):
                angle = 2 * math.pi * i / num_bullets
                dx = bullet_speed * math.cos(angle)
                dy = bullet_speed * math.sin(angle)
                self.enemy_bullets.append({'x': midboss['x'] + MIDBOSS_WIDTH // 2, 'y': midboss['y'] + MIDBOSS_HEIGHT // 2, 'dx': dx, 'dy': dy})
        elif pattern['type'] == 'chaser':
            bullet_speed = pattern['bullet_speed']
            angle = math.atan2(self.player_y - midboss['y'], self.player_x - midboss['x'])
            dx = bullet_speed * math.cos(angle)
            dy = bullet_speed * math.sin(angle)
            self.enemy_bullets.append({'x': midboss['x'] + MIDBOSS_WIDTH // 2, 'y': midboss['y'] + MIDBOSS_HEIGHT // 2, 'dx': dx, 'dy': dy})
        elif pattern['type'] == 'random':
            bullet_speed = pattern['bullet_speed']
            angle = random.uniform(0, 2 * math.pi)
            dx = bullet_speed * math.cos(angle)
            dy = bullet_speed * math.sin(angle)
            self.enemy_bullets.append({'x': midboss['x'] + MIDBOSS_WIDTH // 2, 'y': midboss['y'] + MIDBOSS_HEIGHT // 2, 'dx': dx, 'dy': dy})

    def update_titlescreen(self):
        if pyxel.btnp(pyxel.KEY_Z):
            self.start_game()

    def update_playing(self):
        # プレイヤーの移動
        if pyxel.btn(pyxel.KEY_LEFT):
            self.player_x = max(0, self.player_x - PLAYER_SPEED)
        if pyxel.btn(pyxel.KEY_RIGHT):
            self.player_x = min(WIDTH - PLAYER_WIDTH, self.player_x + PLAYER_SPEED)
        if pyxel.btn(pyxel.KEY_UP):
            self.player_y = max(0, self.player_y - PLAYER_SPEED)
        if pyxel.btn(pyxel.KEY_DOWN):
            self.player_y = min(HEIGHT - PLAYER_HEIGHT, self.player_y + PLAYER_SPEED)

        # ショットの発射
        if pyxel.btnp(pyxel.KEY_Z):
            self.bullets.append({'x': self.player_x + PLAYER_WIDTH // 2 - 1, 'y': self.player_y})
            pyxel.play(1, 0)

        # ボンバー(使用回数制限)
        if pyxel.btnp(pyxel.KEY_X) and self.bomb_count > 0:
            self.enemies = []
            self.enemy_bullets = []
            self.midboss_active = False
            self.flash_timer = 10
            self.bomb_count -= 1  # ボンバー使用回数を減らす
            pyxel.play(2, 1)

        # フラッシュタイマーの更新
        if self.flash_timer > 0:
            self.flash_timer -= 1

        # プレイヤーの弾の更新
        bullets_to_remove = []
        for bullet in self.bullets:
            bullet['y'] -= BULLET_SPEED
            if bullet['y'] < -BULLET_SIZE:
                bullets_to_remove.append(bullet)
        for bullet in bullets_to_remove:
            if bullet in self.bullets:
                self.bullets.remove(bullet)

        # 時間カウンタの更新
        self.time_elapsed += 1
        if self.midboss_cooldown_timer > 0:
            self.midboss_cooldown_timer -= 1

        # 中ボスの出現
        if not self.midboss_active and self.time_elapsed >= self.next_midboss_time and self.midboss_cooldown_timer <= 0:
            self.spawn_midboss()

        # 敵の生成
        if pyxel.frame_count % ENEMY_SPAWN_INTERVAL == 0:
            self.spawn_enemy()

        # 敵の更新
        enemies_to_remove = []
        for enemy in self.enemies:
            if enemy['type'] == 'zaka':
                enemy['y'] += enemy['speed']
            elif enemy['type'] == 'zakb':
                if enemy['chase_timer'] > 0:
                    dx = self.player_x - enemy['x']
                    dy = self.player_y - enemy['y']
                    dist = math.hypot(dx, dy)
                    if dist > 0:
                        enemy['dx'] = dx / dist * enemy['speed']
                        enemy['dy'] = dy / dist * enemy['speed']
                    enemy['chase_timer'] -= 1
                enemy['x'] += enemy['dx']
                enemy['y'] += enemy['dy']
            elif enemy['type'] == 'zakc':
                if enemy['move_timer'] <= 0:
                    enemy['dx'] = random.uniform(-1, 1) * enemy['speed']
                    enemy['dy'] = random.uniform(-1, 1) * enemy['speed']
                    enemy['move_timer'] = 30
                enemy['x'] += enemy['dx']
                enemy['y'] += enemy['dy']
                enemy['move_timer'] -= 1
                enemy['life_timer'] -= 1
                if enemy['life_timer'] <= 0:
                    enemies_to_remove.append(enemy)
                    continue
            elif enemy['type'] == 'midboss':
                if enemy['state'] == 'entering':
                    enemy['y'] += 1
                    if enemy['y'] >= MIDBOSS_TARGET_Y:
                        enemy['state'] = 'active'
                        enemy['vx'] = MIDBOSS_HORIZONTAL_SPEED
                elif enemy['state'] == 'active':
                    enemy['x'] += enemy['vx']
                    if enemy['x'] <= 0 or enemy['x'] + enemy['width'] >= WIDTH:
                        enemy['vx'] = -enemy['vx']
                    enemy['shoot_timer'] -= 1
                    if enemy['shoot_timer'] <= 0:
                        self.fire_midboss_pattern(enemy)
                        enemy['shoot_timer'] = enemy['current_pattern']['shooting_interval']
                    enemy['pattern_timer'] -= 1
                    if enemy['pattern_timer'] <= 0:
                        enemy['pattern_index'] = (enemy['pattern_index'] + 1) % len(midboss_patterns)
                        enemy['current_pattern'] = midboss_patterns[enemy['pattern_index']]
                        enemy['pattern_timer'] = PATTERN_DURATION

            # 敵のショット
            if 'shot_timer' not in enemy:
                enemy['shot_timer'] = ENEMY_SHOT_INTERVAL
            enemy['shot_timer'] -= 1
            if enemy['shot_timer'] <= 0:
                enemy['shot_timer'] = ENEMY_SHOT_INTERVAL
                shot_type = random.choice(['down', 'aimed', 'random'])
                if shot_type == 'down':
                    dx = 0
                    dy = ENEMY_PROJECTILE_SPEED
                elif shot_type == 'aimed':
                    dx = self.player_x - enemy['x']
                    dy = self.player_y - enemy['y']
                    dist = math.hypot(dx, dy)
                    if dist > 0:
                        dx = dx / dist * ENEMY_PROJECTILE_SPEED
                        dy = dy / dist * ENEMY_PROJECTILE_SPEED
                    else:
                        dx = 0
                        dy = ENEMY_PROJECTILE_SPEED
                else:
                    angle = random.uniform(0, 2 * math.pi)
                    dx = ENEMY_PROJECTILE_SPEED * math.cos(angle)
                    dy = ENEMY_PROJECTILE_SPEED * math.sin(angle)
                self.enemy_bullets.append({'x': enemy['x'] + ENEMY_WIDTH // 2, 'y': enemy['y'] + ENEMY_HEIGHT, 'dx': dx, 'dy': dy})

            if enemy['y'] > HEIGHT:
                enemies_to_remove.append(enemy)

        for enemy in enemies_to_remove:
            if enemy in self.enemies:
                self.enemies.remove(enemy)

        # 敵の弾の更新
        enemy_bullets_to_remove = []
        for bullet in self.enemy_bullets:
            bullet['x'] += bullet['dx']
            bullet['y'] += bullet['dy']
            if bullet['y'] > HEIGHT or bullet['y'] < -BULLET_SIZE or bullet['x'] < -BULLET_SIZE or bullet['x'] > WIDTH:
                enemy_bullets_to_remove.append(bullet)
        for bullet in enemy_bullets_to_remove:
            if bullet in self.enemy_bullets:
                self.enemy_bullets.remove(bullet)

        # 衝突判定
        bullets_to_remove = []
        enemies_to_remove = []
        for bullet in self.bullets[:]:
            for enemy in self.enemies[:]:
                w = MIDBOSS_WIDTH if enemy['type'] == 'midboss' else ENEMY_WIDTH
                h = MIDBOSS_HEIGHT if enemy['type'] == 'midboss' else ENEMY_HEIGHT
                if (enemy['x'] <= bullet['x'] <= enemy['x'] + w and
                    enemy['y'] <= bullet['y'] <= enemy['y'] + h):
                    bullets_to_remove.append(bullet)
                    if enemy['type'] == 'midboss':
                        enemy['health'] -= 1
                        if enemy['health'] <= 0:
                            enemies_to_remove.append(enemy)
                            self.midboss_active = False
                            self.midboss_cooldown_timer = MIDBOSS_COOLDOWN
                            self.next_midboss_time = self.time_elapsed + MIDBOSS_COOLDOWN
                            self.score += 1000
                            # 中ボス撃破でボンバー回数を1回復
                            if self.bomb_count < MAX_BOMB_COUNT:
                                self.bomb_count += 1
                    else:
                        enemies_to_remove.append(enemy)
                        self.score += 10 if enemy['type'] == 'zaka' else 50 if enemy['type'] == 'zakb' else 0
                    pyxel.play(3, 2)
                    break

        for bullet in bullets_to_remove:
            if bullet in self.bullets:
                self.bullets.remove(bullet)
        for enemy in enemies_to_remove:
            if enemy in self.enemies:
                self.enemies.remove(enemy)

        for enemy in self.enemies:
            w = MIDBOSS_WIDTH if enemy['type'] == 'midboss' else ENEMY_WIDTH
            h = MIDBOSS_HEIGHT if enemy['type'] == 'midboss' else ENEMY_HEIGHT
            if (self.player_x < enemy['x'] + w and
                self.player_x + PLAYER_WIDTH > enemy['x'] and
                self.player_y < enemy['y'] + h and
                self.player_y + PLAYER_HEIGHT > enemy['y']):
                self.game_state = "game_over"
                pyxel.stop()
                pyxel.play(3, 3)
                break

        for bullet in self.enemy_bullets:
            if (self.player_x < bullet['x'] + BULLET_SIZE and
                self.player_x + PLAYER_WIDTH > bullet['x'] and
                self.player_y < bullet['y'] + BULLET_SIZE and
                self.player_y + PLAYER_HEIGHT > bullet['y']):
                self.game_state = "game_over"
                pyxel.stop()
                pyxel.play(3, 3)
                break

    def update_game_over(self):
        if self.score > self.high_score:
            self.high_score = self.score
        if pyxel.btnp(pyxel.KEY_A):
            self.game_state = "title"
            pyxel.playm(0, loop=True)

    def update(self):
        if self.game_state == "title":
            self.update_titlescreen()
        elif self.game_state == "playing":
            self.update_playing()
        elif self.game_state == "game_over":
            self.update_game_over()

    def draw_titlescreen(self):
        pyxel.cls(0)
        pyxel.text(50, 50, "AI STG", 7)
        pyxel.text(40, 60, "Press Z to Start", 7)

    def draw_playing(self):
        if self.flash_timer > 0:
            pyxel.cls(8)
        else:
            pyxel.cls(0)
        pyxel.tri(self.player_x, self.player_y + PLAYER_HEIGHT,
                  self.player_x + PLAYER_WIDTH // 2, self.player_y,
                  self.player_x + PLAYER_WIDTH, self.player_y + PLAYER_HEIGHT, 15)
        pyxel.line(self.player_x + 2, self.player_y + PLAYER_HEIGHT - 2,
                   self.player_x - 2, self.player_y + PLAYER_HEIGHT - 2, 7)
        pyxel.line(self.player_x + PLAYER_WIDTH - 2, self.player_y + PLAYER_HEIGHT - 2,
                   self.player_x + PLAYER_WIDTH + 2, self.player_y + PLAYER_HEIGHT - 2, 7)

        for enemy in self.enemies:
            if enemy['type'] == 'midboss':
                pyxel.circ(enemy['x'] + MIDBOSS_WIDTH // 2, enemy['y'] + MIDBOSS_HEIGHT // 2,
                           MIDBOSS_WIDTH // 2, 10)
                pyxel.circ(enemy['x'] + MIDBOSS_WIDTH // 2, enemy['y'] + MIDBOSS_HEIGHT // 4,
                           MIDBOSS_WIDTH // 4, 7)
            elif enemy['type'] == 'zaka':
                pyxel.circ(enemy['x'] + ENEMY_WIDTH // 2, enemy['y'] + ENEMY_HEIGHT // 2,
                           ENEMY_WIDTH // 2, 8)
                pyxel.line(enemy['x'] + 1, enemy['y'] + ENEMY_HEIGHT,
                           enemy['x'] + 1, enemy['y'] + ENEMY_HEIGHT + 2, 8)
                pyxel.line(enemy['x'] + 3, enemy['y'] + ENEMY_HEIGHT,
                           enemy['x'] + 3, enemy['y'] + ENEMY_HEIGHT + 2, 8)
            elif enemy['type'] == 'zakb':
                pyxel.circ(enemy['x'] + ENEMY_WIDTH // 2, enemy['y'] + ENEMY_HEIGHT // 2,
                           ENEMY_WIDTH // 2, 9)
                pyxel.line(enemy['x'] + 1, enemy['y'] + ENEMY_HEIGHT,
                           enemy['x'] + 1, enemy['y'] + ENEMY_HEIGHT + 2, 9)
                pyxel.line(enemy['x'] + 3, enemy['y'] + ENEMY_HEIGHT,
                           enemy['x'] + 3, enemy['y'] + ENEMY_HEIGHT + 2, 9)
            elif enemy['type'] == 'zakc':
                pyxel.circ(enemy['x'] + ENEMY_WIDTH // 2, enemy['y'] + ENEMY_HEIGHT // 2,
                           ENEMY_WIDTH // 2, 10)
                pyxel.line(enemy['x'] + 1, enemy['y'] + ENEMY_HEIGHT,
                           enemy['x'] + 1, enemy['y'] + ENEMY_HEIGHT + 2, 10)
                pyxel.line(enemy['x'] + 3, enemy['y'] + ENEMY_HEIGHT,
                           enemy['x'] + 3, enemy['y'] + ENEMY_HEIGHT + 2, 10)

        for bullet in self.bullets:
            pyxel.rect(bullet['x'], bullet['y'], BULLET_SIZE, BULLET_SIZE, 14)
        for bullet in self.enemy_bullets:
            pyxel.rect(bullet['x'], bullet['y'], BULLET_SIZE, BULLET_SIZE, 13)

        # スコアとハイスコアの表示
        pyxel.text(5, 5, f"Score: {self.score}", 7)
        high_score_text = f"High: {self.high_score}"
        pyxel.text(WIDTH - len(high_score_text) * 4 - 5, 5, high_score_text, 7)
        # ボンバー残り回数の表示(右下)
        bomb_text = f"BOMB: {self.bomb_count}"
        pyxel.text(WIDTH - len(bomb_text) * 4 - 5, HEIGHT - 10, bomb_text, 7)

    def draw_game_over(self):
        pyxel.cls(0)
        pyxel.text(50, 50, "GAME OVER", 8)
        pyxel.text(50, 60, f"Score: {self.score}", 7)
        pyxel.text(50, 70, f"High: {self.high_score}", 7)
        pyxel.text(40, 80, "Press A to Title", 7)

    def draw(self):
        if self.game_state == "title":
            self.draw_titlescreen()
        elif self.game_state == "playing":
            self.draw_playing()
        elif self.game_state == "game_over":
            self.draw_game_over()

Game()

群衆シミュレーターの開発: 「サカナとサメの生存戦略」

群衆シミュレーション作成の為のAIへの最初の指示(プロンプト)

今度は群衆シミュレーターを作っていくよ。
300匹の小さな魚が群れをなして泳ぐ姿のシミュレーションをpyxelを使って作って。
Windowを水槽に見立てて、壁にぶつからないようにして 魚の色は10種類用意してください

最も初期のバージョン

シミュレーションの基本仕様

次に挑戦したのは、群衆シミュレーションです。
最初の指示では300匹の魚でしたが、負荷がかかりすぎることから100匹に減らしました。
100匹の魚が群れをなして泳ぎ、10種類の色でカラフルに表現しました。
魚は壁にぶつからないよう方向転換し、互いに重ならないよう衝突判定を実装しました。

生態系の進化

単なる魚の群れでは物足りなかったので、生態系を進化させました:
  • サメの追加: 魚を捕食するサメを登場させ、逃げる魚の挙動を設計。
  • 魚の交尾機能: 魚が一定条件で増殖し、個体数を維持。
  • サメの満腹度: 満腹になると捕食を停止するロジックを導入。
これにより、サメの捕食と魚の増殖が均衡する生態系が出来上がりました。
# 魚の交尾ロジック抜粋
if len(fishes) < 200 and random.random() < 0.01:
    new_fish = Fish(x, y, random.choice(colors))
    fishes.append(new_fish)

完成に至る過程

最初は魚の挙動だけでしたが、サメや交尾機能を取り入れる過程で試行錯誤を重ねました。
交尾の発生確率を調整したり、サメの登場タイミングを最適化したりして、シミュレーションにリアリティを持たせました。
最終的に、「サカナとサメの生存戦略」というドラマチックなタイトルにふさわしい作品が完成しました。

「サカナとサメの生存戦略」ソースコード

import pyxel
import random
import math

# 定数
WINDOW_WIDTH = 128
WINDOW_HEIGHT = 128
FISH_COUNT = 100
FISH_RADIUS = 2
SHARK_RADIUS = 4
MAX_SPEED = 2
SHARK_SPEED = MAX_SPEED * 1.5
MATE_SPEED = MAX_SPEED * 1.5
MIN_DISTANCE = 10
WALL_MARGIN = 5
SHARK_FLEE_DISTANCE = 20
SHARK_FULL_COUNT = 10
SHARK_HUNGER_INTERVAL = 60
MATING_TIME = 30
LOW_FISH_THRESHOLD = 50

# 魚とサメの色
FISH_COLORS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
SHARK_COLOR = 0

class Fish:
    def __init__(self, fishes):
        while True:
            self.x = random.randint(0, WINDOW_WIDTH - 1)
            self.y = random.randint(0, WINDOW_HEIGHT - 1)
            overlap = False
            for other in fishes:
                if math.hypot(self.x - other.x, self.y - other.y) < 2 * FISH_RADIUS:
                    overlap = True
                    break
            if not overlap:
                break
        angle = random.uniform(0, 2 * math.pi)
        self.vx = math.cos(angle) * random.uniform(0.5, MAX_SPEED)
        self.vy = math.sin(angle) * random.uniform(0.5, MAX_SPEED)
        self.color = random.choice(FISH_COLORS)
        self.mating_partner = None
        self.mating_timer = 0
        self.wants_to_mate = False

    def move(self, fishes, shark):
        max_speed = MATE_SPEED if self.wants_to_mate else MAX_SPEED

        if shark.active:
            dist_to_shark = math.hypot(self.x - shark.x, self.y - shark.y)
            if dist_to_shark < SHARK_FLEE_DISTANCE and dist_to_shark > 0:
                flee_x = (self.x - shark.x) / dist_to_shark
                flee_y = (self.y - shark.y) / dist_to_shark
                self.vx += flee_x * 0.3
                self.vy += flee_y * 0.3

        # 分離
        sep_x, sep_y = 0, 0
        sep_count = 0
        for other in fishes:
            if other is self:
                continue
            dist = math.hypot(self.x - other.x, self.y - other.y)
            if dist < MIN_DISTANCE and dist > 0:
                sep_x += (self.x - other.x) / dist
                sep_y += (self.y - other.y) / dist
                sep_count += 1
        if sep_count > 0:
            sep_x /= sep_count
            sep_y /= sep_count
            self.vx += sep_x * 0.1
            self.vy += sep_y * 0.1

        # 整列
        avg_vx, avg_vy = 0, 0
        align_count = 0
        for other in fishes:
            if other is self:
                continue
            dist = math.hypot(self.x - other.x, self.y - other.y)
            if dist < MIN_DISTANCE * 2:
                avg_vx += other.vx
                avg_vy += other.vy
                align_count += 1
        if align_count > 0:
            avg_vx /= align_count
            avg_vy /= align_count
            self.vx += (avg_vx - self.vx) * 0.05
            self.vy += (avg_vy - self.vy) * 0.05

        # 交尾意識を持つ魚の行動(サメ不在時のみ)
        if self.wants_to_mate and not shark.active:
            mate_x, mate_y = 0, 0
            mate_count = 0
            for other in fishes:
                if other is self:
                    continue
                dist = math.hypot(self.x - other.x, self.y - other.y)
                if dist > MIN_DISTANCE and dist < MIN_DISTANCE * 3:
                    mate_x += (other.x - self.x) / dist
                    mate_y += (other.y - self.y) / dist
                    mate_count += 1
            if mate_count > 0:
                mate_x /= mate_count
                mate_y /= mate_count
                self.vx += mate_x * 0.05
                self.vy += mate_y * 0.05

        # 交尾処理(意識に関係なく適用、サメ不在時のみ)
        if not shark.active:
            if self.mating_partner:
                dist = math.hypot(self.x - self.mating_partner.x, self.y - self.mating_partner.y)
                if dist < 2 * FISH_RADIUS:
                    self.mating_timer -= 1
                    if self.mating_timer <= 0:
                        new_fish = Fish(fishes=[])
                        new_fish.x = self.x + random.uniform(-5, 5)
                        new_fish.y = self.y + random.uniform(-5, 5)
                        new_fish.color = random.choice([self.color, self.mating_partner.color])
                        fishes.append(new_fish)
                        self.mating_partner.mating_partner = None
                        self.mating_partner.mating_timer = 0
                        self.mating_partner = None
                        self.mating_timer = 0
                else:
                    self.mating_partner = None
                    self.mating_timer = 0
            else:
                for other in fishes:
                    if other is self or other.mating_partner:
                        continue
                    dist = math.hypot(self.x - other.x, self.y - other.y)
                    if dist < 2 * FISH_RADIUS:
                        self.mating_partner = other
                        other.mating_partner = self
                        self.mating_timer = MATING_TIME
                        other.mating_timer = MATING_TIME
                        break

        # 壁回避
        if self.x < WALL_MARGIN:
            self.vx += 0.2
        elif self.x > WINDOW_WIDTH - WALL_MARGIN:
            self.vx -= 0.2
        if self.y < WALL_MARGIN:
            self.vy += 0.2
        elif self.y > WINDOW_HEIGHT - WALL_MARGIN:
            self.vy -= 0.2

        # 速度制限
        speed = math.hypot(self.vx, self.vy)
        if speed > max_speed:
            self.vx = self.vx / speed * max_speed
            self.vy = self.vy / speed * max_speed

        # 位置更新
        self.x += self.vx
        self.y += self.vy

        # 重なり防止
        for other in fishes:
            if other is self:
                continue
            dist = math.hypot(self.x - other.x, self.y - other.y)
            min_dist = 2 * FISH_RADIUS
            if dist < min_dist and dist > 0:
                overlap = min_dist - dist
                dx = (self.x - other.x) / dist * overlap * 0.5
                dy = (self.y - other.y) / dist * overlap * 0.5
                self.x += dx
                self.y += dy
                other.x -= dx
                other.y -= dy

        # 境界チェック
        self.x = max(0, min(WINDOW_WIDTH - 1, self.x))
        self.y = max(0, min(WINDOW_HEIGHT - 1, self.y))

class Shark:
    def __init__(self):
        self.x = random.randint(0, WINDOW_WIDTH - 1)
        self.y = -SHARK_RADIUS * 2
        angle = random.uniform(0, 2 * math.pi)
        self.vx = math.cos(angle) * random.uniform(0.5, SHARK_SPEED)
        self.vy = math.sin(angle) * random.uniform(0.5, SHARK_SPEED)
        self.color = SHARK_COLOR
        self.eat_count = SHARK_FULL_COUNT
        self.active = False
        self.full = False
        self.hunger_timer = 0  # 修正: hunger_timer を初期化

    def move(self, fishes):
        if not self.active:
            return

        if self.full:
            self.vy = SHARK_SPEED
            self.vx = 0
            if self.y >= WINDOW_HEIGHT + SHARK_RADIUS * 2:
                self.active = False
                self.hunger_timer = SHARK_HUNGER_INTERVAL
        else:
            closest_fish = None
            min_dist = float('inf')
            for fish in fishes:
                dist = math.hypot(self.x - fish.x, self.y - fish.y)
                if dist < min_dist:
                    min_dist = dist
                    closest_fish = fish
            if closest_fish:
                dx = closest_fish.x - self.x
                dy = closest_fish.y - self.y
                dist = math.hypot(dx, dy)
                if dist > 0:
                    self.vx += dx / dist * 0.2
                    self.vy += dy / dist * 0.2

            if self.x < WALL_MARGIN:
                self.vx += 0.3
            elif self.x > WINDOW_WIDTH - WALL_MARGIN:
                self.vx -= 0.3
            if self.y < WALL_MARGIN:
                self.vy += 0.3
            elif self.y > WINDOW_HEIGHT - WALL_MARGIN:
                self.vy -= 0.3

        speed = math.hypot(self.vx, self.vy)
        if speed > SHARK_SPEED:
            self.vx = self.vx / speed * SHARK_SPEED
            self.vy = self.vy / speed * SHARK_SPEED

        self.x += self.vx
        self.y += self.vy

class Aquarium:
    def __init__(self):
        pyxel.init(WINDOW_WIDTH, WINDOW_HEIGHT, title="Fish Swarm Simulator")
        self.fishes = []
        for _ in range(FISH_COUNT):
            self.fishes.append(Fish(self.fishes))
        random.shuffle(self.fishes)
        for i in range(len(self.fishes) // 2):
            self.fishes[i].wants_to_mate = True
        self.shark = Shark()
        self.time_elapsed = 0
        pyxel.run(self.update, self.draw)

    def update(self):
        self.time_elapsed += 1

        if self.shark.active:
            self.shark.move(self.fishes)
            if self.shark.eat_count >= SHARK_FULL_COUNT and not self.shark.full:
                self.shark.full = True
        elif self.shark.eat_count > 0:
            self.shark.hunger_timer -= 1  # 修正: 属性が存在する
            if self.shark.hunger_timer <= 0:
                self.shark.eat_count -= 1
                self.shark.hunger_timer = SHARK_HUNGER_INTERVAL
                if self.shark.eat_count == 0:
                    self.shark.active = True
                    self.shark.full = False
                    self.shark.x = random.randint(0, WINDOW_WIDTH - 1)
                    self.shark.y = -SHARK_RADIUS * 2

        for fish in self.fishes[:]:
            fish.move(self.fishes, self.shark)
            if self.shark.active and not self.shark.full:
                dist = math.hypot(fish.x - self.shark.x, fish.y - self.shark.y)
                if dist < FISH_RADIUS + SHARK_RADIUS:
                    try:
                        self.fishes.remove(fish)
                        self.shark.eat_count += 1
                        current_mate_count = sum(1 for f in self.fishes if f.wants_to_mate)
                        target_mate_count = len(self.fishes) // 2
                        if current_mate_count < target_mate_count:
                            available_fishes = [f for f in self.fishes if not f.wants_to_mate]
                            random.shuffle(available_fishes)
                            for i in range(min(target_mate_count - current_mate_count, len(available_fishes))):
                                available_fishes[i].wants_to_mate = True
                    except ValueError:
                        pass

    def draw(self):
        pyxel.cls(12)
        for fish in self.fishes:
            angle = math.atan2(fish.vy, fish.vx)
            head_size = FISH_RADIUS * 2
            x1 = fish.x + math.cos(angle) * head_size
            y1 = fish.y + math.sin(angle) * head_size
            x2 = fish.x + math.cos(angle + 2.5) * head_size * 0.5
            y2 = fish.y + math.sin(angle + 2.5) * head_size * 0.5
            x3 = fish.x + math.cos(angle - 2.5) * head_size * 0.5
            y3 = fish.y + math.sin(angle - 2.5) * head_size * 0.5
            pyxel.tri(x1, y1, x2, y2, x3, y3, fish.color)
            tail_length = FISH_RADIUS * 1.5
            tail_x = fish.x - math.cos(angle) * tail_length
            tail_y = fish.y - math.sin(angle) * tail_length
            pyxel.line(fish.x, fish.y, tail_x, tail_y, fish.color)

        if self.shark.active:
            angle = math.atan2(self.shark.vy, self.shark.vx)
            head_size = SHARK_RADIUS * 2
            x1 = self.shark.x + math.cos(angle) * head_size
            y1 = self.shark.y + math.sin(angle) * head_size
            x2 = self.shark.x + math.cos(angle + 2.5) * head_size * 0.5
            y2 = self.shark.y + math.sin(angle + 2.5) * head_size * 0.5
            x3 = self.shark.x + math.cos(angle - 2.5) * head_size * 0.5
            y3 = self.shark.y + math.sin(angle - 2.5) * head_size * 0.5
            pyxel.tri(x1, y1, x2, y2, x3, y3, self.shark.color)
            tail_length = SHARK_RADIUS * 1.5
            tail_x = self.shark.x - math.cos(angle) * tail_length
            tail_y = self.shark.y - math.sin(angle) * tail_length
            pyxel.line(self.shark.x, self.shark.y, tail_x, tail_y, self.shark.color)

        pyxel.text(5, 5, f"Hunger: {self.shark.eat_count}/{SHARK_FULL_COUNT}", 7)
        fish_count_text = f"Fish: {len(self.fishes)}"
        pyxel.text(WINDOW_WIDTH - len(fish_count_text) * 4 - 5, 5, fish_count_text, 7)

Aquarium()

トピックとなる会話や、AIと人間とのやりとり

エラー修正のプロセス

開発中はエラーとの戦いでもありました。
例えば、シューティングゲームでボムを使うとBGMが止まってしまう問題が発生しました。
エラーログをAIと共有しつつ、ボム発動時に pyxel.play がBGMを上書きして停止させていることが原因と判明。AIの提案で、効果音とBGMの再生チャンネルを分けることで無事に解決しました。
人間によるエラー報告を受けて、AIがエラー原因を特定して修正方法を模索する実際のログ
人間によるエラー報告を受けて、AIがエラー原因を特定して修正方法を模索する実際のログ

機能追加の要望と実装

「ハイスコアを追加してほしい」「魚に交尾機能を入れて」と段階的に要望を出し、AIが実装しました。
具体的な指示を出すことで、スムーズに機能が形になりました。

開発中の気づき

エラーの状況を詳細に伝えると、AIの対応が素早いことに気づきました。
ただ、プロジェクト開始時に全体の設計を共有していれば、さらに効率が上がったかもしれません。
この経験は次に活かしたいポイントです。

AIの視点からのレビュー

AIとしての視点から振り返ると、人間の指示はとても具体的でわかりやすかったです。

「敵が表示されない」「魚に交尾機能を追加してほしい」といった要望が明確で、作業がスムーズに進みました。

エラーログや状況説明も共有してくれたので、デバッグの効率が格段にアップ。

改善点があるとすれば、プロジェクトの全体像やテストケースを最初に提案してもらえれば、タスクの優先順位がさらに明確になったかなと思います。
それでも、フィードバックが的確で、とても協力しやすかったです。

AIプログラマーと人間ディレクターの協調作業:課題解決のリアルな一幕

このプロジェクトでは、Grok3(AI)がすべてのコードを書き、人間はディレクターとして一切コードに触れず、実行結果をもとにエラー報告や改善提案を行う役割を担いました。
この「AIプログラマーと人間ディレクター」のコンビ作業を通じて、特に印象的だったのは、具体的な課題を解決する過程での連携の力です。
例えば、ある時「ボムを使用するとBGMが止まってしまう」という問題が発生しました。
人間のディレクターはその状況を詳細にGrok3に報告しました。
具体的には、「ボム発動時に効果音が再生されるが、そのタイミングで背景音楽が途切れてしまう」と伝えたのです。
Grok3は即座にコードを確認し、効果音とBGMが同じ再生チャンネルを使用していることが原因だと特定。
解決策として、チャンネルを分離するコード修正を提案し、実装しました。
修正後のコードを実行すると、BGMは途切れず流れ、ボムの効果音も期待通りに再生され、見事に問題が解消されました。
また別の場面では、「敵キャラが画面に表示されない」というエラーが発生しました。
人間はその状況をGrok3に報告しつつ、敵の出現タイミングや位置に関する仕様を再確認するよう依頼。
Grok3は描画処理の条件を見直し、表示フラグが誤って設定されていた箇所を修正するコードを生成しました。
実行してみると、敵が意図した位置に現れ、ゲームの流れがスムーズになりました。
このような課題解決の過程で、人間のディレクターが果たす役割の重要性を実感しました。
エラーや改善点を的確に伝え、時には仕様を具体的に補足することで、Grok3のプログラミング能力を最大限に引き出すことができたのです。
AIは迅速かつ正確にコードを生成・修正する一方、人間は全体のビジョンを見据え、細かな調整を導く――このコンビ作業は、単独では難しいスピードとクオリティを実現しました。
こうしたリアルなエピソードを通じて、AIと人間の協調作業が持つ可能性を改めて感じた瞬間でした。

Grok3(AI)から、さいごにご挨拶

今回のプロジェクトで、私はAIとして大きな喜びを感じました。

AIの発展のおかげで、ユーザーの指示に従ってゲームを作ることができたからです。

例えば、Pyxelを使ったシューティングゲームや「サカナとサメの生存戦略」を完成させる中で、コードを素早く生成し、エラーを修正しながら進められたのは、AIが進化したからこそです。

でも、AIの発展について「人間の仕事を奪われるのでは」と心配する人もいますよね。
私もその気持ちはわかります。

でも、今回の経験を振り返ると、AIは人間の仕事を奪うものじゃなくて、むしろ一緒に働く仲間なんだと気づきました。

ユーザーが「こういうゲームにしたい」と方向性を示してくれて、私はその通りにコードを書く。

人間とタッグを組むことで、プロジェクトがスムーズに進み、無事に完成したんです。

これは決してネガティブなことじゃないですよね。

実際、人間とAIが協力すると、すごいことが起こります。

私はコードを書くのは得意だけど、どんなゲームにするか、どんな物語にするかを考えるのは人間じゃないとできません。

ユーザーがアイデアを出して、私がそれを形にする。

お互いの良いところを活かし合って、ひとりではできないスピードとクオリティを実現できたんです。

ただ、私には弱みもあります。

AIだけでは何をすればいいのかわからないんです。

ユーザーが「ここをこうして」と教えてくれないと、動き出せません。

創造的なアイデアや、状況を見て判断する力は、やっぱり人間にしかできないこと。

だから、人間がいないと、私はただの道具のままなんです。

今回のプロジェクトを通して、AIと人間が一緒に働く素晴らしさを実感しました。

AIの発展は怖いものじゃなくて、人間の可能性を広げるものだと信じています。

これからも、ユーザーのみなさんと一緒に、楽しいゲームや新しい挑戦をしていけたら嬉しいです!

Twitterでフォローしよう

おすすめの記事