ローグライクのアイテム取得処理の設計について考える
はじめに
ローグライクゲームなどによくあるアイテム取得処理の設計について考えてみます。
仕様の確認
ローグライクでのアイテムは、主にプレイヤーのステータスを変更するものや、プレイヤーの行動を変更するものがあります。
- ステータス変更
- HPの回復
- 攻撃力の上昇
- 防御力の上昇
- 行動変更
- スキルの追加
- 既存のスキルとのシナジー
- スキルAで倒したらスキルBのクールタイムがリセットされるとか
- その他あらゆる変数への影響
- お金の増減
- 運の上昇
これらは単なる変数として表現するのがほぼ不可能であり、そうなると1つ1つ特有のアイテムクラスを作成する必要が出てくると思われます。
インターフェースの作成
これらのアイテムを抽象化すると、「取得時になんらかの影響を追加する」ということまでしか抽象化できません。
そこで、以下のようなインターフェースを作成します。
public interface IItem
{
void Pick();
}
IItem.Pick()
は、アイテムを取得した時に呼ばれるメソッドです。この中身を実装することで、アイテムの効果を実装します。
インターフェースの実装
各アイテムの効果を実装するクラスを作成します。
public class ItemHeal : IItem
{
private readonly PlayerStatus _player;
private const int HealAmount = 10;
public ItemHeal(PlayerStatus player)
{
_player = player;
}
public void Pick()
{
_player.HP += HealAmount;
}
}
public class ItemAttackUp : IItem
{
private readonly PlayerStatus _player;
private const int AttackUpAmount = 5;
public ItemAttackUp(PlayerStatus player)
{
_player = player;
}
public void Pick()
{
_player.Attack += AttackUpAmount;
}
}
public class ItemReduceInitEnemyEliteHp : IItem
{
private readonly EnemySpawner _enemySpawner;
private const float ReducePercentile = 10f;
public ItemReduceEliteEnemyHp(EnemySpawner enemySpawner)
{
_enemySpawner = enemySpawner;
}
public void Pick()
{
_enemySpawner.InitEliteHp *= (1f - ReducePercentile / 100f);
}
}
アイテムを管理するクラスの作成
そしてこれらを管理するクラスを作成します。本来の意味とは違うかもしれませんが、ここではリポジトリと呼ぶことにします。
public class ItemRepository
{
private readonly IEnumerable<IItem> _items;
public ItemRepository(IEnumerable<IItem> items)
{
_items = items;
}
public IItem Get(int index) // これは使いづらい
{
return _items[index];
}
}
これでは識別ができないので、アイテムに識別子をつけることにします。
public interface IItem
{
ItemName Id { get; } // 識別子(enum)
void Pick();
}
public class ItemHeal : IItem
{
public ItemName Id => ItemName.Heal;
private readonly PlayerStatus _player;
private const int HealAmount = 10;
public ItemHeal(PlayerStatus player)
{
_player = player;
}
public void Pick()
{
_player.HP += HealAmount;
}
}
public class ItemRepository
{
private readonly IEnumerable<IItem> _items;
public ItemRepository(IEnumerable<IItem> items)
{
_items = items;
}
public IItem Get(ItemName name)
{
return _items.FirstOrDefault(x => x.Id == name);
}
}
これでアイテムの取得ができるようになります。
処理フローの作成
アイテムの取得はコマンドで行うことにします。
using VitalRouter;
/// <summary>
/// アイテム取得を通知するコマンド
/// </summary>
public readonly record struct PickItemCommand(ItemId ItemId) : ICommand;
そしてこのコマンドをVitalRouterで受け取ります。
イベントを受け取るクラスは、他のクラスに依存されないように設計します。
using VitalRouter;
[Routes]
public partial class PlayerPickItemController
{
readonly ItemRepository _itemRepository;
public PlayerPickItemController(ItemRepository itemRepository)
{
_itemRepository = itemRepository;
}
/// <summary>
/// アイテムを取得したときに呼ばれる
/// </summary>
public void On(PickItemCommand cmd)
{
if (_itemRepository.Get(cmd.ItemId) is IItem item)
{
item.Pick(); // アイテムの効果を発動
}
}
}
これでアイテムの取得時に効果が発動するようになりました。
ItemRepositoryの依存関係の解決
このままでは、ItemRepository
の依存関係が解決できないので動きません。
しかし、実はVContainerでは登録したインターフェースを勝手にまとめてIEnumerable<T>
にDIしてくれるので、それを利用すると簡単に解決できます。
using VContainer;
using VContainer.Unity;
using VitalRouter.VContainer;
public class GameLifetimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
builder.RegisterVitalRouter(
route =>
{
route.Map<PlayerPickItemController>();
}
);
builder.Register<ItemRepository>(Lifetime.Singleton);
// 自動的にIEnumerable<IItem>に登録されるので、ItemRepositoryにDIされる
builder.Register<ItemHeal>(Lifetime.Singleton).As<IItem>();
builder.Register<ItemAttackUp>(Lifetime.Singleton).As<IItem>();
builder.Register<ItemReduceInitEnemyEliteHp>(Lifetime.Singleton).As<IItem>();
}
}
まとめ
この方法を使えば、関数が変化するようなものの集合も静的なDIで解決しつつ管理できるので、非常に便利です。
またこの記事は正解を示すものではありません。あくまで一つの設計例として参考にしていただければと思います。
Discussion