ゲームロジックを複数のアプリケーションで共有する

ゲームロジックを複数のアプリケーションで共有する

ソフトウェアエンジニアの後藤です。

今回は私が所属するプロジェクトで行った、ゲームロジックのクラスライブラリ化について紹介します。

経緯

開発初期にゲームの戦闘パートなどの、所謂「メインゲーム」を Unity のプロジェクト内に実装していました。

しかし開発が進むにつれて、 Unity を使わずにメインゲームを動かしたい場面が出てきました。その例を以下に挙げます。

  • AI に1000回バトルさせて勝率を記録する
  • サーバに送られたログから戦闘を再現できるか検証し、ユーザーがチートしているか調べる

これを実現するため、メインゲームの機能を Unity から分離して他のアプリケーションでも利用できるようにしました。

目的・問題設定

サンプルとして簡素なRPGの戦闘パートを作成しました。

サンプルのRPGの戦闘パート

[ゲームルール]

■キャラクターは「味方」と「敵」のチームに分かれる

■素早さが高い順に1人ずつ、誰かに攻撃する

■HP が 0 以下になったキャラクターは戦闘から離脱する

■どちらかのチーム全員が戦闘から離脱したら終了

この戦闘の主要な機能について、構成図を以下に示します。

BattleScene という MonoBehaviour スクリプトをエントリーポイントとして、以下の流れで処理が進みます。

  • ①マスターデータから必要なパラメータだけを取り出し、モデルのインスタンスを生成
  • ②モデルのインスタンスを Repository に保存
  • ③バトルを開始し、ゲームルールに沿って進行
  • ④キャラクターを操作し、ダメージや命中の計算をして、結果をヒット演出や GUI で表示

今回はバトルの進行に必要な機能(以降、コアロジックと呼びます)を Unity から分離して、コンソールアプリケーションで利用することを目的とします。

コアロジックが Unity に依存しないようにする

依存関係の整理

Unity の外に移したコードは UnityEngine の機能を使えなくなります。
そのため、 MonoBehaviour や ScriptableObject を使う機能とコアロジックは分けて実装しておきます。

先程のクラス図の機能を以下に分類しました。

  • Unity : MonoBehaviour 等の Unity のコンポーネントを継承しているか、メンバーに持つ機能
  • データ定義・シリアライズ : キャラクターやスキルのパラメータを設定するマスターデータに依存する機能
  • コアロジック : それ以外でバトル開始〜終了までに必要な機能

ただし上図のままではコアロジックが Unity 側の機能に依存しているので、インターフェイスを定義してコアロジック側に含めます。
Unity 側の機能がインターフェイスを継承することで、依存の向きが Unity → コアロジック のみになりました。

また、 Unity に依存するライブラリもコアロジックでは参照できなくなります。

今回の対応では UniTask を使っていますが、後述するクラスライブラリのプロジェクトに .NET Core 用の UniTask パッケージ を追加して解決しました。

コアロジック中の UnityEngine API の差し替え

元々 Unity プロジェクトでコアロジックを実装していたので、様々な場面で利用する機能にも UnityEngine の API を利用していました。Unity に依存しないようにするために、以下の対応が必要でした。

  • UnityEngine.Random → System.Random に差し替え
  • UnityEngine.Mathf → System.Math に差し替え
  • UnityEngine.Debug.Log → ログ出力機能を interface で抽象化

コアロジックをクラスライブラリ化する

コアロジックを Unity の外に移していきます。
クラスライブラリ化には以下の記事を参考にしました。
neue cc – .NETプロジェクトとUnityプロジェクトのソースコード共有最新手法

1. プロジェクトの用意

Unity プロジェクトの外にクラスライブラリ用のソリューションとプロジェクトを作成します。

コアロジック用のクラスライブラリプロジェクトを作成

クラスライブラリは Unity 側にコード共有されるので、 C# や .NET のバージョンは Unity に合わせます。
今回はサンプルを作成した Unity に合わせて C# 9 と .NETStandard 2.1 に設定します。

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <LangVersion>9.0</LangVersion>
        <TargetFramework>netstandard2.1</TargetFramework>
        <Nullable>enable</Nullable>
    </PropertyGroup>

</Project>

2. コード共有の設定

まずクラスライブラリのビルド時に生成される binobj を Unity に共有されないようにします。

これには Directory.Build.props を .sln ファイルがある場所に配置してUnity がインポートしない名前のフォルダを出力先に設定します。
今回は .artifactsというフォルダを出力先にしました。

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <PropertyGroup>
        <!-- Unity ignores . prefix folder -->
        <ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
    </PropertyGroup>
</Project>

次に、クラスライブラリ側で Unity 用のファイルを無視します。

package.jsonasmdef は Unity の PackageManager で参照するために必要なファイルで、クラスライブラリには不要です。
また Unity から参照されたとき meta ファイルが生成されるのでこれも無視します。

プロジェクトの .csproj ファイルに以下の項目を記述します。

<ItemGroup>
    <None Remove="**\package.json" />
    <None Remove="**\*.asmdef" />
    <None Remove="**\*.meta" />
</ItemGroup>

最後に package.jsonasmdefを作成します。

{
  "name": "com.sample.2d-battle-core",
  "version": "1.0.0",
  "displayName": "2d-battle-core",
  "description": "2d-battle のコアロジック",
  "unity": "2022.3"
}
{
    "name": "2d-battle-core",
    "rootNamespace": "",
    "references": [],
    "includePlatforms": [],
    "excludePlatforms": [],
    "allowUnsafeCode": false,
    "overrideReferences": false,
    "precompiledReferences": [],
    "autoReferenced": true,
    "defineConstraints": [],
    "versionDefines": [],
    "noEngineReferences": false
}

以上の作業を済ませたフォルダ構成を以下に示します。

クラスライブラリプロジェクトのフォルダ構成図

これらの設定を済ませると PackageManager から package.jsonを置いたフォルダ以下のコードを共有できるようになります。

パッケージを参照するときは manifest.jsonを開いて相対パスを直接記述します
PackageManager ウィンドウの「 Add package from disk 」で追加すると絶対パスになってしまいます。

クラスライブラリを参照

記述したらPackageManager ウィンドウを開き、パッケージが追加されているか確認します。

PackageManager でクラスライブラリを参照できているか確認

3. コアロジックをクラスライブラリに移す

Unity からクラスライブラリの asmdef ファイルがあるディレクトリにコードを移します。
このとき Unity への依存が残っているとコンパイルエラーが出てしまいます。

また、コアロジックのユニットテストも Unity の外に移せます。

クラスライブラリのソリューションにユニットテストプロジェクトを作成して移します。
ユニットテストは Unity とコード共有しないので、クラスライブラリと後方互換性があれば C# や .NET のバージョンは任意です。

クラスライブラリを使ってアプリケーションを作る

今回は以下の状況と機能を実装した最小のバトルを構築します。

■ 味方と敵は1人ずつ

■ 使用するスキルは1つだけ

■ キャラクターに行動順が回ってきたらスキルを相手に撃つ

■ キャラクターやスキルのパラメータはコードに直接書く (マスターデータは使わない)

まず、コンソールアプリケーション用にソリューションとプロジェクトを作成します。
クラスライブラリと後方互換性があれば、 C# や .NET のバージョンは任意です。

コンソールアプリケーションプロジェクトの作成

クラスライブラリを参照することでコアロジック側の機能を使えるようになります。

クラスライブラリを参照する

これを利用してバトルを組み立てます。
また、 Unity から分離するとき抽象化したインターフェイスも実装します。

これらのサンプルコードを以下に示します。

// ---- Program.cs ----
// プロジェクト共有によりコアロジック側に定義した Actor や BattleProcessor 、Repository を参照可能

// キャラクターのインスタンス作成
var ally = new Actor(id: 1, hp: 100, mp: 100, attack: 30, defence: 20, agility: 10, isMine: true); // true なら味方、 false なら敵
var enemy = new Actor(id: 2, hp: 80, mp: 50, attack: 20, defence: 10, agility: 5, isMine: false);
var actorRepository = new ActorRepository();
actorRepository.Set(ally.Id, ally);
actorRepository.Set(enemy.Id, enemy);

// スキルのインスタンス作成
var commonSkill = new Skill(id: 1, damageEffect: 30, requiredMp: 5, accuracyPermil: 1000);
var skillRepository = new SkillRepository();
skillRepository.Set(commonSkill.Id, commonSkill);

// インターフェイスの実装
var commandPlanner = new FixedCommandPlanner(ally, enemy, commonSkill);
var performanceProcessor = new DummyPerformanceProcessor();
var logger = new ConsoleLogger();

// バトルの構築
var battleProcessor = new BattleProcessor(
    actorRepository,
    skillRepository,
    commandPlanner,
    performanceProcessor,
    logger);

// バトルの実行
await battleProcessor.Run();
// コアロジック側に定義したインターフェイスを実装する

// 行動するキャラクターが味方なら敵に、敵なら味方に共通のスキルで攻撃
public class FixedCommandPlanner(Actor ally, Actor enemy, Skill commonSkill)
    : ICommandPlanner
{
    public UniTask<Command> Planning(Actor performer)
    {
        var performerId = performer.Id;
        var targetId = performerId == ally.Id ? enemy.Id : ally.Id;
        return UniTask.FromResult(new Command(performerId, commonSkill.Id, targetId));
    }
}

// 演出や GUI は描画しない
public class DummyPerformanceProcessor : IPerformanceProcessor
{
    public UniTask PlayBattleStart() => UniTask.CompletedTask;
    public UniTask PlaySkill(SkillResult skillResult) => UniTask.CompletedTask;
    public UniTask PlayBattleEnd() => UniTask.CompletedTask;
}

// UnityEngine.Debug.Log の代わりにコンソールに出力
public class ConsoleLogger : ILogger
{
    public void WriteLog(string message) => Console.WriteLine(message);
}

この例はとても簡素ですが、プレイヤー操作やGUIの描画をせず自動でバトルを動かせます。ここから ICommandPlanner を差し替えて、強さを評価したい AI に行動を決定させたり、ログの内容を操作に変換して行動させるアプリケーションも作成できます。

なお、サンプルコードではクラス図中の「データ定義・シリアライズ」の機能は未使用です。マスターデータを扱うときはこれらの機能が必要になり、コアロジックと同様に Unity から分離してコード共有する必要があります。

考察・まとめ

コアロジックを Unity から切り離し、他のアプリケーションで利用できるようになりました。
Unity を使わないアプリケーションでは、 C# や .NET のバージョンを Unity に合わせずに済みます。

一方で、この方法は分割に見合うメリットがあるかの検討が必要です。

Unity から切り離した部分は Unity の機能や、 Unity に依存するライブラリも使えなくなります。

また、分割したあとはアーキテクチャの保守コストが発生します。
追加する機能はどのプロジェクトに含めるか考えたり、開発メンバーが新規参加したときにアーキテクチャを理解する必要があります。

プロジェクトの開発体制によっては、分割しない方が開発スピードが出るかもしれません。

本記事が Unity を使った機能設計の参考になれば幸いです。

参考資料・リンク

サンプルゲームのキャラクター画像には以下を使用しました。
Unity-Chan! コーゲンシティ・オールスターズ キャラクター立ち絵パック Vol.2
Unity-Chan! コーゲンシティ・オールスターズ キャラクター立ち絵パック Vol.3

© Unity Technologies Japan/UCL