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

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

IAMユーザにIP制限をかけていますか?AWS Configのカスタムルールを作成し、システム監査を自動化した話

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

アカツキでエンジニアをしているe__komaと申します。 今年はAWS Summit TokyoAmazon Game Developers Conference などを始め、色んなところでAWS運用を紹介させていただいております。

今回もそんなAWS運用話の1つです。 AWSアカウントが増えてくると、統制をとるのが大変ですよね。AWS Configを使えばポリシー違反のリソースを検知し、システム監査を自動化することができます。

この記事では、IAMユーザのIP制限を題材に、AWS Configのカスタムルールを作成する事例をご紹介いたします。

経緯

弊社ではEC2 IAMロールを利用したり、一時的な認証情報を利用することで、極力IAMユーザを作らない運用を目指しています。 一方で、各種サードパーティツールの連携ではIAMユーザが必要になることもあります。 IAMユーザを作成した場合は必ずIP制限をかけることにしていますが、大量のAWSアカウントを運用していると、この統制を保つことが大変です。

一部のセキュリティポリシーに対してはAWS Configを使って自動監視していますが、AWSに最初から用意されているマネージドルールの中にはIAMユーザのIP制限をチェックするものはありません。 そのため、カスタムルールを作ることにしました。

AWS Configとは?

AWSの設定がポリシーに準拠しているか自動で評価することができる、コンプライアンス監査のサービスです。 例えば、IP制限がされていないセキュリティグループを検知したり、Publicになっているリソースを検知する…など、 これらの監視を、最初から用意されているマネージドルールを有効にするだけで実施することができます。 マネージドルールのリストはこちら。

マルチアカウントマルチリージョンのデータを集約したり、リソースの作成、変更、削除を検知したり、自動修復したりと、豊富な使い方が用意されています。

カスタムルールの作成

今回、やりたいことはIAMユーザにIP制限が設定されているかを監視することです。 マネージドルールには用意されていませんが、AWS Configのルールは自分でカスタムルールを作ることができます。

監視したい内容をLambdaで実装して、AWS Configに評価結果を送信することで、ルールに準拠なのか非準拠なのかを判断することができます。 Lambdaで実装するのであれば、CloudWatch Eventsで定期チェックするのとあまり変わらないように思えるかもしれませんが、AWS Configのカスタムルールとして実装すると

  • マルチアカウントマルチリージョンの非準拠ルールを、1つのアグリゲータアカウントに集約し一元管理できる
  • つまり全アカウントを監視しつつ、その対応状況の管理がプロジェクト依存にならない
  • 新規AWSアカウント作成時のフローに、ルールを組み込むことができる

といったようなメリットがあります。

実装

簡易ですが、以下のような感じになります。 IAMポリシーのStatement内にIPアドレスを指定するキーがあるか(ただしPublic IPはNG)をチェックしています。 ここでは、Lambdaの実装のみで、Lambdaに必要なRole、デプロイ方法には言及しません。

import boto3
import logging
from datetime import datetime

session = boto3.Session()
iam_client = session.client('iam')
config_client = session.client('config')

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def lambda_handler(event, _context):
    logger.info(f'event: {str(event)}')
    result_token = event['resultToken']

    iam_users = iam_client.list_users()
    iam_user_names = [i['UserName'] for i in iam_users['Users']]

    if not iam_user_names:
        logger.info('IAM User does not exist')
        return

    for user_name in iam_user_names:
        user_id = next(i['UserId'] for i in iam_users['Users'] if i['UserName'] == user_name)
        evaluate_compliance(user_name, user_id, result_token)


def evaluate_compliance(user_name, user_id, result_token):
    user_policies = iam_client.list_user_policies(UserName=user_name)

    compliance_type = 'NON_COMPLIANT'
    for policy_name in user_policies['PolicyNames']:
        user_policy = iam_client.get_user_policy(
            UserName=user_name,
            PolicyName=policy_name
        )
        if is_ip_restricted(user_policy):
            compliance_type = 'COMPLIANT'

    logger.info(f'{user_name} is {compliance_type}')
    config_client.put_evaluations(
        Evaluations=[
            {
                'ComplianceResourceType': 'AWS::IAM::User',
                'ComplianceResourceId': user_id,
                'ComplianceType': compliance_type,
                'OrderingTimestamp': datetime.today()
            }
        ],
        ResultToken=result_token
    )


def is_ip_restricted(user_policy):
    statements = user_policy['PolicyDocument']['Statement']

    ip_restriceted = False
    for statement in statements:
        try:
            if statement['Effect'] == 'Allow':
                allow_ips = statement['Condition']['IpAddress']['aws:SourceIp']
            else:
                allow_ips = statement['Condition']['NotIpAddress']['aws:SourceIp']
        except KeyError:
            allow_ips = []

        if not allow_ips:
            pass
        elif is_include_publice_ip(allow_ips):
            ip_restriceted = False
            break
        else:
            ip_restriceted = True

    return ip_restriceted


def is_include_publice_ip(ips):
    is_public = False
    for ip in ips:
        if '0.0.0.0' in ip or '::' in ip:
            is_public = True

    return is_public

※ 今回はサンプルのためインラインポリシーのみのチェックです。別でアタッチされたポリシー、グループポリシーなどのチェックも必要です。

Configルール部分

Lambdaのデプロイができたら、AWS Configのカスタムルールを追加します。 AWS Config側で、カスタムルールの追加を選択して、LambdaのARNを指定すればOKです。

結果

以下のように、ルールに準拠/非準拠なIAMユーザを検知できるようになります。 クロスアカウントでこのLambdaを実行できるようにしておけば、他のマネージドルールと同様に、マルチアカウントマルチリージョン(IAMに限ってはグローバルリソースのため1リージョンで十分ですが)を監視することができます。

f:id:e__koma:20191216151514j:plain
準拠ルール

f:id:e__koma:20191216151520j:plain
非準拠ルール

まとめ

AWS Configのカスタムルールを実装し、IAMユーザにIP制限がされているかを自動検知する事例の紹介でした。 マネージドルールは随時増えていますが、カスタムルールを作れば、より柔軟な監視が実現できます。

今回は触れませんでしたが、非準拠ルールを検知したタイミングで、自動Slack通知することなどももちろん可能です。 AWS Configを使えば、システム監査の多くを自動化することができるため、使わない手はありません。

この記事がみなさまの運用のお役に立てれば幸いです。

Vim 8.2 リリース!同時に公開されたデモのプラグインを解説してみる

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

thinca です。普段は Vim を使って開発をしています。

そんな Vim ですが、つい 2 日ほど前、待望の Vim 8.2 がリリースされました!やったね🎉

本記事では Vim 8.2 で何ができるようになったのかを、同時に公開されたデモプラグインを通して見ていこうと思います。

Vim のリリースについて

その前に、Vim の開発体制について少し説明します。 Vim の開発は GitHub の vim/vim リポジトリで開発されています。ブランチは master のみで、最新版は同時に開発版でもあります。

Vim は、パッチ(Git 管理になった今ではコミットとほぼ同義)を積み重ねて改善が行われます。前回のマイナーバージョンアップ(Vim 8.1)から少しずつパッチを積み重ね、ある程度のところでキリを見て新しいバージョン(今回の場合は 8.2)を振ります。

つまり Vim の HEAD を追いかけている人からすると、バージョンが 8.2 になったところで劇的に何かが変わるわけではありません。Linux の各ディストリビューションも、多くの場合はキリを見てパッチが入ったバージョンをリリースするので、気付いたら新しい機能が入った Vim を使っている場合もあります。

今回、ここで言う Vim 8.2 の新機能とは、Vim 8.1 リリース時点から足された機能のことを指します。上記の事情から、中にはもうずいぶん前から使えていた機能もあります。

デモプラグイン

さて、Vim の開発者である Bram さんは、今回 Vim 8.2 をリリースするにあたって Vim 8.2 で追加された機能のデモンストレーションをするためのプラグインを公開しました。

https://github.com/vim/killersheep

これは Vim 上で動作するゲームで、Vim 8.2 で追加された様々な機能が使われています。

動作の様子は以下のような感じです。

:KillKillKill Ex コマンドでゲームを開始し、画面下の砲台を操って羊の攻撃を避けつつ倒していきます。背景には編集中のテキストがそのまま残っています。とてもシュールですね。

ポップアップウィンドウ

Vim はウィンドウの中にバッファを表示することができ、ウィンドウは縦か横に分割することで複数のバッファを表示することができます。 Vim 8.2 では、新たにポップアップウィンドウがサポートされました。これはウィンドウの分割とは独立して、Vim 上の好きな位置にウィンドウを配置する機能です。IDE のようなプラグインを実装するために、ドキュメントや補完対象の情報をカーソルの近くに表示することができます。 ゲーム内では、羊や砲台がポップアップウィンドウとして実装されています。編集中のテキストの上でゲームが動くのもこれのおかげです。

テキストプロパティ

ゲーム中で砲台や羊に色が付いているのは、テキストプロパティを使っています。

ハイライト自体は今までの Vim でもできましたが、より直接的にハイライトを指定できます。

テキストプロパティにはテキストが編集された際にその位置が連動して動く性質もあるので、ハイライト箇所を絶対位置で指定するよりも柔軟な使い方が可能です。

サウンド

実際にゲームをプレイするとわかりますが、対応している環境であれば効果音が鳴ります。 Vim 8.2 では音を鳴らす機能が搭載されました。

テキストエディタに音を鳴らす機能なんているの? って思いますよね。私もそう思います。なんで入ったんだろう…。ゲームは作りやすくなったかもしれないですね。

まとめ

Vim 8.2 では、今後 IDE のような機能をサポートするための下地のような機能が多数入りました。LSP(Language Server Protocol)の発展も目が離せませんし、今後よりリッチな開発環境の整備が進むことはとても楽しみです。

古いだけじゃない、新しい Vim に皆さんも触れてみてはいかがでしょうか。

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. 技術の積み上げを称賛する

続きを読む