STEP12:敵AIの実装(HP割合で回復優先・ランダム性・防御行動)

STEP12では、戦闘が「作業」にならないための 핵(コア)――敵AI を入れます。
前STEPまでの敵は「殴るだけ」や「たまに魔法」だったと思うので、

  • HPが減ったら回復を優先する。
  • 行動にランダム性を混ぜて読めなくする。
  • 防御(Defend)を選ぶこともある。

この3点を実装して、「それっぽい敵」へ進化させます。

敵AIの設計方針(enemies層 vs battle層の役割)

敵AIは 「何をするかを決める」 だけに寄せると保守しやすいです。
実際のダメージ適用・回復適用は battle層が担当します。

役割enemies層battle層
行動決定(攻撃/回復/防御/魔法)
対象決定(誰を狙うか)
(提案)

(最終)
HP/MPの増減、ダメージ適用
defending状態の更新
(敵も味方も統一)

このSTEPでは enemies層に 「行動を返す関数」 を追加します。

行動を表すデータ構造を作る(EnemyAction)

enemies.h に追加(例)

#ifndef ENEMIES_H
#define ENEMIES_H

#include "common.h"

typedef enum {
    EA_ATTACK = 0,
    EA_CAST_DAMAGE = 1,
    EA_CAST_HEAL = 2,
    EA_DEFEND = 3
} EnemyActionType;

typedef struct {
    EnemyActionType type;
    int spell_index;     /* 魔法を使う場合だけ使用(-1なら未使用) */
    int target_index;    /* 攻撃対象(partyの添字)。-1ならbattle側が決める */
} EnemyAction;

EnemyAction enemies_decide_action(const Enemy* e);

/* 既存:生成、名前、魔法などがあるならそのまま */
#endif

ポイント

  • AIは 「type(何する)」「補助情報(spell/target)」 を返すだけ
  • battle層はこの結果を見て適用する

敵AIの材料(HP割合・乱数・性格)

敵が意思決定するために最低限必要なのは HP割合乱数 です。

  • hp_ratio = e->hp * 100 / e->max_hp(整数でOK)
  • ランダム:rand()%100

また、敵タイプによってAIが変わると面白いので、テンプレートに「性格(AI傾向)」を持たせるのがオススメです。

例:Enemyに追加したい項目(common.h/敵構造体側)

  • int ai_heal_threshold;(回復優先に切り替えるHP%)
  • int ai_defend_rate;(防御を選ぶ確率%)
  • int ai_random_rate;(ランダム行動の混ぜ率%)

今回は既存構造体を壊したくない想定で、固定ルールで作ります(後から拡張しやすい形)。

enemies_decide_action の実装(HP割合で回復優先)

基本ルール(今回の仕様)

  • HPが 35%以下 → 回復魔法があるなら 回復優先(ただし80%)
  • それ以外 → 通常は攻撃(60%)+ 攻撃魔法(25%)+ 防御(15%)
  • さらに「ランダム性」を入れる:一定確率で重みを崩す(フェイント)

enemies.c(例)

#include "enemies.h"
#include <stdlib.h>

static int hp_ratio_percent(const Enemy* e) {
    if (e->max_hp <= 0) return 0;
    return (e->hp * 100) / e->max_hp;
}

static int has_heal_spell(const Enemy* e, int* out_index) {
    for (int i = 0; i < ENEMY_SPELL_MAX; i++) {
        if (e->spells[i].mp_cost <= 0) continue;
        if (e->spells[i].type == SPELL_HEAL) {
            if (out_index) *out_index = i;
            return 1;
        }
    }
    return 0;
}

static int has_damage_spell(const Enemy* e, int* out_index) {
    /* 複数あるならランダムで1つ選ぶ(簡易) */
    int list[ENEMY_SPELL_MAX];
    int n = 0;

    for (int i = 0; i < ENEMY_SPELL_MAX; i++) {
        if (e->spells[i].mp_cost <= 0) continue;
        if (e->spells[i].type == SPELL_DMG) {
            list[n++] = i;
        }
    }
    if (n == 0) return 0;

    if (out_index) *out_index = list[rand() % n];
    return 1;
}

/* 重み付き抽選:0..sum-1 を引いて決める */
static int pick_weighted3(int w0, int w1, int w2) {
    int sum = w0 + w1 + w2;
    if (sum <= 0) return 0;
    int r = rand() % sum;
    if (r < w0) return 0;
    r -= w0;
    if (r < w1) return 1;
    return 2;
}

EnemyAction enemies_decide_action(const Enemy* e) {
    EnemyAction a;
    a.type = EA_ATTACK;
    a.spell_index = -1;
    a.target_index = -1;

    int hp = hp_ratio_percent(e);

    /* 1) 低HPなら回復優先 */
    if (hp <= 35) {
        int heal_i = -1;
        if (has_heal_spell(e, &heal_i) && e->mp >= e->spells[heal_i].mp_cost) {
            int r = rand() % 100;

            /* 80%で回復、20%でフェイント(防御or攻撃) */
            if (r < 80) {
                a.type = EA_CAST_HEAL;
                a.spell_index = heal_i;
                return a;
            }

            /* フェイント:半々で防御/攻撃 */
            if (r < 90) {
                a.type = EA_DEFEND;
                return a;
            }

            a.type = EA_ATTACK;
            return a;
        }
        /* 回復できないなら防御寄りにする */
        if ((rand() % 100) < 35) {
            a.type = EA_DEFEND;
            return a;
        }
    }

    /* 2) 通常時:攻撃/攻撃魔法/防御を重みで選ぶ */
    int dmg_i = -1;
    int can_dmg_spell = has_damage_spell(e, &dmg_i) && (e->mp >= e->spells[dmg_i].mp_cost);

    int w_attack = 60;
    int w_spell  = can_dmg_spell ? 25 : 0;
    int w_defend = 15;

    /* 3) ランダム性:15%で重みを崩す(予測不能にする) */
    if ((rand() % 100) < 15) {
        w_attack = 45;
        w_spell  = can_dmg_spell ? 35 : 0;
        w_defend = 20;
    }

    int pick = pick_weighted3(w_attack, w_spell, w_defend);

    if (pick == 1 && can_dmg_spell) {
        a.type = EA_CAST_DAMAGE;
        a.spell_index = dmg_i;
        return a;
    }
    if (pick == 2) {
        a.type = EA_DEFEND;
        return a;
    }

    a.type = EA_ATTACK;
    return a;
}

battle層に接続する(敵ターンの置き換え)

STEP11では敵ターンで

  • 魔法を打つか判定 → 失敗なら通常攻撃

みたいな処理になっていたと思います。
これを EnemyAction を受け取って実行する形に置き換えます。

battle.c の敵ターン例

static void enemy_take_turn(Enemy* enemy, Character party[PARTY_SIZE]) {
    EnemyAction act = enemies_decide_action(enemy);

    if (act.type == EA_DEFEND) {
        enemy->defending = 1;
        printf("%s is defending!\n", enemy->name);
        sfx_beep(0);
        return;
    }

    if (act.type == EA_CAST_HEAL) {
        Spell sp = enemy->spells[act.spell_index];
        if (enemy->mp >= sp.mp_cost) {
            enemy->mp -= sp.mp_cost;

            int before = enemy->hp;
            enemy->hp += sp.power;
            if (enemy->hp > enemy->max_hp) enemy->hp = enemy->max_hp;

            printf("%s casts %s! HP +%d\n", enemy->name, sp.name, enemy->hp - before);
            sfx_beep(2);
            return;
        }
        /* MP足りないなら通常攻撃にフォールバック */
    }

    if (act.type == EA_CAST_DAMAGE) {
        Spell sp = enemy->spells[act.spell_index];
        if (enemy->mp >= sp.mp_cost) {
            enemy->mp -= sp.mp_cost;

            int t = pick_random_alive_index(party);
            if (t >= 0) {
                int base = enemy->base_mag * sp.power - party[t].def;
                base = clamp_min(base, 1);
                int dmg = apply_variance(base);

                printf("%s casts %s!\n", enemy->name, sp.name);
                apply_damage_to_char(&party[t], dmg);
                return;
            }
        }
        /* 失敗なら通常攻撃へ */
    }

    enemy_attack_party(enemy, party);
}

そして battle_run の敵ターン部分を

printf("\nEnemy turn\n");
enemy_take_turn(enemy, party);

に置き換えればOKです。

動作確認(AIっぽさをチェック)

状況期待する動き
敵HPが35%以下回復魔法があれば高確率で回復
敵HPが少ないが回復できない防御が混ざる(粘る)
通常時攻撃中心だが、たまに防御・たまに攻撃魔法
毎ターン同じにならないランダム性で偏りが崩れる。

演習問題+模範解答例

演習:敵の「瀕死モード」を追加する

問題
敵HPが15%以下になったら、行動比率を
防御 40% / 回復 40% / 攻撃 20% に変更してください(回復魔法がある場合)。

模範解答例(enemies_decide_action の低HP分岐の中に追加)

if (hp <= 15) {
    int heal_i = -1;
    int can_heal = has_heal_spell(e, &heal_i) && e->mp >= e->spells[heal_i].mp_cost;

    int r = rand() % 100;

    if (can_heal && r < 40) {
        a.type = EA_CAST_HEAL;
        a.spell_index = heal_i;
        return a;
    }
    if (r < 80) {
        a.type = EA_DEFEND;
        return a;
    }
    a.type = EA_ATTACK;
    return a;
}

STEP12のまとめ

  • enemies層に「行動決定関数(EnemyAction)」を追加すると、AI拡張が簡単
  • HP割合で回復優先を入れるだけで敵が“生き物っぽく”なる。
  • 防御行動を混ぜると、戦闘が単調になりにくい。
  • ランダム性は「少しだけ」入れるのがコツ(入れすぎると理不尽)