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型で管理する手段も良さそうです。

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

こんにちは、ゆのん(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力を試せた機会だったし、アカツキという会社がどんな会社なのか知ることができたのでとても良いインターンでした!!

UniTaskを使った話

この記事は Akatsuki Advent Calendar 2019 - Adventar 5日目の記事です。

 

はじめに

クライアントエンジニアのkawawa1989です。

現在はUnityを使っての開発を行っています、今回は初めて導入したUniTaskについてご紹介をしたいと思います。

 

UniTaskとは🤔🤔🤔

Cysharpさんが開発された、Unityでメインスレッドを使ってasync/awaitを使えるようにするプラグインです。
ちなみにUniRxは事前に入れる必要があるとか、そういうことはありませんでした。
UniRxなしでも単体で扱うことができます。
tech.cygames.co.jp




今まではIEnumeratorを使って書いていた箇所をasync/awaitで置き換えることができるようになります。

 

やりたいこと🤔

通信などの非同期処理などが多い場合、「通信が成功した(or失敗した)」のコールバック関数だらけになってしまいます。
例えばログインのフローでこんなコードがあったとします。

    // ログインフロー
    // 例えばログインボタンがタップされてログインするとする場合
    void OnLoginButtonClick()
    {
        // 利用規約が読まれていないなら利用規約UIを出したい
        if (!HasReadTermOfUse)
        {
            OpenTermOfUseDialog(onAgreed: OnAgreedEvent);
            return;
        }
        // ログインAPIを叩く
        NetworkManager.Login(onLoginSuccess: OnLoginSuccessEvent);
    }

    // 利用規約が同意された
    void OnAgreedEvent()
    {
        // ログインAPIを叩く
        NetworkManager.Login(onLoginSuccess: OnLoginSuccessEvent);
    }


    // ログイン完了
    void OnLoginSuccessEvent()
    {
        LoadNextScene();
    }

 ぶっちゃけこのレベルなら個人的には許容できるのですが、私が実際に経験したコードだとログイン一つでこれを遥かに超える複雑な作りになっていました。
なのですが、あまり具体的なことは社外秘のためとりあえずこの範囲で...
上記のような処理を、できるなら一つの関数の中で何をやっているのかすべて理解できるようにしたいですね。
 

まず最初に考えたやり方🤔

最初はコルーチンでやってしまおうと思いました。

    IEnumerator LoginFlow()
    {
        if (!HasReadTermOfUse)
        {
            var dialog = OpenTermOfUseDialog();
            // ダイアログを表示している間ずっと待機し続ける
            yield return dialog;

            // 終了したら承認されたかどうかの判定を行う
            // 利用規約に同意してもらえなかったらそこで終了
            if (!dialog.Agreed)
            {
                yield break;
            }
        }

        // ログインAPIを叩く
        var login = NetworkManager.Login();
        yield return login;

        // ログインに成功したのかどうか
        // 失敗したなら終了
        if (!login.Success)
        {
            yield break;
        }

        LoadNextScene();
    }

これで一つの関数内に収めることができます
が、いちいちAsyncOperation的なやつを取らないといけないのが面倒くさいです。
UniRxを使えばFromCoroutineでどうにかなるものの、StartCoroutineを呼び出す元になるスクリプトがないと動かせないのも欠点です。

みたいなことを会社の同僚と話していて「そういう事やりたいならUniTaskってのがあるから、それ使ってみては?」
という案が出ました。

というわけで早速導入してみました。
参考になった記事
UniRx.Async(UniTask)機能紹介 - Qiita

当初、Incremental Compilerなるものが必要で、
しかもそれはPreview版とのことだったので若干UniTaskは避けていたのですが、どうやらそれは昔の話で最近は違うらしいと知りました。

 

Incremental Compilerが不要に!😇

neue cc - UniTask(UniRx.Async)から見るasync/awaitの未来
ということでこれで安心して導入できます!!

    async UniTask LoginFlow()
    {
        // ダイアログを表示して結果が返ってくるまで待機する。
        if (!HasReadTermOfUse)
        {
            var agreed = await OpenTermOfUseDialog();
            if (!agreed)
            {
                return;
            }
        }

        // ログインAPIを叩く
        var success = await NetworkManager.Login();
        // エラーだったら終了
        if (!success)
        {
            return;
        }
        LoadNextScene();
    }

よりスッキリしました!
戻り値をawaitで待機しながら受け取れるのは便利ですね!

とはいえ全部をいきなりUniTaskに置き換えるのは難しくない?

私がUniTaskを導入したのはプロジェクトのかなり終盤でした。
そのため、全てをUniTaskに置き換えることはできないのですが、それでもやりようはあります。
例えばダイアログ関係はこのように書き換えることができます。

    // 元のコード
    public void ShowDialog(string title, string message, Action onClose)
    {
        var prefab = Resources.Load("HogeHogeDialog");
        var dialog = Instantiate(prefab);
        dialog.Initialize(title, message, onClose);
    }

    // これを元に改造
    public UniTask ShowDialogAsync(string title, string message)
    {
        var source = new UniTaskCompletionSource();
        var prefab = Resources.Load("HogeHogeDialog");
        var dialog = Instantiate(prefab);
        dialog.Initialize(title, message, () =>
        {
            source.TrySetResult();
        });
        return source.Task;
    }

UniTaskCompletionSource

UniTaskCompletionSourceというやつで終了時にTrySetResultすれば元の実装をいじくる必要なくUniTaskで待てるように改造できます!
これも最初は自分はまったく知らなかった機能で、知る前までは毎回

        var isDone = false;
        dialog.Initialize(title, message, () =>
        {
            isDone = true;
        });
        await UniTask.WaitUntil(() => isDone);

こんな風に書いてました。

UniTaskの中身を解析してみよう🤔

どう動いているのか非常に気になったので、中身を解析してみました。

PlayerLoopHelper

コルーチンの場合、StartCoroutineを走らせるためのMonoBehaviourがどうしても必要でした。
でしたが、PlayerLoopを上書きできるようになったので、この仕組みで直接UniTaskを走らせる用のUpdate関数を登録しているようでした。
PlayerLoopに関しての参考リンク
tsubakit1.hateblo.jp

PlayerLoopHelper.csの中身

    public static class PlayerLoopHelper
    {
        public static SynchronizationContext UnitySynchronizationContext => unitySynchronizationContetext;
        public static int MainThreadId => mainThreadId;

        static int mainThreadId;
        static SynchronizationContext unitySynchronizationContetext;
        static ContinuationQueue[] yielders;
        static PlayerLoopRunner[] runners;


yieldersrunnersがどうにも気になります。
PlayerLoopHelperのInitというメソッドがRuntimeInitializeOnLoadMethodによって起動時に呼び出されているようです。

        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
        static void Init()
        {
            // capture default(unity) sync-context.
            unitySynchronizationContetext = SynchronizationContext.Current;


Initialize内では以下のような感じになっていました。
InsertRunnerでループ処理を追加しています。

        public static void Initialize(ref PlayerLoopSystem playerLoop)
        {
            yielders = new ContinuationQueue[7];
            runners = new PlayerLoopRunner[7];

            var copyList = playerLoop.subSystemList.ToArray();

            copyList[0].subSystemList = InsertRunner(copyList[0], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldInitialization), yielders[0] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerInitialization), runners[0] = new PlayerLoopRunner());
            copyList[1].subSystemList = InsertRunner(copyList[1], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldEarlyUpdate), yielders[1] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerEarlyUpdate), runners[1] = new PlayerLoopRunner());
            copyList[2].subSystemList = InsertRunner(copyList[2], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldFixedUpdate), yielders[2] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerFixedUpdate), runners[2] = new PlayerLoopRunner());
            copyList[3].subSystemList = InsertRunner(copyList[3], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldPreUpdate), yielders[3] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerPreUpdate), runners[3] = new PlayerLoopRunner());
            copyList[4].subSystemList = InsertRunner(copyList[4], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldUpdate), yielders[4] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerUpdate), runners[4] = new PlayerLoopRunner());
            copyList[5].subSystemList = InsertRunner(copyList[5], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldPreLateUpdate), yielders[5] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerPreLateUpdate), runners[5] = new PlayerLoopRunner());
            copyList[6].subSystemList = InsertRunner(copyList[6], typeof(UniTaskLoopRunners.UniTaskLoopRunnerYieldPostLateUpdate), yielders[6] = new ContinuationQueue(), typeof(UniTaskLoopRunners.UniTaskLoopRunnerPostLateUpdate), runners[6] = new PlayerLoopRunner());

 
InsertRunnerの中身を見たところ、yieldLoopというのとrunnerLoopというループがあるのですが、
これをUnityの各PlayerLoopに2つずつ挿入しています。
 
f:id:kawawa1989:20191205112536j:plain


このループの中で実際にやっている処理はContinuationQueuePlayerLoopRunnerというやつをそれぞれ走らせています。
yieldLoop内でContinuationQueueのRunを走らせて
runnerLoop内でPlayerLoopRunnerのRunを走らせています。
では次にContinuationQueuePlayerLoopRunnerを見てみます。

ContinuationQueue

内部的にはactionListというActionの配列を管理しているようです。
そしてこれをRun実行時に呼び出している。
Enqueueというメソッドが用意されていて、これを呼び出すことでActionが登録できそうです。
ではこのEnqueueはどこから呼び出されているのだろう?

と思って追ってみたら、PlayerLoopHelperで管理しているようでした。
イメージとしては以下の図のような感じでしょうか。

f:id:kawawa1989:20191205113005j:plain
では、次にこのAddActionとAddContinautionがどこから呼び出されているか調べてみます。

f:id:kawawa1989:20191205114020p:plain
ReusablePromise.csが気になるので見てみます。
PlayerLoopReusablePromiseBaseというクラスのIsCompletedというメソッドで呼び出されていました。

PlayerLoopReusablePromiseBase

        public override bool IsCompleted
        {
            get
            {
                if (Status == AwaiterStatus.Canceled || Status == AwaiterStatus.Faulted) return true;

                if (!isRunning)
                {
                    isRunning = true;
                    ResetStatus(false);
                    OnRunningStart();
#if UNITY_EDITOR
                    TaskTracker.TrackActiveTask(this, capturedStackTraceForDebugging);
#endif
                    PlayerLoopHelper.AddAction(timing, this);
                }
                return false;
            }
        }


PlayerLoopReusablePromiseBaseの実装を見ればわかりますが、IPlayerLoopというやつを実装していました。

    public abstract class PlayerLoopReusablePromiseBase : ReusablePromise, IPlayerLoopItem
    {

これはMoveNextというメソッドを提供するインターフェースのようで、
MoveNextメソッドはPlayerLoopReusablePromiseBaseを継承した先で実装されていました。
WaitUntilPromise
WaitWhilePromise
YieldPromise

PlayerLoopRunnerは基本的にこのIPlayerLoopItemを配列で管理して、毎ループ時に各IPlayerLoopItemのMoveNextを呼び出しているようです。
次はUniTaskを見ます。
AddActionメソッドにログを仕込んでみました。
で、下記のようなサンプルコードを書いてみました。とりあえず3秒間待つタスクです。

    bool isDone = false;
    private void Awake()
    {
        Run().Forget();
        StartCoroutine(WaitCoroutine());
    }

    private IEnumerator WaitCoroutine()
    {
        yield return new WaitForSeconds(3.0f);
        isDone = true;
    }

    private async UniTask Run()
    {
        Debug.Log("Run Start!!");
        await UniTask.WaitUntil(() => isDone);
        Debug.Log("Run End!!");
    }

f:id:kawawa1989:20191205115942p:plain
呼ばれています!
呼び出し先を見てみると、PlayerLoopReusablePromiseBaseIsCompletedというプロパティで呼び出されているのがわかります。
PlayerLoopReusablePromiseBaseIsCompletedを呼び出しているのはUniTaskIsCompletedプロパティのようです。

UniTask

IsCompleted

UniTask.IsCompleted

        [DebuggerHidden]
        public bool IsCompleted
        {
            get
            {
                return awaiter == null ? true : awaiter.IsCompleted;
            }
        }

UniTaskawaiterというフィールドを持っていることがわかります。
このawaiterにPlayerLoopReusablePromiseBaseのインスタンスを設定しているということになります
そして、これを呼び出しているのはAwaiterというやつのようですAwaiterはUniTask内に定義されています。
Awaiter

Awaiter
        public struct Awaiter : IAwaiter
        {
            readonly UniTask task;
            [DebuggerHidden]
            public Awaiter(UniTask task)
            {
                this.task = task;
            }

こいつの中にUniTaskが内包されているようです。
で、実際はAwaiterのIsCompletedからUniTaskのプロパティにアクセスしているようです。

IAwaiter

IAwaiterの定義自体はこんな感じです。

    public interface IAwaiter : ICriticalNotifyCompletion
    {
        AwaiterStatus Status { get; }
        bool IsCompleted { get; }
        void GetResult();
    }
ICriticalNotifyCompletion

ICriticalNotifyCompletionはちょっとよくわからないので調べます。
どうもTaskでも使用されているインターフェースのようです。

.NET Framework標準で提供されているTaskAwaiter/TaskAwaiter型はINotifyCompletionインターフェースを継承するICriticalNotifyCompletionインターフェースを実装しています。

参考記事
このIsCompletedはどこで呼ばれているのか?
IsCompleted内部にログを仕込んでみます。
f:id:kawawa1989:20191205121706p:plain

どうやらAsyncUniTaskMethodBuilderとかいうやつが居るようで、そいつが呼び出しているようです。
では今度はこいつのStartメソッドに同じようなログを仕込んでみましょう。
f:id:kawawa1989:20191205121755p:plain

これ以上先はもう何もありません。
このAsyncUniTaskMethodBuilderが何者なのか?といろいろ調べていたら、これはどうもコンパイラ側が使用するクラスのようです
www.atmarkit.co.jp

IAsyncStateMachineというやつが何か関連がありそうです。
コイツについて調べてみましょう。
docs.microsoft.com

非同期メソッドに生成されるステート マシンを表します。 この型はコンパイラでのみ使用されます。

他にもこのような記事がありました。
blog.xin9le.net

・async/awaitは何かの糖衣構文で、実際はコンパイラがもっと複雑な形に変換している
・exeやdllには変換後の形で格納されている

とのことなので、試しにILSpyで中身を開いてみます。

    .method /* 06000006 */ private hidebysig 
        instance valuetype [UniRx.Async]UniRx.Async.UniTask Run () cil managed 
    {
        .custom instance void [mscorlib]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [mscorlib]System.Type) = (
            01 00 12 54 65 73 74 43 6f 64 65 2b 3c 52 75 6e
            3e 64 5f 5f 36 00 00
        )
        .custom instance void [mscorlib]System.Diagnostics.DebuggerStepThroughAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x2080
        // Code size 59 (0x3b)
        .maxstack 2
        .locals /* 11000001 */ init (
            [0] class TestCode/'<Run>d__6',
            [1] valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder
        )

        IL_0000: newobj instance void TestCode/'<Run>d__6'::.ctor() /* 06000012 */
        IL_0005: stloc.0
        IL_0006: ldloc.0
        IL_0007: ldarg.0
        IL_0008: stfld class TestCode TestCode/'<Run>d__6'::'<>4__this' /* 04000008 */
        IL_000d: ldloc.0
        IL_000e: call valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder::Create() /* 0A000012 */
        IL_0013: stfld valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder TestCode/'<Run>d__6'::'<>t__builder' /* 04000007 */
        IL_0018: ldloc.0
        IL_0019: ldc.i4.m1
        IL_001a: stfld int32 TestCode/'<Run>d__6'::'<>1__state' /* 04000006 */
        IL_001f: ldloc.0
        IL_0020: ldfld valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder TestCode/'<Run>d__6'::'<>t__builder' /* 04000007 */
        IL_0025: stloc.1
        IL_0026: ldloca.s 1
        IL_0028: ldloca.s 0
        IL_002a: call instance void [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder::Start<class TestCode/'<Run>d__6'>(!!0&) /* 2B000001 */
        IL_002f: ldloc.0
        IL_0030: ldflda valuetype [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder TestCode/'<Run>d__6'::'<>t__builder' /* 04000007 */
        IL_0035: call instance valuetype [UniRx.Async]UniRx.Async.UniTask [UniRx.Async]UniRx.Async.CompilerServices.AsyncUniTaskMethodBuilder::get_Task() /* 0A000014 */
        IL_003a: ret
    } // end of method TestCode::Run

AsyncUniTaskMethodBuilder::Create()とかAsyncUniTaskMethodBuilder::Start()とか呼ばれてます!
ILコードがよくわかりませんが、callとか呼ばれてますね。
(この辺もそのうち勉強してみたい)

[参考リンク]
www5b.biglobe.ne.jp

次に気になったこと

どうやってAsyncUniTaskMethodBuilderを呼び出している?🤔

これがちょっと謎でしたが、UniTask.csの定義をよく見るとAsyncMethodBuilderというやつがあります!

    [AsyncMethodBuilder(typeof(AsyncUniTaskMethodBuilder))] // ←これ!!!!
    public partial struct UniTask : IEquatable<UniTask>

AsyncMethodBuilderに関して参考リンク
qiita.com

つまりawaitが呼ばれたとき、その戻り値の型に対応されたAsyncMethodBuilderが呼び出される
ということでしょうか?
ここ、調べてもあまり出てこないのでちょっとわからない。
おそらくこんな感じ

f:id:kawawa1989:20191205122359j:plain
試しに
UnsafeOnCompleted(Action act)
で受け取るデリゲートを最後に呼び出さないようにしてみたら
永遠にawaitから戻ってこれない状態になったので、おそらくこれは処理が終了したあとに呼び出さないといけないやつ。
GetAwaiterが何者なのか気になったけど、これは戻り値の型が独自定義型であろうとなんだろうと関係なく呼ばれるように見えます。
docs.microsoft.com
ちなみにGetAwaiterをコメントアウトするとawaitする箇所全てでコンパイルエラーになって死にます。

要約

全体のライフサイクルはこんな感じ?だと思われる🤔
f:id:kawawa1989:20191205122539j:plain


オレオレTaskを作ってみよう!

試しに自分でawaitできるタスクを作ってみます。
UniTaskはstructで定義されていましたが、structだとデフォルトコンストラクタが定義できないので今回はclassで定義しています。

using System.Runtime.CompilerServices;
using System;

namespace Bz.Brotherhood
{
    [AsyncMethodBuilder(typeof(BzTaskBuilder))]
    public class BzTask
    {
        IAwaiter awaiter;

        public BzTask()
        {
            UnityEngine.Debug.Log($"BzTask.Constructor");
        }

        public BzTask(IAwaiter awaiter)
        {
            this.awaiter = awaiter;
        }

        public IAwaiter GetAwaiter()
        {
            UnityEngine.Debug.Log($"BzTask.GetAwaiter");
            return new Awaiter(awaiter);
        }

        private struct Awaiter : IAwaiter
        {
            IAwaiter awaiter;
            public Awaiter(IAwaiter awaiter)
            {
                this.awaiter = awaiter;
            }

            public bool IsCompleted
            {
                get
                {
                    var isCompleted = true;
                    if (awaiter != null)
                    {
                        isCompleted = awaiter.IsCompleted;
                    }
                    UnityEngine.Debug.Log($"Awaiter.IsCompleted :{isCompleted}");
                    return isCompleted;
                }
            }

            public void OnCompleted(Action moveNext)
            {
                UnityEngine.Debug.Log("Awaiter OnCompleted");
                awaiter?.OnCompleted(moveNext);
            }

            public void UnsafeOnCompleted(Action moveNext)
            {
                UnityEngine.Debug.Log("Awaiter UnsafeOnCompleted");
                awaiter?.UnsafeOnCompleted(moveNext);
            }

            public void GetResult()
            {
                UnityEngine.Debug.Log("Awaiter GetResult");
                awaiter?.GetResult();
            }
        }
    }

AsyncMethodBuilderAttributeは存在しない?

コードを書いてみたのですがエラーが出ます。
f:id:kawawa1989:20191205122824p:plain
UniTaskはAsyncMethodBuilderAttributeを定義しているようでした。
blog.meilcli.net

また、現時点でほとんどのプラットフォームではAsyncMethodBuilderAttribute属性も自分で定義する必要があります。

理由はよくわからないのですが、とにかくこっちで定義しないと駄目らしい。
以下のようなオレオレタスクビルダーを作ってみました(といってもほぼUniTaskBuilderのコピペ)

namespace Bz.Brotherhood
{
    public struct BzTaskBuilder
    {
        IAwaiter awaiter;

        // 1. Static Create method.
        public static BzTaskBuilder Create()
        {
            UnityEngine.Debug.Log("BzTaskBuilder.Create");
            var builder = new BzTaskBuilder();
            return builder;
        }

        // 2. TaskLike Task property.
        public BzTask Task
        {
            get
            {
                UnityEngine.Debug.Log("BzTaskBuilder.Task");
                return new BzTask(awaiter);
            }
        }

        // 3. SetException
        public void SetException(Exception exception)
        {
            UnityEngine.Debug.Log("BzTaskBuilder.SetException");
        }

        // 4. SetResult
        public void SetResult()
        {
            UnityEngine.Debug.Log("BzTaskBuilder.SetResult");
        }

        // 5. AwaitOnCompleted
        public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
            where TAwaiter : INotifyCompletion
            where TStateMachine : IAsyncStateMachine
        {
            UnityEngine.Debug.Log("BzTaskBuilder.AwaitOnCompleted");
            this.awaiter = awaiter as IAwaiter;
            this.awaiter.OnCompleted(stateMachine.MoveNext);
        }

        // 6. AwaitUnsafeOnCompleted
        [SecuritySafeCritical]
        public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
            where TAwaiter : ICriticalNotifyCompletion
            where TStateMachine : IAsyncStateMachine
        {
            UnityEngine.Debug.Log("BzTaskBuilder.AwaitUnsafeOnCompleted");
            this.awaiter = awaiter as IAwaiter;
            this.awaiter.UnsafeOnCompleted(stateMachine.MoveNext);
        }

        // 7. Start
        public void Start<TStateMachine>(ref TStateMachine stateMachine)
            where TStateMachine : IAsyncStateMachine
        {
            UnityEngine.Debug.Log("BzTaskBuilder.Start");
            stateMachine.MoveNext();
        }

        // 8. SetStateMachine
        public void SetStateMachine(IAsyncStateMachine stateMachine)
        {
            UnityEngine.Debug.Log("BzTaskBuilder.SetStateMachine");
        }
    }
}


そして最後にこんなテストコードを試しに作ってみます。

    void Start()
    {
        Debug.Log("Start");
        TestTask();
    }

    async BzTask HelloWorldTask()
    {
        Debug.Log("Hello World!!");
    }

    async BzTask TestTask()
    {
        Debug.Log("TestTask Start");
        await HelloWorldTask();
        Debug.Log("TestTask End");
    }

これでも一応エラーになりません(まったく待機とかしないけど)
そして実行してみます。
f:id:kawawa1989:20191205123138p:plain

動いた!
現在のままだとawaitとか言っておきながら全く待機していないので今度は例えば60フレーム後に処理が進行するようにしてみたいです。
このBzTaskのIAwaiterに何か実装を渡せばその実装に合わせて動くようになっています。
IAwaiterの中身はUniTaskのものとほぼ同じです。

    public interface IAwaiter : ICriticalNotifyCompletion
    {
        // BzTask.Awaiter内部で呼び出される。
        bool IsCompleted { get; }
        void GetResult();
    }


そしてこのIAwaiterを実装した60フレーム待機するクラスを作ってみます。

    private interface IOnUpdate
    {
        bool OnUpdate();
    }

    private class FrameAwater : IAwaiter, IOnUpdate
    {
        int frame = 0;
        int waitFrameCount = 0;
        Action moveNext = null;

        public FrameAwater(int waitFrameCount)
        {
            this.waitFrameCount = waitFrameCount;
        }

        public bool OnUpdate()
        {
            Debug.Log($"OnUpdate[frame:{frame}, waitFrameCount:{waitFrameCount}]");
            if (frame == waitFrameCount)
            {
                moveNext();
                return false;
            }
            frame += 1;
            return true;
        }

        // BzTask.Awaiter内部で呼び出される。
        public bool IsCompleted { get { return false; } }
        public void GetResult()
        {
        }

        public void OnCompleted(Action continuation)
        {
            moveNext = continuation;
        }

        public void UnsafeOnCompleted(Action continuation)
        {
            moveNext = continuation;
        }
    }


そしてMonoBehaviour側にIOnUpdateをUpdate時にコールする、みたいなやつを追加します。

    List<IOnUpdate> list = new List<IOnUpdate>();
    List<IOnUpdate> removeList = new List<IOnUpdate>();
    void Update()
    {
        removeList.Clear();
        foreach (var update in list)
        {
            if (!update.OnUpdate())
            {
                removeList.Add(update);
            }
        }
        foreach (var item in removeList)
        {
            list.Remove(item);
        }
    }

そして試しに60フレーム待機するように以下のように書き換えてみます。

    void Start()
    {
        Debug.Log("Start");
        TestTask();
    }

    BzTask WaitForFrame(int frame)
    {
        Debug.Log("WaitForFrame");
        var frameAwaiter = new FrameAwater(frame);
        list.Add(frameAwaiter);
        return new BzTask(frameAwaiter);
    }

    async BzTask TestTask()
    {
        Debug.Log("TestTask Start");
        await WaitForFrame(60);
        Debug.Log("TestTask End");
    }

意図した通りに動くのであれば、
60フレーム経過したらUnsafeOnCompleted()で受け取ったデリゲートを実行し、それによってタスクは終了するはずです。

f:id:kawawa1989:20191205123431p:plain
f:id:kawawa1989:20191205123516p:plain
しっかり60フレーム待機してくれています!
長くなりましたがたまにこうやって他の人の作ったプラグインのソースコードを除くのは楽しいですね。


ここまで読んでくださってありがとうございました!