アクションゲームのための「実戦的」簡易ボスAIの作り方[Unity, C#, 共通, ゲームAI]
Augustです.

今回の記事は, 僕が秋ゲームジャムで作った簡易アクションゲームBoss用のコントロールスクリプトの解説と, 似たようなシステムの作り方について書いたものです.
https://github.com/August13742/RiGGameJam202510_SurvivorLike (ソースコード)
所要知識として, 通常のC#やゲームエンジン知識の上に, Coroutine, Finite State Machineについて多少知る必要がある.
目次
簡易ゲームAI
今回紹介するゲームAIの手法は, 本当は非常にシンプルである.
考えとしては,
「攻撃頻度や攻撃の派手さを上げれば, 不自然な動きに注意向けなくする」
という.
本当は, 非常に多くのゲーム(大手含む)のゲームAIも, この理論で動いている.
なお, 「簡易」っていうのは, 「ターゲット探索なし」, 「相互インタラクションなし」という方面から私がそう判断してそう読んでいるだけである.
要は, 今回解説するのは一方的にプレイヤーに追いかけ, 攻撃演出を繰り返すだけの”ゲームAI”.
では早速始めよう.
キャラクターコントローラーと必要Utility
コンテキストとしてこの記事で記載されるボスキャラクターのコントローラー仕組みも簡単に説明する.
紹介されたコードは2Dゲーム用とはいえ, 考え方は共通する.
まず, このゲームは物理シミュレーションではないため, 全物体はKinematic RigidBodyが使われる.
Kinematicは, 外部の力は全く働かないため, 自分で速度, 加速度, 摩擦を定義し, 基本移動計算を実装する.
[Header("Movement")]
[Tooltip("The current velocity of the boss, calculated via acceleration and friction.")]
[field: SerializeField] public Vector2 Velocity { get; set; }
[Tooltip("A forced velocity for special moves like dashes, bypassing acceleration.")]
[field: SerializeField] public Vector2 VelocityOverride { get; set; }
[Tooltip("The intended direction of movement, set by the current state.")]
[field: SerializeField] public Vector2 Direction { get; set; }
[SerializeField] private float acceleration = 75f;
[SerializeField] private float friction = 35f;
void FixedUpdate()
{
if (IsDead) return;
Vector2 finalVelocity;
// 最高優先度: ダッシュなど, 特殊動作用の速度変数 VelocityOverrideを使う
if (VelocityOverride.sqrMagnitude > 0.01f)
{
finalVelocity = VelocityOverride;
Velocity = finalVelocity;
}
else // 優先度2: 通常加速・摩擦力モデルを使う
{
Vector2 targetVelocity = Direction * config.ChaseSpeed;
Velocity = Vector2.MoveTowards(Velocity, targetVelocity, acceleration * Time.fixedDeltaTime);
if (Direction.sqrMagnitude < 0.01f)
{
Velocity = Vector2.MoveTowards(Velocity, Vector2.zero, friction * Time.fixedDeltaTime);
}
finalVelocity = Velocity;
}
// 仲裁結果となる速度で計算
RB.MovePosition(RB.position + finalVelocity * Time.fixedDeltaTime);
if(_currentState?.GetType() != typeof(StateAttack)) UpdateFacing();
}特殊動作のために優先度の高い変数を用意して仲裁で計算する.
これは僕は, できるだけ「Single Source of Truth が保たれるコードを書くべき」, だと思う思想で書いている.
これは, デバグ過程で「この変数は同時に一箇所しかいじられない」ということが保証され, 追いやすいからである.
アニメーション コールバック処理
どのエンジンでも, アニメーションを作るとき, 「このタイミングでこの関数呼ぶべき」という, イベントを置くことができる

ここで,

この図のように, このAnimator が存在するGameObjectがもつ他のコンポーネント(スクリプト)で定義している関数を呼ぶことができる.
しかし, ここには 個人的に気に食わない仕組みがある. ここで選択できるのは, 「このAnimator が存在するGameObjectがもつ他のコンポーネント(スクリプト)で定義している関数」しか呼べない.
要は, 親や子物体はダメ, 定義していない関数はダメ(当然ながら)
つまり, ここで, コールバック機能が使いたければ, Animatorが存在すべき物体構造が限られ, 呼びたい関数も存在する必要がある.
つまり, 「普遍性は考えるな」と言ってるようだ.
因みにこの問題は設計理念の衝突で, バグではない, Unityだけではなく, Godotもこのような設計を取り込んでいる.
これの解決は, 初心者? にとって単純ではないが, 一応できる.
単純に言うと, EventBusというデザインパターンを使えばいい.EventBusはObserverパターンの応用で, 雑に言うと,
aイベントが発生->「A, お前a'関数を実行しろ」
のではなく,
「aイベントが発生した, 興味あるなら行動しろ」
という…ちょっと分かりにくいかもしれない
実際に何をやるかというと,
using System;
using UnityEngine;
[DisallowMultipleComponent]
public sealed class AnimationEventBus : MonoBehaviour
{
// Consumers subscribe to this once (e.g., in Start).
public event Action<AnimationEvent> Fired;
// Put this component on the same GameObject as the Animator.
// In the AnimationEvent's Function field, type exactly "AE".
public void AE(AnimationEvent e) => Fired?.Invoke(e);
}この部品をAnimatorが存在するGameObjectに貼り付ける.
アニメーションでコールバック付けたいとき, このAE関数を呼ぶ(名前任意)

AnimationEventは, Unityが定義したクラスで, メッセージPayloadが用意される. (ここでGarbage Collectionを避けようとして自分でPayload用Structを定義しても意味がない. AnimationEventはAnimatorでコールバック呼ぶ時点で既に発生するから)
その後, これの受け取り側に,
if (Visuals.TryGetComponent<AnimationEventBus>(out var bus))
bus.Fired += OnAnimEvent;でイベントを購読して,
private void OnAnimEvent(AnimationEvent e)
{
switch (e.stringParameter)
{
case "hitbox_melee_on":
ToggleMeleeHitbox(true);
break;
case "hitbox_melee_off":
ToggleMeleeHitbox(false);
break;
case "projectile":
SpawnProjectile(firePoint == null ? (Vector2)transform.position : (Vector2)firePoint.position);
break;
case "attack_start":
HandleAttackTelegraphBegin();
break;
case "attack_end":
HandleAttackTelegraphEnd();
break;
case "dead":
Destroy(gameObject);
break;
}
}ここで, 実際に使いたいパラメーターイベントをフィルタリングする. (この例ではString型メッセージしか使ってないが, int,float, objectなど, 様々な型も対応する.)
これで, Animatorの置く場所も, 実際にそのコールバックに対応する必要もなくなり, アニメーションは普遍性を持つようになる. (そのイベントがいらないならスパムメッセージとして無視すればいい, イメージ通り)
更に改良したければ, Stringではなく, Hash String (StringName) でやってもいいのかもしれない.
ボスAI詳細
索敵範囲とバンド
対象との距離によって, バンド距離を定義する. ここの場合, 「近接攻撃範囲, 戦闘(=遠隔攻撃)範囲, 索敵範囲」として3段階定義している.
これをベースに, 次は詳細のロジックが書けるようになる.
ダイナミック重み付き攻撃パターン選択
ルーレット選択のアルゴリズム
まずルーレット選択(Roulette Wheel Selection, またはWeighted Random Selectionなど, 名前は分野によって異なる)について説明しないといけない.
考えは簡単. 全てのアイテムの重みの和=分母.
例えば, 3つのアイテムが存在する. その重みは, [1,2,3]とする.
ここ場合, 和は1+2+3 = 6. この中から一個アイテム選べようとすると, 各アイテムが選ばれる確率は,
1/6 ≈ 17%,
2/6 =1/3 ≈ 33%,
3/6 = 1/2 = 50%
になる.
実際にこれはどうやって実装するかというと
float total = 0f;
for (int i = 0; i < attacks.Count; i++)
total += Mathf.Max(0f, attacks[i].Weight);
if (total <= 0f)
return attacks[attacks.Count - 1];
float r = Random.value * total; // Random.value() の戻り値は 0..1
for (int i = 0; i < attacks.Count; i++)
{
float w = Mathf.Max(0f, attacks[i].Weight);
if (r < w) return attacks[i];
r -= w;
}
ここもバンド(Band,帯?)の概念を使えば通じるかもしれない.
簡単に説明すると, [0, 全重みの和]の範囲内で乱数を取る.
同時に, アイテムが代表する「値の帯」を計算し, 乱数が落ちだ帯を代表するアイテムが, 選択される.
因みにこのアルゴリズムは, ドロップアイテムなど, ゲームだけでもあらゆるところで使われる.
ダイナミック重み調整
「近接攻撃パターンを, 近接攻撃範囲外で使わせたくない」
「近接攻撃範囲内は, 近接攻撃パターンを重点的に使わせたいが, 遠距離攻撃パターンも使わせたい」
「BossのHPが一定数に落ちたら, 技リストを替えたい」
「BossのHPが50%以下に落ちたら, この攻撃技発生させたい」
などなど,
そう考う君に, 「ダイナミック重み調整」が必要.
これも難しくない.
要は, 毎回ルーレット選択する対象リストを再ビルドし, その時, 対象を選別・重みに係数掛ければいい.
public bool TryBuildCandidatesForDistance(float dist, out List<ScriptableAttackDefinition> result)
{
result = null;
var band = GetBand(dist);
if (band == RangeBand.OffBand) return false;
var list = new List<ScriptableAttackDefinition>();
if (band == RangeBand.Pocket) // <- 攻撃範囲にいる?
{
foreach (var def in config.AttackPatterns)
{
if (def.Category == AttackCategory.Ranged && !IsAttackTagOnCooldown(def.CooldownTag))
list.Add(def); //<- 遠隔攻撃だけ入れる
}
}
else // <- ここは近接攻撃範囲に居る時しか遷移されない
{
foreach (var def in config.AttackPatterns)
{
if (def.Category == AttackCategory.Melee && !IsAttackTagOnCooldown(def.CooldownTag))
list.Add(def);
}
foreach (var def in config.AttackPatterns)
{
if (def.Category == AttackCategory.Ranged && !IsAttackTagOnCooldown(def.CooldownTag))
{
list.Add(new ScriptableAttackDefinition
{
Category = def.Category,
Pattern = def.Pattern,
CooldownTag = def.CooldownTag,
Cooldown = def.Cooldown,
Weight = def.Weight * Mathf.Max(0f, config.RangedInMeleeWeightMultiplier)
// 近接距離に居る時, 遠距離攻撃パターンの重みにペナルティをつける↑
});
}
}
}
if (list.Count == 0) return false;
result = list;
return true;
}残りは, ただのフラグ管理や変数の入れ替えで実装できる
例えば, HP一定落ちたら狂暴化させ, 大技ぶっ放せたいなら
if (IsEnraged && _enrageActionPending && config.EnrageAction != null)
{
for (int i = 0; i < attacks.Count; i++)
{
if (attacks[i].Pattern == config.EnrageAction.Pattern)
{
_enrageActionPending = false;
return attacks[i];
}
}
}ルーレット選択の前にこれでShortCircuitさせればいい.
FSM構造
今回のFSMは単純FSMで線形ステートしかもたない(非階層型).
基本的に, ステートは「攻撃」「待機」「追跡」3つに分けられる.
主要コントローラーのコードだけでも600行あるためここに全ては乗らない.
ステートマシーンの構築法は, ネットなら山ほどあるため, ここでは紹介しない
追跡
このステートの遷移条件は, 「索敵範囲内 && 攻撃範囲外」にある時のみ遷移される.
このステートでやることは, ただの移動とはいえ, 遷移先の調整には少々ギミックがある.
public Type Tick(float deltaTime)
{
float dist = _controller.DistanceToPlayer();
var band = _controller.CurrentBand;
// 仲裁:
if (band != RangeBand.OffBand && !_controller.IsGlobalAttackOnCooldown())
{
if (_controller.TryBuildCandidatesForDistance(dist, out _))
return typeof(StateAttack);
} // ↑ 攻撃範囲内, 及びキャラは意思決定できる状態(決定クールダウンに入てない) なら攻撃状態に遷移を試みる
// Movement Intention
if (band == RangeBand.OffBand || band == RangeBand.Pocket)
// ↑↓攻撃範囲外 && 索敵範囲, が, 使える遠距離攻撃が無い(全部クールタイムに入てるなど)
{
// ↓目標に近づくように移動する(近接攻撃範囲内に移動させ, 近接攻撃パターン使えるように試みる)
Vector2 dirToPlayer = ((Vector2)(_controller.PlayerTransform.position - _controller.transform.position)).normalized;
_controller.Direction = dirToPlayer;
return null;
}
else // ↓近接攻撃範囲内に居るけど, 使える近接攻撃パターンが無い -> 待機状態に遷移する
{
return typeof(StateIdle);
}
}待機
この状態は, 近接攻撃範囲内に居る, なお, 使える技が存在しない時だけ(長期的)遷移される
また, 一般ステートリセット用として, 一瞬だけ遷移される
この状態の動作は, 本来何もしなくてもいいけど, 僕の都合で, ランダムウオークするように作ってる.
Gradient Noise
ランダムウオークと言っても, Gaussian Noiseでキャラをブラン運動させるのは非常に醜いと僕が思うから, Gradient Noise, 例えばPerlinやSimplex Noiseのような, 一定の空域では連続的に見える乱数の方がいいと思う.

遷移対象は追跡ステートと似て, 攻撃できるなら攻撃に切り替え, 近接範囲に居ないなら追跡に切り替え, 近接範囲内まで移動させ, 近接攻撃パターンを使わせる, それ以外はノイズの方向に従ってランダムウオークするだけ.
攻撃
攻撃ステートは, 攻撃パターンを読み込み, それをプレイするただの殻ステートだけである.
private IEnumerator AttackRoutine(ScriptableAttackDefinition attackDef)
{
_controller.StartAttackTagCooldown(attackDef.CooldownTag, attackDef.Cooldown);
yield return attackDef.Pattern.Execute(_controller); // <-コマンドパターンが使われる攻撃を発動する
_running = false;
}
public Type Tick(float deltaTime) => _running ? null : typeof(StateChase);「コマンドパターンってなに?」 知りたければ自分で検索するか
僕のアイテムシステム記事 (そこにクリック) を読みんでみよう
面白みは全部攻撃パターンの設計にあるから, 早速そのセクションに行こう!
攻撃パターンの設計法
パラメーター管理と普遍性を付ける
攻撃パターンは, ScriptableObjectに基づくデータ・ロジックファイルとして僕が作っている
まず, キャラが読み込む攻撃パターンの定義をつけるScriptableObjectがこれ
using UnityEngine;
namespace Survivor.Enemy.FSM
{
public enum AttackCategory
{
Melee,
Ranged
}
[System.Serializable]
public class ScriptableAttackDefinition
{
[Tooltip("近接か遠距離攻撃か?")]
public AttackCategory Category;
[Tooltip("ロジックが入るファイルをここに")]
public AttackPattern Pattern;
[Tooltip("クールタイム計測用独自タグ")]
public string CooldownTag;
[Tooltip("クールタイム(秒)")]
public float Cooldown = 3.0f;
[Tooltip("重み")]
public float Weight = 1.0f;
}
}ここにはまだ攻撃の詳細定義書かない. これの役目は, 普遍性
「このボスはこの技めっちゃ使う」-> 重みを高く設置し, クールタイムを短くする
「このボスはこの類の技(テレポートなど)いっぱい持っている」 -> タグを共通するものにつける(例: “teleport”)
「この攻撃, Boss Aは近接状態に居る時だけ使うけど, Boss Bはいつでも使える」 -> Categoryを調整する
これをSerializableと付けたら, List<ScriptableAttackDefinition>で随時追加できる.

攻撃パターンの詳細
まず, コマンドパターンの殻だけの抽象クラスとして,AttackPatternクラスを定義する
using System.Collections;
using UnityEngine;
namespace Survivor.Enemy.FSM
{
public abstract class AttackPattern : ScriptableObject
{
// returns an IEnumerator so it can be run as a coroutine.
public abstract IEnumerator Execute(BossController controller);
}
}冒頭に言ったように, Coroutineに関しての知識は, 前提条件である.
簡単にいうと, 「まず他所実行して, こっちの条件が満たせたらまた戻って」という(仮)非同期処理 (真の非同期ではない)
わかりやすくするためのメンタルイメージ
実際の例を見せる前に, まずこういうイメージ持ってほしい
「料理レシピ」

材料: パスタ, 水, 塩…
=> 材料:飛び道具 Prefab, 音効果のファイル, ビジュアル効果のファイル…
ステップ1: 鍋に水を入れて, 沸騰するまで待つ
=> 射撃アニメーションに切り替えて, 1秒待つ
ステップ2: 鍋に生パスタや塩を入れる
=> 飛び道具をInstantiate()して, 射撃音効果をプレイする
…
…
…
はい, これで攻撃パターンが作成される.
具体例で見てみよう
例1 扇型射撃パターン
この技は, 本質的に上の例で例えたステップと全く違いがない.
単純に, それの… +αをしているだけ
雑に言うと, 一回射撃ではなく,
- 準備動作-> ある扇型範囲で, ある数の矢を撃たせる. これが 「1サイクル」
- サイクル数は定数 或いは 確率減衰モデルに従い, 詳細は定義できる
- サイクルは「Ping Pong」, 前回の終了角度が次回の開始角度で, 方向は逆転する
- 矢は追尾でき, 追尾する秒数も定義できる.
- ボスが狂暴化なら矢の速度, 打つ間隔, 確率減衰率などが変化する
- などなど

using System.Collections;
using UnityEngine;
namespace Survivor.Enemy.FSM
{
[CreateAssetMenu(
fileName = "New SweepingBarrage",
menuName = "Defs/Boss Attacks/Sweeping Barrage")]
public sealed class AttackPattern_SweepingBarrage : AttackPattern
{
[Header("Projectile Config")]
[SerializeField] private GameObject projectilePrefab;
[SerializeField] private float damage = 10f;
[SerializeField] private float speed = 14f;
[SerializeField] private float projectileLife = 3.0f;
[Header("SFX")]
[SerializeField] private SFXResource fireSFX;
[Header("Sweep Geometry")]
[Tooltip("Total arc width in degrees to cover.")]
[SerializeField] private float sweepWidthDegrees = 45f;
[Tooltip("Offset from the player direction to start the sweep. e.g. -22.5 starts at the left edge.")]
[SerializeField] private float startOffsetDegrees = -22.5f;
[Tooltip("If true, the start offset is randomized between left/right mirror.")]
[SerializeField] private bool randomStartSide = true;
[Header("Density")]
[SerializeField] private int shotsPerSweep = 10;
[SerializeField] private int sweepRepetitions = 1;
[SerializeField] private bool pingPong = true; // If true, sweeps back and forth. If false, resets to start.
[Header("Timing")]
[SerializeField] private float delayBetweenShots = 0.05f;
[SerializeField] private float delayBetweenSweeps = 0.2f;
[SerializeField] private float initialWindup = 0.2f;
[Header("Homing Options")]
[SerializeField] private bool projectilesHome = false;
[SerializeField] private float homingDuration = 1.0f;
[Header("Enrage")]
[SerializeField] private float enrageSpeedMul = 1.2f; // Projectile speed
[SerializeField] private float enrageRateMul = 1.5f; // Fire rate
[SerializeField] private int enrageExtraReps = 1;
[Header("Probabilistic Repetitions")]
[SerializeField] private bool repIsProbabilistic = false;
[SerializeField, Range(0f, 1f)] private float probDecayPerSweep = 0.25f;
[SerializeField, Range(0f, 1f)] private float enrageDecayReduction = 0.5f; // 50% less decay when enraged
[Header("Tracking")]
[Tooltip("If true, re-calculates the angle to the player before every specific sweep.")]
[SerializeField] private bool reaimBetweenSweeps = false;
[Header("Animation")]
[SerializeField] private string animName = "Barrage";
public override IEnumerator Execute(BossController controller)
{
if (projectilePrefab == null || controller.PlayerTransform == null)
yield break;
// 1. Setup & Enrage calc
bool enraged = controller.IsEnraged;
float rateMul = enraged ? enrageRateMul : 1f;
float spdMul = enraged ? enrageSpeedMul : 1f;
// This is the "max" number of sweeps; with probabilistic enabled it becomes a hard cap.
int totalReps = sweepRepetitions + (enraged ? enrageExtraReps : 0);
totalReps = Mathf.Max(1, totalReps);
float actualShotDelay = delayBetweenShots / rateMul;
float actualSweepDelay = delayBetweenSweeps / rateMul;
if (controller.Animator != null && !string.IsNullOrEmpty(animName))
{
controller.Animator.Play(animName);
}
// 2. Windup / Initial Aim Snapshot
float baseAngle = GetBaseAngle(controller);
// Decide once per pattern whether we flip to the mirrored side (initial state)
bool flipStartSide = randomStartSide && Random.value > 0.5f;
yield return new WaitForSeconds(initialWindup / rateMul);
// 3. Execution Loop
if (repIsProbabilistic)
{
yield return ExecuteProbabilisticSweeps(
controller,
baseAngle,
flipStartSide,
enraged,
totalReps,
actualShotDelay,
actualSweepDelay,
spdMul
);
}
else
{
for (int r = 0; r < totalReps; r++)
{
// Re-aim check
if (reaimBetweenSweeps && r > 0)
{
baseAngle = GetBaseAngle(controller);
}
// Ping-pong: even reps go A->B, odd reps go B->A.
bool isReverse = pingPong && (r % 2 != 0);
float angleA = baseAngle + startOffsetDegrees;
float angleB = baseAngle + startOffsetDegrees + sweepWidthDegrees;
if (flipStartSide)
{
angleA = baseAngle - startOffsetDegrees;
angleB = baseAngle - startOffsetDegrees - sweepWidthDegrees;
}
float startAngle = isReverse ? angleB : angleA;
float endAngle = isReverse ? angleA : angleB;
yield return FireSweep(controller, startAngle, endAngle, shotsPerSweep, actualShotDelay, spdMul);
if (r < totalReps - 1)
yield return new WaitForSeconds(actualSweepDelay);
}
}
}
private IEnumerator ExecuteProbabilisticSweeps(
BossController controller,
float initialBaseAngle,
bool flipStartSide,
bool enraged,
int maxReps,
float shotDelay,
float sweepDelay,
float speedMul)
{
float p = 1f; // start: always do at least one sweep
int rep = 0;
int guard = 0;
const int hardCap = 32;
float currentBaseAngle = initialBaseAngle;
while (rep < maxReps && guard++ < hardCap && Random.value <= p)
{
// Re-aim check
if (reaimBetweenSweeps && rep > 0)
{
currentBaseAngle = GetBaseAngle(controller);
}
bool isReverse = pingPong && (rep % 2 != 0);
float angleA = currentBaseAngle + startOffsetDegrees;
float angleB = currentBaseAngle + startOffsetDegrees + sweepWidthDegrees;
if (flipStartSide)
{
angleA = currentBaseAngle - startOffsetDegrees;
angleB = currentBaseAngle - startOffsetDegrees - sweepWidthDegrees;
}
float startAngle = isReverse ? angleB : angleA;
float endAngle = isReverse ? angleA : angleB;
yield return FireSweep(controller, startAngle, endAngle, shotsPerSweep, shotDelay, speedMul);
rep++;
// p decays each sweep, reduced decay when enraged
float decay = probDecayPerSweep * (enraged ? enrageDecayReduction : 1f);
p = Mathf.Max(0f, p - decay);
if (rep < maxReps)
yield return new WaitForSeconds(sweepDelay);
}
}
private float GetBaseAngle(BossController controller)
{
if (controller.PlayerTransform == null) return 0f;
Vector2 pivot = controller.transform.position;
Vector2 toPlayer = ((Vector2)controller.PlayerTransform.position - pivot).normalized;
return Mathf.Atan2(toPlayer.y, toPlayer.x) * Mathf.Rad2Deg;
}
private IEnumerator FireSweep(
BossController controller,
float startAngleDeg,
float endAngleDeg,
int count,
float delay,
float speedMul)
{
for (int i = 0; i < count; i++)
{
float t = count <= 1 ? 0.5f : (float)i / (count - 1);
float currentDeg = Mathf.Lerp(startAngleDeg, endAngleDeg, t);
Vector2 fireDir = DegreeToVector2(currentDeg);
SpawnProjectile(controller, fireDir, speedMul);
if (i < count - 1)
yield return new WaitForSeconds(delay);
}
}
private void SpawnProjectile(BossController controller, Vector2 dir, float speedMultiplier)
{
Vector2 origin = controller.FirePoint == null ? controller.transform.position : controller.FirePoint.position;
AudioManager.Instance?.PlaySFX(fireSFX);
var go = Object.Instantiate(projectilePrefab, origin, Quaternion.identity);
var bullet = go.GetComponent<Weapon.EnemyProjectile2D>();
if (bullet != null)
{
bullet.Fire(
pos: origin,
dir: dir,
spd: speed * speedMultiplier,
dmg: damage,
life: projectileLife,
target: controller.PlayerTransform,
homingOverride: projectilesHome,
homingSecondsOverride: homingDuration
);
}
else
{
Debug.LogWarning("SweepingBarrage projectilePrefab missing EnemyProjectile2D.");
Object.Destroy(go);
}
}
private static Vector2 DegreeToVector2(float degree)
{
float rad = degree * Mathf.Deg2Rad;
return new Vector2(Mathf.Cos(rad), Mathf.Sin(rad));
}
}
}料理レシピみたいに, 一歩一歩定義すればいい. 近道はない.
例2 超新星爆発(仮)
この攻撃も, 一歩一歩分離してみよう (コードは550行あるので挙げないが, ゲーム自体はオープンソースなので僕のGitHubで見える)
- Step1: 大きいテレグラフを召喚して, ちょっとだけ待ち, その後対象を引き寄せる
- Step2: 大きいと小さいテレグラフ2個召喚して, 詠唱開始, 特殊道具を一定範囲内で一定数で一定頻度で召喚, 大きい輪内に居る対象を小さいステップで引き寄せ続ける
- Step3: 詠唱終了->爆発
ざっとこういうもん


まぁ…パラメーターが多いけど. これもカスタマイズ可能性のためである.
ダイナミック難易度調整(簡易)
ダイナミック難易度調整は, Game AIにおける重要目標の一つである.
ゲームの難易度を影響する要素は非常に多く, それをうまく プレイヤー体験をよくしつつ, なお簡単にしすぎて退屈させるようにしないというバランスが非常に難しい.

「Flow Theory」の考えから, プレイヤー体験が一番になるのが, 常に勝ち負けの真ん中にバランスさせて, 「Flow Zone」に入れることにある.
一般的に, 攻撃頻度や攻撃ダメージ, 技リストそのものを変更するなど, かなり設計する余地がある.
とはいえ, ダメージを弄るの体感的に一貫性を破るのでよくないと僕は思う.
僕のこのゲームでは,
ボスは, 技ごとのクールタイム以外, 意思決定クールタイムも持っている.
意思決定クールタイムは, 文字通り, 次の技を選別する前提とするクールタイムであり, 攻撃において優先度が最も高いクールタイムである.
このパラメーターは, 「攻撃頻度」に比例する.
例えば, プレイヤーの現在HP値をxとしてAnimationCurve曲線を作り,

この図よりうまく 「意思決定クールタイム」の係数として入れたら,
「プレイヤーの体力が低いほど, 敵の攻撃頻度が下がる」
ように作れる. 最小・最大値をうまく設定すれば,
学園祭では, あまりアクションゲームに得意じゃない人も, 僕が用意した「簡単すぎるEasy Mode」ではなく, 通常モードでもいい感じにプレイできたかもしれない.
まとめ
今回の記事でアクションゲーム用の簡易ボスGame AIの作り方について少々説明した.
次回は予想外の事情がなければ, 「階層型FSM複数個と仲裁システムで, 大手製品アクションゲームで使われる, 自由度高いキャラクターコントローラーの作り方」に関して書くつもり.
ではまた.

