🎲

ローグライクのアイテム取得処理の設計について考える

2024/10/12に公開

はじめに

ローグライクゲームなどによくあるアイテム取得処理の設計について考えてみます。

仕様の確認

ローグライクでのアイテムは、主にプレイヤーのステータスを変更するものや、プレイヤーの行動を変更するものがあります。

  • ステータス変更
    • 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