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

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

クラウドにインフラがある会社のセキュリティへの取り組み

これは Akatsuki Advent Calendar 2019 20日目のネタです。

CTO兼セキュリティの責任者を担当している田中です。株式会社アカツキでは、ネットワークとエンドポイントデバイス、一部のビルドサーバ以外のリソースを全てクラウドに置いています。

「出来る限りクラウドを利用する」という考え方は事業の柔軟性を重視し、インフラ管理コストを下げたい企業では一般的になっているかと思います。

クラウドをインフラにすることであまりセキュリティを重視していない会社も多いと思います(昔のアカツキもそうでした)が、「個人情報を扱うようになった」「事業が成功した」といった事業状況の変化によって求められるセキュリティレベルも変わってきます。

セキュリティを意識せずに開発していた時代から現在まで、様々なセキュリティ対策を検討し、実施してきました。 セキュリティは組織・プロセス・規範/法律・技術と様々な観点がありますが、この記事では技術面の対策に焦点を当てて、どのような対策をしてきたか、その一部を紹介したいと思います。

ID管理とSSO

大雑把に言えば、セキュリティを管理するということは、何を信用するかを管理することです。 クラウドを利用しているということは、誰かが認証/認可を管理しているはずですが、中央集権的なID基盤が無いと退職/異動の権限設定の漏れが発生したり、インシデント発生時に迅速な対応ができない等の問題が発生します。

ID管理には Single source of truth (SSOT) という考え方がとても重要です。 アカツキではADをID管理のSSOTとしています。システム構成は以下のイメージです。

システム 役割
AD ID管理のSSOT
Azure AD Office365ライセンスの認証
OneLogin SSO( Single Sign On )の管理

Active Directory (AD)

ID管理の中心となるデータソースです。個人を一意に識別するための情報を管理しています。 以下の情報をOUとしてメンテナンスしており、プロビジョニングの属性として利用可能にしています。

  • 所属会社
  • 雇用形態
  • 所属組織
  • 職位

詳しい方は「全部クラウドならJumpCloudのようなCloud Directoryを使うほうが良いのでは?」「OktaやOneLoginのようなIdentity Provider (IdP)があればADは不要では?」と思うかもしれません。 当時は、以下の理由から、この構成になりました。

  1. プリンターの認証など、ADDS認証しか対応していない機器があるため、ADが必要
  2. Microsoft ライセンスの管理にAzureADが必要
  3. AzureADは管理機能が不足しており、IdPとしてはOneLoginのような便利なものを使いたい

ADは古くからあるので、周辺ツールも充実しています。検討した結果、ADManager Plusというツールが使いやすかったので、これをADの運用に利用しています。

AzureAD

Azure AD Connect を利用して、ADで管理しているIDをAzure ADへ同期しています。 Microsoft Office365 ライセンスの認証にAzure ADが必須なので、Officeライセンスの認証のために、Azure ADと同期しています。

2016年にはシングルフォレストドメインしか対応していなかったAzure ADも、最近はID基盤として十分な機能と管理機能を持っています。 ADDS連携をAzure ADDS連携に置き換え、Azure ADをID管理のSSOTとする運用でも、問題ないような気がしています。

OneLogin

AWS、Googleアカウント、Slackやラクスルのようなサービスまで、様々なクラウドサービスへのSSOに利用しています。 また、ネットワーク機器の管理画面やツール等の自社アプリケーション、Jenkinsへのログイン等、社内メンバーのIDを元に認証したい全てのツールを、OneLoginで認証するように設定しています。

OneLoginは機能表で比較すると、Oktaのように高くないし、機能が多くて魅力的です。採用当時は機能の多さと、担当者の経験により、OneLoginを選定しました。

運用をしていくうちに管理画面が微妙に使いづらい(保存時の余計なダイアログにより保存できなかったという事例が多発している、ロールのフィルターができないため操作対象を間違える等)、WebAPIのAPIトークンがほぼ全て強い権限を求める等の、細かい辛さも感じています。

今だったらどんな構成にするか?

Gartner Magic QuadrantThe Forrester Waveを見ると、IdPとしてはOktaが最高評価を受けています。 ざっくりとOneLogin, Ping Identity, Oktaを比較検証しましたが、管理機能の使いやすさやカスタムアプリのフィールドカスタマイズ、対応アプリケーションの多さなど、Oktaが一つ頭を抜けている感覚もあります。

※ The Forrester WaveでLeaderに選出されているIdaptiveは試しておらず、少し気になっています。

ただ、Microsoft 365 E3 ライセンスを一つ持っているとPREMIUM P1, Microsoft 365 E5 ライセンスを一つ持っているとPREMIUM P2ライセンスが有効になるAzure ADはコスパという面では最高です。

今からID管理基盤を構築するならどうする?と問われたら、以下のどちらかの構成を検討します。

  • 管理面でのストレスを最小限にしたい場合はOktaをID管理基盤としてAzure ADと連携する
  • コストを最小限にしたい場合はAzure ADだけをID管理基盤として頑張る

CASB: Netskope

クラウドを業務の中核として利用していると、日々利用されるクラウドサービスが変わってきています。 アクセス制御してコントロールすることはやめたほうが良いでしょう。業務改善のスピードが遅くなりますし、自由さの阻害はShadow ITが生まれる原因にもなります。

とはいえ、危険なクラウドの利用を把握しない・追跡可能性が無い、というのはセキュリティ管理者の怠慢です。 アカツキではNetskopeを利用して、リスクが高いクラウドの利用を監視したり、DLP監視としてConfidentialな資料を社外からダウンロードされたときの監視をしています。

エンドポイントセキュリティ

EPP/EDR: CrowdStrike Falcon

この分野には様々な製品がありますが、アカツキではCrowdStrike Falconを利用しています。

EDRの機能を含むこと、Linux, Mac, Windows 問わず監視ができること、検知率が高いこと、ネットワーク遮断等の対応ができること、インシデント発生時に「どこで、どんなプロセスが動いていたか」を遡って検索できること、等が選定の理由です。

端末管理: Jamf / Intune

端末を配布した後に、エンドポイントセキュリティ製品が無効化されていたり、セキュリティ設定が変更されることを想定しておく必要があります。 MacはJamf、WindowsはIntuneを利用して、FalconやNetskopeが有効化されていることを確認しています。

監視

SIEM : SumoLogic

ADやネットワーク機器のログ、EDRのアラートや各種SaaSのログを、全て集約しています。 一箇所にログが集約されていることで、「OneLoginログイン時のリスクスコアが高いユーザが不審な行動をしていないか?」の検索や「全てのオフィスからのEmotetへの通信をアラートする」といった設定が、数分で可能になります。

セキュリティインシデント発生時は初動の調査速度がとても重要なので、SoC/CSIRTの運用をする上で、SIEMへのログ連携は必須だと考えています。

ネットワーク

ファイアーウォール

オフィスネットワークのファイアーウォールにはFortiGateを利用しています。 アンチウィルス機能、IPSによる侵入防御、Webコンテンツフィルタリングによるセキュリティ上問題のあるサイトのブロックを有効化しています。

また、ファイアーウォールの設定変更があった際にはSumoLogicからSlackにアラートを飛ばすように設定しており、意図せぬセキュリティホールが発生しないように監視しています。

ネットワーク監視: Verizon NDR

支給端末に十分なセキュリティ対策を施しているからといって、ネットワーク監視をしなくて良いという理由にはなりません。 LANケーブルにより不用意に接続されたデバイスの存在や、ネットワーク機器の脆弱性への攻撃を考慮する必要もあります。

アカツキでは、ネットワークレイヤの監視として、Verizon NDRを利用しています。 オフィスネットワークだけでなく、AWS/GCPのIaaS環境も監視対象とすることで、運用環境を攻撃された際に検知できるようにしています。

トリアージまでは パロンゴ社 にお願いしています。 アラート情報を流しているSlack channelをパロンゴ社との共有チャンネルとしています。 高い技術力を持ったパロンゴ社のメンバーにより、24時間365日 迅速な対応をしていただいており、とても助かっています。

f:id:csouls:20191220220654p:plain

秘匿情報を扱うクラウドサービス

全ての業務をクラウドで管理しているため、利用されているクラウドアプリケーションは200程度あります。 重要な情報を扱うサービス、攻撃を受けたときの影響が大きいサービスを把握し、常にセキュリティ対策を改善していくことが重要だと考えています。

利用している会社が多いであろうサービスをピックアップして、アカツキでの対策の工夫を紹介します。

AWS

AWSのセキュリティは考えることがたくさんありますが、クラスメソッドさんの AWSでのセキュリティ対策全部盛り 初級から中級まで という資料がまとまっていて最高です。

アカツキでは上記リンクにあるような基本的な対策に加えて、

  • AWSアカウントを払い出したタイミングで十分なセキュリティ対策(AWS Config, CloudTrail, SIEMへの連携など)が施されている状態にする
  • 運用が行き届かない古いAWSアカウントは狙われがちなので、不要になったらなるべく早くアカウントごと削除する

としておき、脆弱性を発生させるような操作が行われた場合に、すぐに気付けるようにしています。

もし、AWSのセキュリティ脆弱性に触れてみたい人は、以下の"crackme"サイトに挑戦してみて下さい。脆弱な環境はいかに簡単に生まれるかというのを、実感出来ると思います。

http://flaws.cloud/

http://flaws2.cloud/

メール: Gmail

Emailはコミュニケーションの入り口ですので、攻撃者はよくEmailを送って侵入を図ります。

Gmailにはセキュリティサンドボックスにより不正なソフトウェアを発見する機能がありますので、有効化しています。

f:id:csouls:20191220225739p:plain

ストレージ: Google Drive

一般公開ファイルとしてリンク共有することはできない設定にしています。 しかし、「ドメイン内へのリンク共有」は気軽にできてしまうのがGoogle Driveの怖いところです。 特定の文字列をタイトル名に含むファイルや、特定の共有ドライブに対して、共有設定の監視をしています。

SIEMで監視しても良いのですが、監査ログのペイロードが大きすぎてパースできない場合もあるので、G Suiteの監査ログ画面から、アラートを作成しています。

f:id:csouls:20191220212948p:plain

アラートはメールの転送ルールでSlackに通知するよう、設定しています。

f:id:csouls:20191220213208p:plain

GitHub

GitHubは強力な開発支援ツールですが、大きなセキュリティホールでもあります。 AWS, GCP, GitHub, Slack 等のAPIトークンをGitHub Publicリポジトリに投稿してしまうと、すぐに他人に知られてしまいます。 例えば、https://shhgit.darkport.co.uk/ というサイトでは、GitHub に公開されたトークンをリアルタイムに発見することができます。

PublicリポジトリへのPushにだけ気をつければ良いかというとそうでもなく、GitHub Personal Access Tokenを不用意に公開してしまったり、PrivateリポジトリへのCollabolatorの登録間違いといったことでも、漏洩可能性があります。また、GitHubへのアクセストークンはCI/CD環境に保存されていることも多く、Jenkins環境をターゲットとして攻撃をするアクターも存在します。

アカツキでは、Privateリポジトリを捜査するツールを作成して、commitされているトークンを探し出しています。

Slack

Slackはインテグレーションが豊富なため、攻撃者に狙われやすいツールでもあります。 特にSlackのメッセージ内容を読み取ることが出来るAPIトークンは慎重になるべきです。 Slackアプリは承認を必須としており、以下のポリシーを元に承認可否を判断しています。

APIトークンやアプリの種類については、https://slack.com/intl/ja-jp/help/articles/215770388 を参考にして下さい。

aktsk や会社単位のワークスペースに対するアプリは、以下の様に判断します。その他ワークスペースは、ワークスペース管理者の判断におまかせしています。

## 外部アプリケーション
### メッセージ内容を取得できる外部アプリ

以下スコープを要求する外部アプリケーションは原則拒否します。

メッセージが読めるスコープの一覧:
bot
channels:history
conversations:history
groups:history
im:history
mpim:history
search:read
stars:read

メッセージ内容を取得する外部アプリは、信頼できる発行元かつ、連携しなければならない理由がある場合に限り承認しますので、申請理由を詳しくご記載下さい。

### チャンネル名やDMのリストを取得することができる外部アプリ

以下スコープを要求するアプリケーションは、利用用途に応じて承認可否を検討します。申請理由を詳しくご記載下さい。

チャンネルを取得することができるスコープの一覧:
channels:read
groups:read
mpim:read
im:read

### Slack社によるレビューを通っていない外部アプリ

Slack社によるレビューが実施されていないアプリケーションについては、原則承認しません。どうしても必要な場合は、申請理由をご記載下さい。

## 内部(自作)アプリケーション

原則として、IP制限を必須とします。
その他は外部アプリケーションの判断基準に従います。

## Slackアプリ以外のIntegration

### レガシーテストトークン

どんな理由があっても、利用禁止です。ワークスペース設定で不許可としています。

### Outgoing Webhook

取得元のチャンネル制限を必須とします。

### Bot

カスタムインテグレーションボットのユーザートークン は非推奨です。
自作のボットは、スコープを最小限にした内部アプリケーションへ置き換えて下さい。

### Slash command, Incoming Webhook

特に制限しません。申請があったら承認します。

また、Slack Enterprise Gridの監査ログ、アクセスログをSIEMに連携しており、インシデント発生時に追跡できるようにしています。

トークンの管理

チェック

標的型攻撃におけるCyber Kill Chainを元に技術的対策を分類してみました。 クラウド中心にシステムを運用している会社にとって、以下のチェック項目がご参考になれば幸いです。

偵察

侵入に使えそうな情報を収集する行為です。企業サイト、SNS、公開サーバなどが調査対象になります。

[対策]

監視していない、運用されていない場所があると、侵入される危険性が高くなります。不要なサーバやクラウドサービスは削除しましょう。

  • 中央集権的ID管理ができているか?退職や異動について、即日対応できているか?
  • 開発用のサーバ等、不必要にポート公開していないか?
  • 古いサーバ・運用されていないサーバはないか?
  • CASBによって誰がどのようなクラウドサービスを利用しているか把握し、リスクを管理できているか?

武器化

エクスプロイトコードやマルウェアを作成し、メールやSNSを使って送付します。 URLをクリックすると、BeEF が実行され、内部ネットワークの脆弱性を把握するといった攻撃手法もあります。 最近は Emotetの被害が主にWordファイルによって拡大していたりします。

[対策]

メールはサイバー攻撃の入り口としてまだ多く利用されていますので、対策しましょう。攻撃手法に関する情報を得ておくことも重要です。

  • JPCERTなど信頼できるコミュニティから、注意喚起情報を得ているか?
  • メールのサンドボックス機能等により、マルウェアがダウンロードされる前に隔離する対策ができているか?
  • C&CサーバやフィッシングサイトのWebフィルタリングは設定されているか?

デリバリー

メールの添付ファイルや、レジュメにカモフラージュしたURL等によって、マルウェアに感染させます。 ファイアーウォールやVPNに脆弱性があれば、そこから侵入されこの段階まで進まれます。

[対策]

エンドポイントセキュリティが重要です。

  • エンドポイントデバイスにEDR製品(最低でもEPP製品)がインストールされているか?
  • エンドポイントデバイスのEDR稼働状況を、Jamf/Intune等のデバイス管理ソフトウェアでチェックできているか?
  • VPNは無効化されているか?VPNを利用している場合は、適切に運用され、監視できているか?

エクスプロイト/侵入

侵入に成功したPCから、内部ネットワークの構造を把握し、機密情報が保存されている場所を探し、アクセス権限を盗み取ります。

[対策]

内部ネットワークの攻撃を防止/検知するための対策や、脆弱性調査を行いましょう。

  • IPS/IDSが運用されているか?
  • ファイアーウォールは適切に設定されているか?
  • ネットワークの監視(例: C&Cサーバやランサムウェアが置かれていたホストへのアクセス)ができているか?
  • NDR等のネットワーク監視の製品により、エンドポイントデバイスからの不審な通信を監視できているか?
  • 定期的な内部ネットワーク診断により、社内ネットワークからの脆弱性を発見できているか?
  • 利用しているネットワーク機器やソフトウェアのバージョンを把握しており、迅速にセキュリティパッチを適用できているか?

潜伏/目的の実行

情報の盗み出し、システムの改ざん、ログの消去を行います。

[対策]

ここまでKill chainが進んでいたら予防は難しいので、素早く攻撃を発見できることが重要です。 また、ログの消去を難しくするように、SIEMに連携しておくことも重要です。

  • 重要な情報を扱うツールのアクセスログは全てSIEMに連携されているか?
  • SIEMのアクセスログ監視は、継続的にアップデートされているか?
  • 各ツールの特権ユーザのアクティビティやS3の公開設定の変更など、特に重要な操作をアラートできているか?
  • 開発環境や外部からアクセス可能な本番環境にも、EDR及びネットワーク監視が適用されているか?
  • AWSやGCPの監査ログを監視できているか?(IAMユーザの作成や国外からのコンソールアクセスなど、不審なアクティビティを監視できているか?)

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