Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

ゲームプログラミングのHP計算システムアンチパターン

この記事は Akatsuki Advent Calendar 2019 11日目の記事です。

はじめに

アカツキでクライアントエンジニアをやっている tomotaka-yamasaki です。
私が所属しているプロジェクトは長く運用されているタイトルなので、実装当時に書かれたソースコードでは到底実現できない開発を迫られることがあります。*1

この記事では、ゲームのバトル中に行われるHP計算周りの改修を行ったときに踏んだアンチパターンと、最終的に行き着いた設計についてまとめています。バトルが存在するゲームになくてはならないHP計算システムについて、考えるきっかけになればと思います。

TL;DR

C++を前提とした記事です。
既存のHP計算システムでは実現できない仕様が追加されたのでリファクタリングに踏み切りましたが、苦しみました。
この記事ではHP計算システムの4つのアンチパターンとそれぞれの課題点を紹介しています。

  1. int型で定義されたHPに対して直接加減算する
    1. 課題: HP変動要素がキャンセルされる可能性
  2. HPを直接書き換えるsetterを導入する
    1. 課題: HP変動の種類
  3. HP変動の種類ごとにint型を保存し、計算する
    1. 課題: HP変動の順序性
  4. HP変動の種類ごとにフェーズに区切って変動値を計算する
    1. 課題: HP変動時の厳格なclamp処理

更に、これらの課題を解決する設計についても詳しく触れています。一言で表すと、HP変動ごとにインスタンスを生成し、順序性を保った上でclamp処理をかけながら計算するシステムです。

HP計算について考える

HPとは

ゲームでHPと書くと大抵ヒットポイントのことをイメージすると思います。
先頭に参加しているキャラクターの生命力のことをHPと称し、それが0になるとキャラクターは戦闘に参加できなくなるのが一般的です。*2
私の開発しているタイトルのHPも例外無く、その仕様です。HPは整数値で扱われるためint型で表されることが多いと思います。
ここからはC++を前提としたコードで説明していきます。

アンチパターン

1. int型で定義されたHPに対して直接加減算する

シンプルなターン制のバトルシステムを例に取ると、HPは敵から攻撃されると減少し、回復アイテムなどにより増加します。HPの増減計算は一方向で行われ、巻き戻ることはありません。

単純な一方向のHP計算

この場合HPはint型の変数として持っておくだけで充分仕様を満たすことができます。

class Player {
public:
    int getHp() const { return _hp; }
    void damage(const int damage) { setHp(_hp - damage); }
    void heal(const int heal) { setHp(_hp + heal); }

private:
    void setHp(const int hp) {
        // 詳細割愛: HPのmin,maxを超えない範囲で_hpにセットする
    }

    int _hp;
};

都度_hp変数に値を加減算し、現在のHPが知りたい時はgetHpするだけで事足ります。しかし、これは一方向のHP計算しかない場合にのみ有効です。発動タイミングが異なる回復スキルやキャンセル可能な回復スキルが出てくると破綻します。
つまり、「HP変動要素がキャンセルされる可能性」を考慮できていません。

2. HPを直接書き換えるsetterを導入する

プレイヤーの回復スキルが発動したときにHPが回復するとします。そのスキルには以下の仕様があります。

  • プレイヤー操作により、発動したスキルがキャンセルされる場合がある
  • 発動したスキルのリストは別クラスが保持しているため、回復値はそのリストから計算する
  • 発動するタイミングはスキルによって異なる

キャンセルが発生するため、HP計算に巻き戻りが生じています。そのため、スキル発動前のHPを記憶する変数が外部に存在し、スキルがキャンセルされた場合にその変数を用いてHPをリセット、変動値の再計算が行われていました。

回復がキャンセルされるパターン

class Player {
public:
    int getHp() const { return _hp; }
    void damage(const int damage) { setHp(_hp - damage); }
    void heal(const int heal) { setHp(_hp + heal); }

    // publicにして、強制的にHPをリセットするsetterを作る
    void setHp(const int hp) {
        // 詳細割愛: HPのmin,maxを超えない範囲で_hpにセットする(clamp処理)
    }

private:
    int _hp;
};

setHp関数は以下のように呼ばれていました。

// スキルがキャンセルされたタイミングで呼ばれるリセット処理
void resetHp() {
    // _resetHp: ターン開始時のHP
    // _itemHealHp: アイテムによって回復したHP
    _player->setHp(_resetHp + _itemHealHp);
    auto skillHealedHp = calcSkillHealedHp();
    _player->heal(skillHealedHp);
}

このsetterでHPを自由に書き換えられてしまうため、複雑さが一気に増しています。しかもPlayerではない外部クラスがHPの情報を持っていてカプセル化もできていません。良くない方向に転がっています。
この場合、「HP変動の種類」をPlayerクラスで考慮できていないのが問題です。

3. HP変動の種類ごとにint型を保存し、計算する

諸悪の根源であるsetHpをprivateに戻し、HPに関係する変数は全てPlayerクラスに閉じ込めることにします。その後、HP回復値を加算し続けるもの、巻き戻る可能性があるものに分類して保持しましょう。
HP変動は以下の2つに分類が可能です。

  • 同一ターン内で加算し続ける回復値:
    • アイテムによる回復や発動キャンセルできないスキルによる回復
  • キャンセルされる可能性のある回復値:
    • キャンセルが可能なスキルによる回復

この2種類のHP変動値を管理するmapをPlayerクラスで保持します。また、回復を行う関数も改修します。回復関数内では以下の処理を行います。

  1. HP変動の種類によって、変数に対して加算 or 上書きを選択する
  2. 「ターン開始時のHP + このターンで変動した値」を現在のHPにする

詳細は割愛しますが、ダメージを与える関数も修正します。

class Player {
public:
    int getHp() const { return _hp; }

    void damage(const int damage) {
        _startTurnHp -= damage;
        setHp(_startTurnHp + getHealHpInTurn());
    }

    void healInTurn(HealHpCalcType healHpCalcType,
                    const int heal) {
        switch (healHpCalcType) {
            case HealHpCalcType::ADD:
                _healHpInTurn[healHpCalcType] += heal;
                break;

            case HealHpCalcType::OVERWRITE:
                // スキルは上書き
                // (ターン内で解除される可能性があるため、
                // 発動しているEfficacyInfoから毎回計算している)
                _healHpInTurn[healHpCalcType] = heal;
                break;
        }

        setHp(_startTurnHp + getHealHpInTurn());
    }

    enum class HealHpCalcType {
        ADD,        //!< ターン内で加算し続ける
        OVERWRITE,  //!< 都度計算し最新の値に更新する
    };

private:
    void setHp(const int hp) {
        // 詳細割愛: HPのmin,maxを超えない範囲で_hpにセットする(clamp処理)
    }

    int getHealHpInTurn() {
        int allHealHp = 0;
        for (auto itr = _healHpInTurn.begin(); itr != _healHpInTurn.end(); itr++) {
            allHealHp += itr->second;
        }
        return allHealHp;
    }

    int _hp;                                        //!< 現在のHP
    int _startTurnHp;                               //!< ターン開始時のHP
    std::map<HealHpCalcType, int> _healHpInTurn;    //!< ターン内で回復したHP
};

setHpをprivateにし計算をPlayerクラスに閉じ込めることで、スキルキャンセル時に呼び出されていたHPリセット処理が不要になりました。ただ、この処理は回復に比重を置きすぎているため、ダメージを与える際にstartTurnHpから直接減算するなど良くないコードが目立ちます。setHpも本当に必要なのかよく分かりません。
更に、詳しくは後述しますがこのコードは「HP変動の順序性」が考慮できていません。

4. HP変動の種類ごとにフェーズに区切って変動値を計算する

「HP変動の種類ごとにint型を保存し、計算する」で回復とダメージのインターフェースは統一されましたが、2つの問題があります。

  1. HP全回復からダメージを受けた時、それまで上限で打ち止めされていた回復スキルが発動し、ダメージが軽減されてしまう
  2. 回復HPを計算し、そのHPが加算なのか上書きなのかを外部クラスが逐次判断しなければならない

回復やダメージの順序性の問題が発生しています。HP全快の状態ではもちろん回復できないのでHPは増えませんが、回復スキル自体は発動しています。そのスキルによる回復がダメージを受けた後に発動してしまうという問題です。ユーザには、「スキルによる回復 → ダメージ」という順番に見えていますが、内部ではスキルによる回復計算時にスキル発動タイミングが一切考慮されていないため、ダメージ前に発動していたスキルの回復値がダメージ計算後に反映されています。

HP計算の順序性

なので、順序を考慮した設計にします。
インターフェースを3つ用意しました。

syncPlayerHp: HPの同期をとる
applyDealDamage: ダメージを反映させる(ダメージ値を蓄積する変数を更新)
addPlayerBaseHp: スキル以外の回復値を反映させる(_startTurnHpに加算)

スキルによるHPが変動する可能性のある箇所ではsyncPlayerHpを呼び、発動しているスキルから毎回回復値を計算します。回復値を保持していた_healHpInTurnは撤廃しています。

/**
 * HPが変動する可能性があるタイミングで呼び出し、HPの同期を取る
 * 回復タイミングを3つのフェーズに分解し、各フェーズでの計算終了時にHPをclampする
 */
void syncPlayerHp() {
    // 1: ターン開始時のHP + 攻撃フェーズまでに発動したスキルによる回復値
    auto healHpBeforeAttack = calcSkillHealedHp(/*攻撃前だけ計算するためのパラメータ注入*/);
    auto playerHp = _startTurnHp + healHpBeforeAttack;
    setHp(playerHp);

    // 2: ダメージ値を反映
    playerHp = getHp() + _damagedValue;
    setHp(playerHp);

    // 3: 攻撃フェーズ中に発動したスキルによる回復値
    auto healHpInAttack = calcSkillHealedHp(/*攻撃中だけ計算するためのパラメータ注入*/);
    playerHp = getHp() + healHpInAttack;
    setHp(playerHp);
}

回復タイミングを3つのフェーズに分解し、それぞれのフェーズでHPをclampし、min,maxを超えない範囲でHPを更新しています。それにより、それまで上限で打ち止めされていた回復スキルはフェーズ1で計算が終了しているためダメージを受けた後に反映されることがなくなりました。また、回復値のキャッシュも止めたので、外部からは変動する可能性のある箇所でsyncPlayerHpを呼ぶだけで良くなりました。

これでHP計算に順序性を持たせることができました。しかし、まだ考慮できていない点があります。
それは「HP変動時の厳格なclamp処理」ができていない問題です。

最終的なHP計算システム設計

これまでの問題点のまとめ

解説する前に最後のアンチパターンプログラムの問題点を挙げます。そのプログラムには「攻撃フェーズまでにダメージを受けることが無い」という前提が隠れています。仮に今のシステムで攻撃前に自傷ダメージ*3を受けた場合、回復アイテムで回復したとしてもその後に自傷ダメージが計算されるため、ユーザの行動によるHP変異とシステム上のHP変異に差が生まれます。

従って、HP変動順序を厳格に守る必要があり、更に1回のHP変動ごとにclamp処理を行わなければ上記仕様は成立しません。
ここでHP計算で考慮すべき点をおさらいします。

  1. HP変動要素がキャンセルされる可能性
  2. HP変動の種類
  3. HP変動の順序性
  4. HP変動時の厳格なclamp処理

この4つを実現するHP計算システムを再設計しました。

クラスの概要

一言で表すと、「HP変動ごとにインスタンスを生成し、順序性を保った上でclamp処理をかけながら計算する」システムを作りました。

  • HPの変動情報を持つエンティティクラスを作成
  • HPの計算を全て管理するクラスを作成
    • このクラスが変動情報を持ち、管理する
  • スキルによる回復以外も全て同じ変動情報エンティティとして管理し、区別しない
/**
 * HP変動値の計算に必要な要素
 */
class HpCalculationFactor {
public:
    enum class Type {
        SKILL,              // !< スキルによる変動
        NOT_RECALC,         // !< スキル以外による変動
        DAMAGE,             // !< ダメージによる変動
    };
    struct SkillIdentifer {
        int index;
        int skillId;
        SkillIdentifer(int index, int skillId)
        : index(index)
        , skillId(skillId)
        {}
        bool operator ==(const SkillIdentifer& other)
        {
            return index == other.deckIndex && skillId == other.skillId;
        }
    };
    HpCalculationFactor(int diffHp, SkillIdentifer skillIdentifer);
    HpCalculationFactor(int diffHp, Type type);
    virtual ~HpCalculationFactor() = default;

    int calcDiffHp(const int playerHp, const int playerMaxHp);
    int getDiffHp() const { return _diffHp; }
    SkillIdentifer getSkillIdentifer() const { return _skillIdentifer; }
    Type getType() const { return _type; }
    bool isActive() const { return _isActive; }
    void inactivate();
    void update(int diffHp);

private:
    int             _diffHp;            // 変動する可能性のあるHP
    SkillIdentifer  _skillIdentifer;    // スキルによるHP変動のみidが存在する
    Type            _type;              // 変動の要因となったもの
    bool            _isActive;          // 変動要素が機能しているかどうか
};
using HpCalculationFactorPtr = std::shared_ptr<HpCalculationFactor>;

/**
 * HP変動値の計算を行うクラス
 */
class HpCalculator {
public:
    HpCalculator();
    virtual ~HpCalculator() = default;

    void reset(const int playerHp);
    int calculate(const int playerMaxHp);
    void setSkillFactors();
    void setFactor(const int factorValue, HpCalculationFactor::Type factorType);

private:
    void gc();
    int total();
    void inactivateSkillFactor();

    std::list<HpCalculationFactorPtr> _factors;
    int _startTurnHp;
};

クラスの詳細解説

HpCalculationFactorクラス

このクラスはHPを変動させるためのエンティティクラスです。
今までは数値としてでしか管理できていなかったHP変動要素をオブジェクト指向らしくクラス化しました。HPを変動させる可能性のある値はもちろんのこと、calcDiffHp関数を呼ぶことでclamp処理を行った本当の変動値を取得可能にしています。また、1スキルごとに固有のIDを持たせているため、そのスキルの内容が途中で変更された場合はエンティティを特定し更新することができます。

/**
 * 実際に変動するHP値を計算する
 * @param[in] playerHp 現在のプレイヤーHP
 * @param[in] playerMaxHp プレイヤーの最大HP
 * @return 現在のプレイヤーHPを加味した変動HP値を返す
 */
int HpCalculationFactor::calcDiffHp(const int playerHp, const int playerMaxHp)
{
    auto hp = playerHp + _diffHp;
    if (_type == Type::DAMAGE) {
        hp = std::min(std::max(hp, 0), playerMaxHp);
    } else {
        hp = std::min(std::max(hp, 1), playerMaxHp);
    }
    return hp - playerHp;
}
HpCalculatorクラス

このクラスはHpCalculationFactorクラスのインスタンスを順序性のあるstd::listで管理しています。
calculate関数で現在のHPを計算します。関数の中身は単純で、リストのFactorから変動値を取得、合算し_startTurnHpに加算しているだけです。

/**
 * 登録されているHP変動要素から、最終的なHP値を算出する
 * @param[in] playerMaxHp プレイヤーの最大HP
 * @return プレイヤーHPのmin,maxを加味したHP変動値を算出する
 */
int HpCalculator::calculate(const int playerMaxHp)
{
    gc();

    auto playerHp = _startTurnHp;
    for (auto factor : _factors) {
        auto diff = factor->calcDiffHp(playerHp, playerMaxHp);
        playerHp += diff;
    }
    return playerHp;
}

Factorの登録はsetFactorとsetSkillFactorsで行います。setFactorはスキル以外のHP変動要素があったときに呼びます。変動する値と種類を元にFactorを生成し、リストに格納します。
スキルによる回復はsetSkillFactors内で計算されます。発動しているスキル一覧は別クラスが保持しているためその情報を元にFactorを生成します。もし既にFactorとして登録されているスキルがあった場合新しくFactorは登録せず、リストの順序を保ったままFactorの内容だけ更新します。

/**
 * スキル以外のHP変動要素をcalculatorに登録する
 * @param[in] factorValue HP変動値
 * @param[in] factorType HP変動の要因となったもの
 */
void HpCalculator::setFactor(const int factorValue, HpCalculationFactor::Type factorType)
{
    auto factor = std::make_shared<HpCalculationFactor>(factorValue, factorType);
    _factors.push_back(factor);
}

/**
 * スキルによるHP変動要素を生成し、calculatorに登録する
 * 既にFactor登録されているスキルは内容だけ上書きする
 */
void HpCalculator::setSkillFactors()
{
    // スキルは発動していれば再計算可能なので一度inactiveにする
    inactivateSkillFactor();

    // 中略...

    for (auto& skill : skillList) {
        // 中略...

        auto skillIdentifer = HpCalculationFactor::SkillIdentifer(skill->getIndex(), skill->getSkillId());
        auto factor = std::make_shared<HpCalculationFactor>(healHp, skillIdentifer);
        auto itr = std::find_if(_factors.begin(), _factors.end(), [factor](const HpCalculationFactorPtr& f) {
            return f->getSkillIdentifer() == factor->getSkillIdentifer();
        });
        if (itr != _factors.end()) {
            (*itr)->update(factor->getDiffHp());
        } else {
            _factors.push_back(factor);
        }
    }
}
HpCalculatorクラスのgcについて

途中でキャンセルされたスキルのFactorはinactive状態になります。inactive状態になったFactorはcalculate関数を呼んだときに自動的にgcされ、計算から除外される仕組みを実装しています。

/**
 * 機能しなくなっているHP変動要素があればリストから削除する
 * @detail 一部スキルはターンの途中で消える場合がある
 */
void HpCalculator::gc()
{
    _factors.remove_if([](const HpCalculationFactorPtr& factor) {
        return !factor->isActive();
    });
}
HPの同期を取るsyncPlayerHp

syncPlayerHp関数があるクラスでは順序性を考慮しなくても良くなったのでスキルによるHP変動のFactorを登録し、calculateを呼ぶだけのシンプルな関数になりました。

void syncPlayerHp()
{
    _hpCalculator->setSkillFactors();
    auto playerHp = _hpCalculator->calculate(getPlayerHpMax());
    setPlayerHp(playerHp);
}

まとめ

この記事では4つのアンチパターンとそれぞれの課題点を紹介しました。

  1. int型で定義されたHPに対して直接加減算する
    1. 課題: HP変動要素がキャンセルされる可能性
  2. HPを直接書き換えるsetterを導入する
    1. 課題: HP変動の種類
  3. HP変動の種類ごとにint型を保存し、計算する
    1. 課題: HP変動の順序性
  4. HP変動の種類ごとにフェーズに区切って変動値を計算する
    1. 課題: HP変動時の厳格なclamp処理

これらを満たすためには単純に整数値を加減算するだけではなく、変動要素クラスとそれを管理する計算クラスを実装すべきです。
特に順序性やclamp処理は深く考えずに実装するとどこかで考慮漏れが発生します。オブジェクト指向型言語であればできるだけカプセル化しましょう。
HPclamp処理は1つの変動要素を計算するたびに行うようにすると計算のズレが発生しません。
要するに、HP変動ごとにインスタンスを生成し、順序性を保った上でclamp処理をかけながら計算すると良いかと思います。

またこの記事では触れていませんが、HP計算を更に複雑にさせる仕様は存在します。
例: 無敵状態の場合HPは変動させない、自傷ダメージではHPは0にならない など*4
そのような仕様があったとしても変動要素クラスさえあれば、変動値を取得する関数内で計算することで処理を隠蔽することが可能です。変動する値とその種類を同クラス内で持つことはそういった利点があります。

HP計算はバトル要素のあるゲームに必須なので、この記事が誰かのお役に立てれば幸いです。


*1:エンジニアはできる限り柔軟に対応できるソースコードを書くことに尽力しますが、時代ともにユーザのニーズは刻一刻と変化するため、エンジニアの予想を遥かに超えた要求仕様を頑張って実現しなければいけない場面に稀に遭遇します。

*2:ヒットポイントでありながらライフポイントの意味合いで使われているのは今更ながら不思議ですね。

*3:自傷ダメージを受けてステータスアップ、みたいなスキル、ありそうですよね。

*4:この記事を書いている途中でHP(ヒットポイント)をどう作る?という記事を見つけました。そこで書かれている「残っている体力の割合計算が手間」というのもあるあるです。int型ではなくあえてfloat型で管理する手段も良さそうです。