もどる

この記事は「うずら」とAIが協力して作成しました。
なるべく正確さを心がけていますが、最新の公式ドキュメントなどもあわせて確認してみてね!

python2026.3.18

4-8. 決戦の刻:HPによる状態変化(モードチェンジ)

実践:決戦
モード変化

1. 3秒でわかるまとめ:ボスは状況によって戦い方を変える!

今回のテーマは「ボス戦」のさらなる深化です。強力なボスキャラクターを相手にする際、残りHP(ヒットポイント)が少なくなると、急に攻撃が激しくなったり、動きが変わったりする演出を見たことがある方も多いでしょう。

これは、ボスの「状態」をプログラムで管理し、その状態に応じて行動を変化させることで実現します。まさに「モードチェンジ」ですね。

2. ボスに「状態」を持たせる

前回の記事 実践:弾幕回避せよ では、巨大なボスを出現させ、左右に移動させながら弾を撃つところまで実装しました。今回はこのボスに、HPによって行動が変わる「状態」を持たせてみましょう。

まず、ボスを表す辞書 boss に、新しいキーを追加します。

# ボスデータ
boss = {
    'x': SCREEN_WIDTH // 2 - BOSS_W // 2,
    'y': -40,
    'hp': 125,
    'max_hp': 125,
    'dir': 1,
    'state': 'normal',          # ★追加: 現在の状態
    'move_phase': 0,            # ★追加: 移動フェーズ(stateがmovingの場合に使用)
    'triggered_half': False,    # ★追加: HP半分時のフラグ
    'triggered_low': False      # ★追加: HP低下時のフラグ
}
  • 'state': ボスの現在の状態を表す文字列です。最初は 'normal'(通常)にしておきます。
  • 'move_phase': ボスが特定の動きをする際に、どの段階にいるかを管理するための数値です。
  • 'triggered_half''triggered_low': ボスのHPが特定の割合を下回ったときに、一度だけイベントを起こすためのフラグです。

補足だよ

'normal''moving'のような文字列は、プログラマーが自由に決めることができます。意味が分かりやすい名前をつけることが大切です。

3. update関数でのモードチェンジ処理

いよいよ、update関数内でボスの行動を状態に応じて変化させる処理を記述します。

3-1. HPによる状態変化のトリガー

ボスの行動を更新するブロック (if boss:) の中に、以下のコードを追加します。

    # ボスの行動
    if boss:
        # HPトリガーによるモードチェンジ (初回のみ発動)
        if boss['hp'] <= boss['max_hp'] // 2 and not boss['triggered_half']:
            boss['state'] = 'moving'
            boss['move_phase'] = 1
            boss['triggered_half'] = True
            # <div class="bg-blue-50 border-2 border-dashed border-blue-300 p-5 md:p-8 my-10 rounded-2xl text-blue-900">
  <div class="flex items-center gap-3 mb-2">
    <img src="/icons/icon-denkyu.png" alt="" class="w-8 h-8 flex-shrink-0" />
    <h4 class="font-black text-blue-600">
      豆知識
    </h4>
  </div>
  <div class="md:pl-10 text-sm md:text-base font-bold">
    ここで「ボスのHPが半分になった!」といった専用の弾幕を生成したり、<br />            # 音楽を変えたりする処理を追加すると、演出がさらに盛り上がります。
  </div>
</div>

        # 状態ごとの動き
        if boss['state'] == 'normal':
            # ... (中略:前回の記事で実装した通常移動と弾の発射) ...
        
        elif boss['state'] == 'moving':
            # ... (後述:新しい移動パターンを実装) ...

この部分では、ボスのHPが最大HPの半分以下になったときに、まだモードチェンジを起こしていなければ、'state''moving'に切り替えています。'triggered_half'フラグは、このモードチェンジが一回だけ行われるようにするためのものです。

3-2. 状態に応じた行動ロジック

次に、'state'の値によってボスの行動を分岐させます。

        # 状態ごとの動き
        if boss['state'] == 'normal':
            # 上から降下
            if boss['y'] < 10:
                boss['y'] += 0.5
            else:
                # 左右移動
                boss['x'] += boss['dir']
                if boss['x'] < 10 or boss['x'] > SCREEN_WIDTH - BOSS_W - 10:
                    boss['dir'] *= -1
                
                # 定期的に弾を発射
                if pyxel.frame_count % 50 == 0:
                    spawn_boss_bullets(boss['x'] + BOSS_W//2, boss['y'] + BOSS_H)
        
        elif boss['state'] == 'moving':
            speed = 6
            if boss['move_phase'] == 1: # 右へ
                boss['x'] += speed
                if boss['x'] > SCREEN_WIDTH - BOSS_W - 5: boss['move_phase'] = 2
            elif boss['move_phase'] == 2: # 下へ
                boss['y'] += speed
                if boss['y'] > SCREEN_HEIGHT - BOSS_H - 5: boss['move_phase'] = 3
            elif boss['move_phase'] == 3: # 左へ
                boss['x'] -= speed
                if boss['x'] < 5: boss['move_phase'] = 4
            elif boss['move_phase'] == 4: # 上へ
                boss['y'] -= speed
                if boss['y'] < 10: boss['move_phase'] = 5
            elif boss['move_phase'] == 5: # 中央へ戻る
                center_x = SCREEN_WIDTH // 2 - BOSS_W // 2
                if boss['x'] < center_x: boss['x'] += speed
                if boss['x'] > center_x: boss['x'] -= speed
                if abs(boss['x'] - center_x) < speed:
                    boss['state'] = 'normal' # 移動が終わったらnormal状態に戻る
  • if boss['state'] == 'normal': のブロックには、これまでの左右移動と弾発射のロジックが入ります。
  • elif boss['state'] == 'moving': のブロックでは、新しい移動パターンを定義しています。
    • 'move_phase' を使って、右、下、左、上と画面の端をぐるっと回るような動きを実装します。
    • 中央に戻ったら、ボスの'state'を再び'normal'に戻します。

これにより、ボスのHPが半分を切ると、一時的に画面の端を高速で移動し、その後通常の左右移動に戻る、という変化が生まれます。

ボスの「状態」を管理するデザインパターンとは?

このように、キャラクターやオブジェクトの振る舞いを複数の「状態」に分け、特定の条件で状態を切り替えるプログラミングの考え方を「ステートマシン(状態機械)」または「状態遷移パターン」と呼びます。複雑なキャラクターのAIや、ゲームの進行管理など、様々な場面で活用される非常に強力なテクニックです。

4. 完成コードと実行

上記の変更を適用した完全なコードは以下の通りです。

4-8_boss_state.py

import pyxel
import random

# =========================================================
#  ASTRO SURVIVOR
#  - 宇宙シューティングゲーム -
# =========================================================

# --- 定数設定 ---
SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
STAR_COUNT = 40
GAME_TITLE = "ASTRO SURVIVOR"

# キャラクターのサイズ
PLAYER_W = 8
PLAYER_H = 8
PLAYER_SPEED = 3

ENEMY_W = 8
ENEMY_H = 8

BOSS_W = 24
BOSS_H = 24

# 色の定義
COL_BLACK   = 0
COL_NAVY    = 1
COL_PURPLE  = 2
COL_GREEN   = 3
COL_BROWN   = 4
COL_DBLUE   = 5
COL_LBLUE   = 6
COL_WHITE   = 7
COL_RED     = 8
COL_ORANGE  = 9
COL_YELLOW  = 10
COL_L_GREEN = 11
COL_CYAN    = 12
COL_GRAY    = 13
COL_PINK    = 14
COL_PEACH   = 15


# --- Pyxelの初期化と音の定義 ---
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title=GAME_TITLE)

# --- 変数の初期化 ---
# プレイヤー
player_x = SCREEN_WIDTH // 2 - PLAYER_W // 2
player_y = SCREEN_HEIGHT - 20

# ゲームオブジェクト
bullets   = [] # [x, y, vx, vy]
enemies   = [] # Dictionary {'x', 'y', 'type', ...}
particles = [] # Dictionary
items     = [] # [x, y, kind]
stars     = [] # [x, y, speed]

# 星空の準備
for _ in range(STAR_COUNT):
    stars.append([
        random.randint(0, SCREEN_WIDTH),
        random.randint(0, SCREEN_HEIGHT),
        random.randint(1, 3)
    ])

# ゲーム進行状況
score = 0
game_over = False
boss_active = False

# パワーアップ & ボム
power_level = 1
power_timer = 0
num_bombs = 1
is_bomb_active = False
bomb_radius = 0

# ボスデータ
boss = None


# =========================================================
#  関数定義 (エフェクト生成など)
# =========================================================

# 爆発エフェクトなどを生成する
def spawn_particles(x, y, count, color):
    for _ in range(count):
        particles.append({
            'x': x, 
            'y': y,
            'vx': random.uniform(-2.0, 2.0),
            'vy': random.uniform(-2.0, 2.0),
            'life': random.randint(10, 20),
            'color': color
        })

# ボムを発動する
def trigger_bomb():
    global num_bombs, is_bomb_active, bomb_radius
    if num_bombs > 0 and not is_bomb_active:
        num_bombs -= 1
        is_bomb_active = True
        bomb_radius = 5

# 敵を出現させる
def spawn_enemy():
    is_fast = random.random() < 0.3
    kind = 'fast' if is_fast else 'zako'

    enemies.append({
        'x': random.randint(0, SCREEN_WIDTH - ENEMY_W),
        'y': -10,
        'vx': 0,
        'vy': random.uniform(1.5, 2.5) if is_fast else random.uniform(0.5, 0.8),
        'hp': 1,
        'type': kind,
        'w': ENEMY_W, 'h': ENEMY_H,
        'score': 20 if is_fast else 10,
        'color': COL_RED if is_fast else COL_GRAY
    })

# ボスから弾を発射する
def spawn_boss_bullets(bx, by):
    # 5方向への扇状ショット
    for angle in range(-2, 3):
        vx = angle * 0.8
        vy = 1.5
        enemies.append({
            'x': bx, 'y': by,
            'vx': vx, 'vy': vy,
            'hp': 1,
            'type': 'bullet',
            'w': 4, 'h': 4,
            'score': 0
        })

# アイテムを出現させる
def drop_item(x, y, kind):
    items.append([x, y, kind])

# 矩形同士の当たり判定
def check_collision(x1, y1, w1, h1, x2, y2, w2, h2):
    return (x1 < x2 + w2 and x1 + w1 > x2 and
            y1 < y2 + h2 and y1 + h1 > y2)


# =========================================================
#  メインループ (Update & Draw)
# =========================================================

# 毎フレーム実行される更新処理 (計算などはここで行う。描画は禁止)
def update():
    global player_x, player_y, score, game_over
    global power_level, power_timer, num_bombs, is_bomb_active, bomb_radius
    global boss, boss_active
    global bullets, enemies, particles, items

    # --- 1. ゲームオーバー / クリアチェック ---
    if game_over:
        return

    # --- 2. システム更新 (ボム & パワーアップ) ---
    if is_bomb_active:
        bomb_radius += 4
        if bomb_radius > 200:
            is_bomb_active = False
            bomb_radius = 0
            
    if power_timer > 0:
        power_timer -= 1
        if power_timer <= 0:
            power_level = 1

    # --- 3. プレイヤー操作 ---
    if pyxel.btn(pyxel.KEY_LEFT):  player_x = max(player_x - PLAYER_SPEED, 0)
    if pyxel.btn(pyxel.KEY_RIGHT): player_x = min(player_x + PLAYER_SPEED, SCREEN_WIDTH - PLAYER_W)
    if pyxel.btn(pyxel.KEY_UP):    player_y = max(player_y - PLAYER_SPEED, 0)
    if pyxel.btn(pyxel.KEY_DOWN):  player_y = min(player_y + PLAYER_SPEED, SCREEN_HEIGHT - PLAYER_H)

    # 弾の発射
    if pyxel.btnp(pyxel.KEY_SPACE):
        # 通常弾
        if power_level == 1:
            bullets.append([player_x + 3, player_y - 4, 0, -5])
        # パワーアップ弾 (3方向)
        elif power_level >= 2:
            bullets.append([player_x + 3, player_y - 4, 0, -5])
            bullets.append([player_x + 3, player_y - 4, -2, -4])
            bullets.append([player_x + 3, player_y - 4,  2, -4])

    # ボム発動
    if pyxel.btnp(pyxel.KEY_Z) or pyxel.btnp(pyxel.KEY_B):
        trigger_bomb()

    # --- 4. オブジェクト更新 ---
    # 弾の移動
    surviving_bullets = []
    for b in bullets:
        b[0] += b[2] # vx
        b[1] += b[3] # vy
        # 画面外でなければ残す
        if b[1] >= -10 and b[0] >= -10 and b[0] <= SCREEN_WIDTH + 10:
            surviving_bullets.append(b)
    bullets = surviving_bullets

    # 敵の出現
    if not boss_active:
        # 【一時的】ボスの動作確認のため、最初からボスを出現させる
        # 本来は if score >= 500: の条件
        if True:
            boss_active = True
            boss = {
                'x': SCREEN_WIDTH // 2 - BOSS_W // 2,
                'y': -40,
                'hp': 125,
                'max_hp': 125,
                'dir': 1,
                'state': 'normal',
                'move_phase': 0,
                'triggered_half': False,
                'triggered_low': False
            }
        elif pyxel.frame_count % 30 == 0:
            spawn_enemy()

    # ボスの行動
    if boss:
        # HPトリガーによるモードチェンジ (初回のみ発動)
        if boss['hp'] <= boss['max_hp'] // 2 and not boss['triggered_half']:
            boss['state'] = 'moving'
            boss['move_phase'] = 1
            boss['triggered_half'] = True

        # 状態ごとの動き
        if boss['state'] == 'normal':
            # 上から降下
            if boss['y'] < 10:
                boss['y'] += 0.5
            else:
                # 左右移動
                boss['x'] += boss['dir']
                if boss['x'] < 10 or boss['x'] > SCREEN_WIDTH - BOSS_W - 10:
                    boss['dir'] *= -1
                
                # 定期的に弾を発射
                if pyxel.frame_count % 50 == 0:
                    spawn_boss_bullets(boss['x'] + BOSS_W//2, boss['y'] + BOSS_H)
        
        elif boss['state'] == 'moving':
            speed = 6
            if boss['move_phase'] == 1: # 右へ
                boss['x'] += speed
                if boss['x'] > SCREEN_WIDTH - BOSS_W - 5: boss['move_phase'] = 2
            elif boss['move_phase'] == 2: # 下へ
                boss['y'] += speed
                if boss['y'] > SCREEN_HEIGHT - BOSS_H - 5: boss['move_phase'] = 3
            elif boss['move_phase'] == 3: # 左へ
                boss['x'] -= speed
                if boss['x'] < 5: boss['move_phase'] = 4
            elif boss['move_phase'] == 4: # 上へ
                boss['y'] -= speed
                if boss['y'] < 10: boss['move_phase'] = 5
            elif boss['move_phase'] == 5: # 中央へ戻る
                center_x = SCREEN_WIDTH // 2 - BOSS_W // 2
                if boss['x'] < center_x: boss['x'] += speed
                if boss['x'] > center_x: boss['x'] -= speed
                if abs(boss['x'] - center_x) < speed:
                    boss['state'] = 'normal'

        # ボス vs プレイヤー
        if check_collision(player_x, player_y, PLAYER_W, PLAYER_H, boss['x'], boss['y'], BOSS_W, BOSS_H):
            game_over = True

        # ボス vs 弾
        for b_idx in reversed(range(len(bullets))):
            bx, by = bullets[b_idx][0], bullets[b_idx][1]
            if (boss['x'] < bx < boss['x'] + BOSS_W and
                boss['y'] < by < boss['y'] + BOSS_H):
                
                boss['hp'] -= 1
                spawn_particles(bx, by, 5, COL_YELLOW)
                
                del bullets[b_idx]
                
                if boss['hp'] <= 0:
                    boss = None
                    score += 1000
                    boss_active = False
                    spawn_particles(player_x, player_y - 20, 100, COL_RED) 
                    break

    # 敵の更新
    surviving_enemies = []
    for e in enemies:
        if e['type'] == 'bullet':
            e['x'] += e['vx']
            e['y'] += e['vy']
        else:
            e['y'] += e['vy']
            
        destroyed = False
        if is_bomb_active:
             # ボムの範囲 (中心座標から左上座標を計算)
             bomb_x = player_x + 4 - bomb_radius
             bomb_y = player_y + 4 - bomb_radius
             bomb_size = bomb_radius * 2
             
             if check_collision(bomb_x, bomb_y, bomb_size, bomb_size, e['x'], e['y'], ENEMY_W, ENEMY_H):
                 destroyed = True

        if destroyed:
            spawn_particles(e['x'], e['y'], 5, COL_WHITE)
            if e['type'] != 'bullet': score += 10
            continue # del せずにループ続行 (appendされないので消える)

        if e['y'] > SCREEN_HEIGHT:
            continue

        hit_w = ENEMY_W if e['type'] != 'bullet' else 4
        if check_collision(player_x, player_y, PLAYER_W, PLAYER_H, e['x'], e['y'], hit_w, hit_w):
            game_over = True
            
        hit = False
        if e['type'] != 'bullet':
            for b_idx in reversed(range(len(bullets))):
                bx, by = bullets[b_idx][0], bullets[b_idx][1]
                if (e['x'] < bx < e['x'] + ENEMY_W and
                    e['y'] < by < e['y'] + ENEMY_H):
                    del bullets[b_idx]
                    hit = True
                    break
        
        if hit:
            spawn_particles(e['x'], e['y'], 5, e['color'])
            score += e['score']
            
            dice = random.random()
            if dice < 0.08: drop_item(e['x'], e['y'], 'bomb')
            elif dice < 0.18: drop_item(e['x'], e['y'], 'power')
            continue
        
        surviving_enemies.append(e)
    enemies = surviving_enemies

    # アイテム更新
    surviving_items = []
    for item in items:
        item[1] += 1
        ix, iy, kind = item
        
        if check_collision(player_x, player_y, PLAYER_W, PLAYER_H, ix, iy, 4, 4):
            if kind == 'power':
                power_level = 2
                power_timer = 300
            elif kind == 'bomb':
                num_bombs += 1
            continue # 取得したのでリストに追加しない (消える)

        if iy <= SCREEN_HEIGHT:
            surviving_items.append(item)
    items = surviving_items

    # パーティクル更新
    surviving_particles = []
    for p in particles:
        p['x'] += p['vx']
        p['y'] += p['vy']
        p['life'] -= 1
        if p['life'] > 0:
            surviving_particles.append(p)
    particles = surviving_particles



# 毎フレーム実行される描画処理 (結果を画面に表示する。計算はしない)
def draw():
    pyxel.cls(COL_BLACK)
    
    # 背景
    for star in stars:
        pyxel.pset(star[0], star[1], COL_WHITE if star[2] > 1 else COL_GRAY)

    # 衝撃波
    if is_bomb_active:
        pyxel.circb(player_x + 4, player_y + 4, bomb_radius, COL_WHITE)
        pyxel.circb(player_x + 4, player_y + 4, bomb_radius - 2, COL_LBLUE)

    # ゲームオーバー
    if game_over:
        pyxel.text(55, 50, "GAME OVER", COL_RED)
        pyxel.text(45, 60, f"SCORE: {score}", COL_WHITE)
        return
        
    # プレイヤー
    col = COL_ORANGE
    if power_level >= 2:
        if power_timer < 90 and pyxel.frame_count % 4 < 2: col = COL_ORANGE
        else: col = COL_CYAN
    pyxel.rect(player_x, player_y, PLAYER_W, PLAYER_H, col)
    pyxel.rect(player_x+3, player_y-2, 2, 2, COL_YELLOW)

    # 弾
    for b in bullets:
        pyxel.rect(b[0], b[1], 2, 4, COL_L_GREEN)

    # 敵
    for e in enemies:
        if e['type'] == 'bullet':
            pyxel.circ(e['x'], e['y'], 2, COL_YELLOW)
        else:
            col = e['color']
            pyxel.rect(e['x'], e['y'], e['w'], e['h'], col)
            if e['type'] == 'zako':
                pyxel.rect(e['x']+2, e['y']+2, 4, 4, COL_NAVY)
            else:
                pyxel.pset(e['x']+3, e['y']+3, COL_WHITE)
            
    # ボス
    if boss:
        if boss['hp'] > 20 or pyxel.frame_count % 4 < 2:
            pyxel.rect(boss['x'], boss['y'], BOSS_W, BOSS_H, COL_PINK)
            bar_w = (boss['hp'] / boss['max_hp']) * BOSS_W
            pyxel.rect(boss['x'], boss['y'] - 3, bar_w, 2, COL_RED)

    # アイテム
    for item in items:
        char = "P" if item[2] == 'power' else "B"
        col  = COL_YELLOW if item[2] == 'power' else COL_CYAN
        pyxel.text(item[0], item[1], char, col)

    # UI
    pyxel.text(5, 5, f"SCORE:{score}", COL_WHITE)
    pyxel.text(120, 5, f"BOMB:{num_bombs}", COL_CYAN)
    if boss and boss_active:
        pyxel.text(60, 5, "WARNING: BOSS", COL_RED)

pyxel.run(update, draw)

このコードを実行して、実際にブラウザで遊んでみてください。ボスのHPが半分になった瞬間に動きが変わるはずです。

実際に動かしてみよう!

矢印キーで宇宙船を操作、スペースキーで弾を発射、Zキーでボムを発動できます。ボスのHPが半分になると動きが変わります。

5. 次のステップ

今回はHPが半分になったら一度だけモードチェンジを行うようにしましたが、さらにHPが減ると別のモードに切り替わるようにしたり、時間でモードを切り替えたりすることも可能です。
次の記事では、いよいよゲームを完成させ、タイトル画面やエンディングを追加していきます。
これであなたも立派なゲーム開発者ですね。 伝説の完結ゲーム完成

最後まで読んでくれてありがとう!🌱
ノートみたいに、いつでも見返してね。