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

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

UnityのVariant機能をつかってちょっと躓いた話

はじめに

この記事は Akatsuki Advent Calendar 2019 13日目の記事です...でした。 まだUTC-8くらいまでは13日なのでセーフったらセーフです。

挨拶が遅れました。アカツキでクライアントエンジニアをしている shairo_jp こと下村です。

師も開発者も忙しく走り回る年末に皆さんいかがお過ごしでしょうか。

UnityのAddressablesからpreviewが外れてそろそろ半年ほども経つようですね。 巷のUnityプロジェクトはもうAssetBundleを脱出する算段をつけている頃合いかと思います。

私もAddressableに関する記事を投稿する腹積もりでしたが、少々アテが外れたためAssetBundleに関する小さなTipsを共有することにしました。 もうAssetBundleはだいぶ触り尽くしたと思っていたのですが、Variantの取り扱いで躓いた点があったので紹介します。

Variantとは

VariantはUnityのAssetBundleの機能の一つで、AssetBundleの参照関係を壊さないようにアセットを置き換えるための仕組みです。 もっぱらSD/HDアセットや言語の切り替えといった用途に利用されます。 ここではVariantについて詳しい説明はしません。後の説明に必要な部分だけに留めます。

f:id:shairo_jp:20191214163827p:plain

BundleA に含まれるPrefabは BundleB のImageアセットを参照しています。 今回はこのImageを切り替えられるようにしたいので、 BundleB にXとYのVariantを用意します。

f:id:shairo_jp:20191214165532p:plain

Variant違いのアセットは同じ名前と内部IDを持つため、 BundleB.X の代わりに BundleB.Y をロードすればPrefabが参照するアセットが自然に切り替わります。

VariantをサポートするUnity公式のAssetBundleManagerを見てみましょう。

// Get dependecies from the AssetBundleManifest object..
string[] dependencies = m_AssetBundleManifest.GetAllDependencies(assetBundleName);
if (dependencies.Length == 0)
    return;

for (int i = 0; i < dependencies.Length; i++)
    dependencies[i] = RemapVariantName(dependencies[i]);

// Record and load all dependencies.
m_Dependencies.Add(assetBundleName, dependencies);
for (int i = 0; i < dependencies.Length; i++)
    LoadAssetBundleInternal(dependencies[i], false);

Unity-Technologies / assetbundledemo / demo / Assets / AssetBundleManager / AssetBundleManager.cs — Bitbucket より引用

GetAllDependenciesで取得したAssetBundle名に対してVariant名のリマップを行っているようです。 これでめでたく読み込むImageアセットを切り替えることができました。

問題点

しかしVariantのAssetBundleに含まれるアセットもさらに他のAssetBundleのアセットを参照しているかもしれません。 もう少し複雑な次の例を考えてみましょう

f:id:shairo_jp:20191214164021p:plain

この時 GetAllDependencies("BundleA")["BundleB.X", "BundleC"] を返します。 しかしこのリストの BundleB.XBundleB.Y に置き換えても、 BundleB.Y に必要な BundleD が不足してしまいます。

このように、実はVariantを利用する場合にはGetAllDependenciesを利用することができません。 Variant名の解決の解決は、AssetBundleが 直接 依存するAssetBundleに対して行う必要があります。

Variantを指定してDependenciesを取得する

気づいてしまえばあとは簡単です。ここは再帰呼び出しを利用して簡単にGetAllDependenciesの代替スクリプトを書いてみます。

public static string[] GetAllDependenciesWithVariant(this AssetBundleManifest manifest, string assetBundleName,
    IReadOnlyDictionary<string, string> variantMap)
{
    var dependencies = new HashSet<string>();
    GetDependencies(assetBundleName);
    return dependencies.ToArray();

    void GetDependencies(string name)
    {
        if (variantMap.TryGetValue(name.Split('.')[0], out var trueAssetBundleName))
        {
            name = trueAssetBundleName;
        }

        foreach (var dependency in manifest.GetDirectDependencies(name))
        {
            if (dependencies.Add(dependency))
            {
                GetDependencies(dependency);
            }
        }
    }
}

Variantの具体的な使い方は人それぞれなので、非Variant名からVariant名を取得できるようなテーブルを用意するのが柔軟でよいです。 今回の例では variantMap に以下のようなテーブルを渡します。

{
    {"BundleB", "BundleB.Y"}
}

AssetBundle名を受け取ったら、まずはSplitで末尾のVariantを取り除いて先程のテーブルを引きます。 Variant名を取り除いたらGetDirectDependenciesで依存するAssetBundleを取得し、既出でなければ再帰的に依存関係を調べます。

普段GetAllDependenciesを使っていると気づきませんが、AssetBundleの依存関係は循環することがため既出かどうかを判定しなければなりません。

なお、AssetBundle名にはピリオドを含めれないので、Variant以外でSplitに引っかかることはありません。

全てのVariantを含むDependenciesを取得する

突然ですが、AssetBundleの欠点の一つにそれをResourcesと同様に扱う事ができないという問題があります。 そのために主要な全てのアセットをAssetBundleに含め、起動から初回ダウンロードまでに必要なAssetBundleをStreamingAssetsに格納する、という設計がしばしば採用されます。

このときStreamingAssetsに格納するAssetBundleは、当然全ての依存関係を完全に含まなければなりません。 つまり、今度は全てのVariantを含む依存関係の解決を行う必要があります。

public static string[] GetAllRequirementsWithVariant(this AssetBundleManifest manifest, string assetBundleName)
{
    var allVariantsMap = manifest.GetAllAssetBundlesWithVariant()
        .GroupBy(n => n.Split('.')[0])
        .ToDictionary(g => g.Key, g => g.ToList());

    var requirements = new HashSet<string>();
    GetDependenciesWithAllVariant(assetBundleName);
    return requirements.ToArray();

    void GetDependenciesWithAllVariant(string name)
    {
        name = name.Split('.')[0];

        if (allVariantsMap.TryGetValue(name, out var variants))
        {
            foreach (var variant in variants)
            {
                GetDependencies(variant);
            }
        }
        else
        {
            GetDependencies(name);
        }
    }

    void GetDependencies(string name)
    {
        if (!requirements.Add(name))
        {
            return;
        }

        foreach (var dependency in manifest.GetDirectDependencies(name))
        {
            GetDependenciesWithAllVariant(dependency);
        }
    }
}

まずはGetAllAssetBundlesWithVariantで全てのVariantの対応表を作ります。 例では allVariantsMap は以下のようなテーブルになります。

{
    {"BundleB", {"BundleB.X", "BundleB.Y"}
}

あとはAssetBundle名からVariantを取り除き、改めてVariantを列挙しながら依存AssetBundleを列挙するだけです。 今回はルートのAssetBundleのVariantも含めたいので、 requirements にルート自身を含めるようにしています。

おわりに

もう4ヶ月もすればUnity2019もLTSがリリースされ、Addressablesの実戦投入もグッと現実化するでしょう。 このTipsはもはや過去のノウハウですが、この間隙にまだAssetBundleに悩まされている開発者の助けになれば幸いです。

明日...いや今日は s-capybara さんの番になります。

ゲームプログラミングの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型で管理する手段も良さそうです。

エンジニア組織の成長に必要なのは、一人の情熱を大切にすることである

こんにちは、ゆのん(id:yunon_phys)です。この記事は Akatsuki Advent Calendar 2019 10日目の記事です。

エンジニア組織の成長のために大切にしている2つの事柄

アカツキのエンジニア組織は2~3年かけて成長していく状態を目指しています。 そしてその成長のためには、情熱技術の積み上げが大事である、と考えています。

1. 情熱という感情を大切に扱う

アカツキでは、情熱を持って仕事をしている状態を称賛します。 というのも、その人の想いが込められたプロダクトは明らかに完成物のクオリティが高くなりますし、よりクオリティを上げるためのいかなる努力も惜しまなくなり、結果として人も組織も成長すると考えているからです。

情熱というのは大きな野望である必要はありません。 その人が心からやりたいと思っているものであれば、その情熱の炎に大きさは関係ありません。 個人としてはその炎に絶えず薪をくべて大切に育てて欲しいですし、 組織としてはその炎が消えないように他からガードし、時には燃料の供給となるようにその機会を与えられるよう支援します。

2. 技術の積み上げを称賛する

続きを読む

AWS と GCP (GAE) それぞれのリージョン間のレイテンシを計測してみた

この記事は Akatsuki Advent Calendar 2019 の 9 日目として書きました。

はじめに

みなさん、マルチクラウドしてますか!? 株式会社アカツキでエンジニアをしている @sachaos です。

弊社では各ゲームプロダクトから利用される共通基盤をマイクロサービスとして構築しています。 この共通基盤は Google Cloud Platform (以降 GCP) の Google App Engine (以降 GAE) Standard Environment により運用されています。 現在、ゲームプロダクトでは主に Amazon Web Services (以降 AWS) を利用しているため、ゲームサーバは AWS と 共通基盤は GCP のマルチクラウド構成となっています。

アカツキでは海外向けに提供しているゲームもあります。 日本にゲームサーバがあれば日本のプレイヤーは快適にゲームをすることが出来ますが、 海外向けのゲームで日本にゲームサーバがあると、物理的に遠いのでどうしてもレイテンシが発生してしまいます。 なので、海外版を提供する際にはプレイヤーの体験を損なわないようにするため、ゲームサーバをなるべくプレイヤーの近くに構築しています。

また、共通基盤となるサービスは同じ理由でゲームサーバの近くにあるべきです。 しかし、共通基盤を AWS の各リージョンから最も近いところに複数個デプロイするというのは、運用コストの点から現実的ではありません。

なので、運用コストを抑えつつもプレイヤーの体験を損なわないようにするため、 共通基盤を用意する GCP のリージョンを少なく して、かつ 様々な AWS リージョンからもレイテンシが低く保てる ような GCP のリージョンを探したいです。

そこで、AWS 上に構築されるゲームサーバから GCP (GAE) 上に構築されている共通基盤までのレイテンシはどの程度なのか、 AWS の各リージョンから GCP (GAE) の各リージョンまでのレイテンシを計測してみました。

結果

AWS の各リージョンから GCP の各リージョンへ 20 回リクエストを送り、その中央値をとったものをまとめたものが以下の表です。 単位は ms です。 100ms 未満のものを緑色に、300ms より大きいものを赤色で塗っています。

f:id:sachaos:20191209164034p:plain

docs.google.com

計測方法

レイテンシの計測対象として GAE SE の go111 runtime を利用し、単純に Hello World を出力する HTTP サーバを作成し、各リージョンに配置しています。 そしてレイテンシの計測をするものとして Lambda を利用しました。 こちらも各リージョンに配置し、パラメータとして GAE のリージョンを指定すればそこに対して、計測を開始するようなものになっています。 AWS Lambda は Go のランタイムを利用しており、Go 製の負荷テストライブラリを利用しました。

f:id:sachaos:20191209145937p:plain

考察

当たり前ですが、近いところはレイテンシが低いですw どんな AWS のリージョンからでも、71 ms 以下で返せる GAE のリージョンがありそうです。

GAE の us-cental, europa-west リージョンが全体的に優秀です。 us-cental は AWS のどんなリージョンからでも 300ms 未満でリクエストを返しています。 europa-west リージョンも殆どのリージョンから 300ms 未満でリクエストを返しています。

asia-northeast1 に加えてこの2つのリージョンにサービスを配置すれば どんな AWS のリージョンからのリクエストでも 100 - 200ms 未満でリクエストを返すことができそうです。

アカツキでのインターン戦記:クライアントエンジニア編

f:id:kenji-hanada:20190925180518p:plain少し前の話になりますが、9/5 ~ 9/20 まで アカツキ でインターンをしてきたうじまるです。 今回はその体験記を書いていこうと思います。アカツキのインターンにこれから参加する/検討している人の参考になれば嬉しいです。

目次

インターンの内容

八月のシンデレラナイン のクライアント側の開発をしてました。 主にやっていたことは

  ・既存の実装だと動作が重くなっている部分を改善する

  ・累積報酬を試合結果画面からみえるようにする

  ・SR選手専用のクリスタルベアマックスの背景画像をクライアント側で生成する

ということをやってました。大きめのタスクがあってそれを2週間でやるという形ではなく小さめのタスクをたくさんやるという感じでやっていました。小さめのタスクだったので進捗が出てる感があってよかったです。

これにプラスして 9/14 ~ 9/15 にあった AKATSUKI GAME JAM も参加しました。

既存の実装だと動作が重くなっている部分を改善する

既存の実装だとアイテム一覧や練習参加先一覧がデータの数だけ生成されてしまいその数が大きいと重くなっていました。そこでそれを軽くするために表示に必要な数だけオブジェクトを生成するように修正するようにしました。必要分生成するComponentはすでにあったのでそれを使うように実装するタスクでした。

累積報酬を試合結果画面からみえるようにする

累積報酬の詳細が試合結果画面から見れなかったのでそれを見れるようにしました。 現状だと試合を選択する画面からしか累積報酬の詳細を見ることができなかったのですが再戦をして周回すると現在どのくらいポイントが溜まっていて次がどの報酬なのか見れないようになっていました。

f:id:kenji-hanada:20190925180512p:plain
ここから累積報酬を確認できるようにする

前のタスクの実装は一部のComponentに修正を入れるだけだったのですがこのタスクはSceneの処理の流れやデータの流れを理解しないと実装が難しかったのでコードを読んだり実際にどんな処理をしているのかを理解するのが大変でした。

SSR選手専用のクリスタルベアマックスの背景画像をクライアント側で生成する

今まで背景画像をデザイナーさんが作っていたのですが、運用コスト的にクライアント側で生成できるならしたほうが良いよね、ということでそれを実装しました。

f:id:kenji-hanada:20190925180507p:plain
この画面のUIの後ろ側の画像を作る

最初に自分が考えていた実装方針もあったのですが、そっちだと現在のコードをうまく使って実装できなかったので別の方針でやることにしました。画像の合成はオブジェクトを2つ作って合わせても良かったのですが、実装をシンプルにしたかったのと現在の処理もうまく使いたかったのでシェーダーで実装することにしました。

マスターデータから画像IDを取ってきたり、デバッグ用にアイテム付与をするツールを使ったりソシャゲを作ってる感があってよかったです。

ただ、キャラ画像の位置調整が手に入るデータだけではうまくできなかったのでデザイナーさんと話してマスクの範囲を小さくするという結果になりました。

このままリリースされるかどうかはわかりませんが、基本的な部分は実装できたと思います。

AKATSUKI GAME JAM

テーマが「カラフル」で3~4人一組になって2日間掛けてゲームを作るというインターンです。 僕のチームは3人チームでチームメンバ全員がカレー(特にインドカレー)がまぁまぁ好きという理由で「ナンカレー」というチーム名になりました。

作ったゲームは「縦スクロールでロケットを操作して宇宙人を集めるゲーム」を作りました(なんかよく分かりませんね、僕もうまく説明できないです)

f:id:kenji-hanada:20190925180504j:plain
開発風景

結果、準優勝をして「ハチナイカレー」を貰いました。ナンカレーがハチナイカレーを貰えたのでなんか良かったです。

f:id:kenji-hanada:20190925180459j:plain

今回作ったゲームはリリースまで頑張ろうということになったのでリリース目指して頑張ろうと思います。

まとめ

直前に参加していたインターンとの兼ね合いがあり、3週間の予定が約2週間のインターンになってしまいましたが、それなりに進捗を出すことができたと思います! Unityのコードをレビューしてもらう機会が今までほとんど無かったのでとても勉強になりました。 他にも、インターン参加前に「設計とか見たい」と思ってましたが、この点でも普通に実装や設計で勉強になる部分が多かったのでいい経験になりました。

ゲーム開発の現場もエンジニア以外にも検証の人やデザイナーさん、プランナーさんなどなど色んな職種の人がいて楽しかったです。ソシャゲ開発あるあるなのか分からないですが、インターン初日がメンテの日で、メンテ明けにみんな一斉にハチナイのスカウトを回していたのがちょっと面白かったです。

自分のUnity力を試せた機会だったし、アカツキという会社がどんな会社なのか知ることができたのでとても良いインターンでした!!