Open9

Cities: Skylines II MOD 開発用メモ

zakurozakuro

CSL2 の MOD 開発ができるようになったのでチャレンジ。

現時点では日本語情報が存在しないので、色々書いておく(とはいえ英語でも解説はあんまりないので、最終的には自分で調査できないと MOD 開発は厳しそう)。

zakurozakuro

環境構築

ゲーム内の設定からインストールできる。Unity とか落としてくるので結構時間がかかる。待てば終わる

プロジェクト作成

Visual Studio 2022 か Rider

VS2022 で進める。

ウィザードに沿って進める。ここに入力した値はプロジェクトファイル内に書き込まれるため、絶対パス等は書かずに環境変数を使う(とくに認証情報)。

プロジェクトファイルに書き込まれるだけなので、すべての項目は一応あとから変更できる。

Installion Path みたいなものの入力欄があるが、原則として空にしておく。これは上書き用の設定であり、必要なパスの類はツールチェイン構築時に環境変数で設定されている。

pdx account data はお好みで。

description 系はどうせ後で編集すると思うので適等に。

設定を作るかのチェックボックスがあり、有効にすると設定画面用のコードが作られる(CS2 ではゲーム内の設定画面から MOD の設定ができる)。消すのは簡単だが作るのは面倒なので、有効にするのを推奨。

作れているかチェック

とりあえずビルドが通るか確認する。作成時にミスをしていなければとりあえずエラーは出ない。エラーが出るなら作成時に何かを間違えている。

作られるもの

最低限の Mod.cs が作られる。機能は別ファイルで作って、このファイルはMODのセットアップコードを書くことになる(System 登録とか)。

zakurozakuro

コミュニティ

GitHub

コードを公開しているMODの場合、Paradox Mods や Skyve から GitHub のリポジトリにジャンプできる

Discord

Cities: Skylines

公式 Discrord。MODの話題もある

https://discord.com/invite/citiesskylines

Cities: Skylines Modding

FindIt、Anarchy、529Tiles 等の作者がいる。

http://discord.gg/479jjntdKq

Cities: Skylines Modding Discord

Extra Networks and Areas や Extended Road Upgrades 等の作者がいる

https://discord.gg/27CVdGFA47

zakurozakuro

コードMOD

リバースエンジニアリング(Game)

VisualStudio からでも必要になったときに逆コンパイルはできるが、全部まとめて逆コンパイルしておいて、全体を見られる状態に保存しておく方が便利。

Cities Skylines II\Cities2_Data\Managed に使用される dll があるので、dotPeek か ILSpy 辺りのお好みの逆コンパイラで復元して保存する。VisualStudio に統合されているのは ILSpy。

Getting Started

チュートリアルに沿って環境構築し、ビルドが通るところまでを確認する。

MOD実装方法

ECS を学ぶ

Cities: Skylines II の MOD を作る場合、特に深い機能を作ろうとすると ECS の理解が必須になってくる。

とはいえ一旦は飛ばしてもよい。理解してから作ろうとすると永遠に作り始められない。分からなくなってから勉強しよう。

役に立った解説を貼っておく。

https://www.f-sp.com/entry/2019/04/18/175747#アーキタイプとチャンク

https://www.f-sp.com/entry/2019/04/19/175923#ジョブとEntityManager

設定について

プロジェクトを作るときに有効にしていると、自動的に Setting クラスが作成されている。

実際にゲームを起動してみると、自作 MOD 用の設定がゲーム内の設定画面に追加されているのがわかる。

この設定は、ゲームの設定と同じ仕組みで扱われる。特に複雑なことをしなくても自動的に永続化してくれたりしてとても便利。

また、使い方についても Setting.cs 内にサンプルが生成されており、それを読めばだいたいわかるはず。

セットアップ

まずは Mod.cs でセットアップを行う。

ここで System 等を登録することで、実装した機能が呼ばれるようになる。

まずは初期実装を見ていく。

  public class Mod : IMod
  {
      public static ILog log = LogManager.GetLogger($"{nameof(MyMod)}.{nameof(Mod)}").SetShowsErrorsInUI(false);
      private Setting m_Setting;

      public void OnLoad(UpdateSystem updateSystem)
      {
          log.Info(nameof(OnLoad));

          if (GameManager.instance.modManager.TryGetExecutableAsset(this, out var asset))
              log.Info($"Current mod asset at {asset.path}");

          m_Setting = new Setting(this);
          m_Setting.RegisterInOptionsUI();
          GameManager.instance.localizationManager.AddSource("en-US", new LocaleEN(m_Setting));

          AssetDatabase.global.LoadSettings(nameof(MyMod), m_Setting, new Setting(this));
      }
      public void OnDispose()
      {
          log.Info(nameof(OnDispose));
          if (m_Setting != null)
          {
              m_Setting.UnregisterInOptionsUI();
              m_Setting = null;
          }
      }
  }

IMod を継承しているとかは多分そのままの意味なので、そのままにしておく。

既存のコードを見る限り、クラス名は変えても変えなくてもいいっぽい(両方ある)のでお好みで。

フィールドとして ILog logSettings m_settings を持っている。log はロギングに、m_settings は設定に使う。設定については後述。

OnDispose() も特に書くことはない。リソース破棄をする。自前で確保していて手動で破棄するものがある場合はここに書く。

OnLoad(UpdateSystem updateSystem)

一番大事な部分。Mod のロード時に呼ばれる。

引数として UpdateSystem を受け取る。これは CS2 側で用意されたもので、ゲームの Update に関する機能を担う System。ECS の S。

これを使って、実装したシステムを登録したり色々する。

以下に再掲する

      public void OnLoad(UpdateSystem updateSystem)
      {
          log.Info(nameof(OnLoad));

          if (GameManager.instance.modManager.TryGetExecutableAsset(this, out var asset))
              log.Info($"Current mod asset at {asset.path}");

          m_Setting = new Setting(this);
          m_Setting.RegisterInOptionsUI();
          GameManager.instance.localizationManager.AddSource("en-US", new LocaleEN(m_Setting));

          AssetDatabase.global.LoadSettings(nameof(MyMod), m_Setting, new Setting(this));

          // ここにコードを書く
      }

ここから必要なコードを末尾に追記する。既存のコードは読めばなんとなくわかる。

機能を実装する (System を作る)

MOD の機能は GameSystemBase を継承した System として実装する。GameSystemBase からは様々な機能が提供される。

作成する System は一つである必要はない。機能ごとに分割して実装するのを推奨。

class MyModSystem : GameSystemBase {

}

UpdateAt UpdateBefore UpdatAfter で、指定した型のSystemを登録する。引数は何が更新されたら呼び出されるかをSystemUpdatePhase で、指定する。

(ところで登録するメソッドにこの名前はどうかと思う)

UpdateBefore``UpdatAfter では第二の型引数で依存対象を指定できる。指定しない場合、普通に登録するもの2比べて先/後に更新される。ただし最初/最後とは限らない。

UpdateAt<SystemType>(SystemUpdatePhase phase)
UpdateBefore<SystemType>(SystemUpdatePhase phase)
UpdateBefore<SystemType, OtherType>(SystemUpdatePhase phase)
UpdateAfter<SystemType>(SystemUpdatePhase phase)
UpdateAfter<SystemType, OtherType>(SystemUpdatePhase phase)

ゲームの機能を使う (既存Systemを取得する)

ゲームのほぼすべての機能は、System として実装されているため、これを取得することで、ゲームの機能を呼び出したり、ゲーム内の値を取得したりできる。

システムの取得は World.GetOrCreateSystemManaged で行う。GetOrCreate と書いてあるように、存在しない場合作成されることになるが、あまり気にしなくていい。システムは事実上のシングルトンなので、ゲーム全体で同一のものになる。

なお、GetOrCreateSystem というのもあるが、これではなく Managed の方。間違えやすいので注意。

典型的には、OnCreate で取得してメンバとして持っておく。

void OnCreate()
{
    _simulationSystem = World.GetOrCreateSystemManaged<SimulationSystem>();
}

既存の System の UpdatePhase

UpdateSystem に登録する際には UpdatePhase と UpdateAfter 等によって順序関係を指定できるが、そのためには依存したい System の UpdatePhase が分かっている必要がある

既存の System の登録は Game.Common.SystemOrder に記述されており、どの UpdatePhase が使われているかを確認できる。

Localization

ゲーム内で他言語対応の仕組みが整備されているので、それに沿った実装をすることで、良い感じの他言語対応ができる。

テンプレートからプロジェクトを作成すると、Settings.csLocaleEN が作成される。ごく簡易的なローカライズであれば、これに従って LocaleJP とかを作れば他言語対応は可能ではある。

ただし、この方法はあまりお勧めしない。C# のコードとして書いているので、翻訳協力なども求めにくい。

Localization の登録をどうやればいいかのサンプルコードとしての価値はある。

Fallback

現在の言語のメッセージが提供されていない場合は en-US にフォールバックされる。en-US にも存在しない場合はキーがそのまま表示される(キーを調べるのにも使える)

Locale の提供と登録

登録には Source を登録することになる。AssetDatabase を使用して Asset として登録する方式もあるが、無視してよい。

登録は localizationManager.AddSource を使って以下のように行う。

GameManager.incetance.localizationManager.AddSource("en-US", localeSource)

第一引数の "en-US" はそのままロケールの名前。日本語なら "ja-JP"。他の言語は適当に調べてもらえれば。

第二引数はやや分かりにくく、Colossal.IDictionarySource を渡さなければならない。これは以下のように定義されている。

public interface IDictionarySource
{
    IEnumerable<KeyValuePair<string, string>> ReadEntries(IList<IDictionaryEntryError> errors, Dictionary<string, int> indexCounts);

    void Unload();
}

ReadEntries で、IEnumerable<KeyValuePair<string, string>> を返す必要があるが、これは Dictionary<string, string> が実装しているため、実際には Dictionary を返すだけでよい。

引数が2つ渡されるが、用途は良くわからない。生成される LocaleEN では使用していないし、既存の MOD を見ても使っていなかったりするので、とりあえず無視しても大きな問題はなさそう。

ここで返す Dictionary は、Key はメッセージのキーで、Value がメッセージの中身。具体的なキーに何を指定すればいいかはゲームコードを呼んでもいいし、メッセージが完全に見つからなかった場合にはゲーム内でキーが表示される。

例えば設定画面の場合、Options.SECTION[MyMod.MyMod.Mod] のような形式になる。

これらを踏まえると、単純な辞書のラッパーとしての DictionarySource は以下のように実装できる。

public class LocaleDictionarySource : IDictionarySource
{
    private readonly Dictionary<string, string> _dictionary;

    public LocaleDictionarySource(string localeId, Dictionary<string, string> dictionary)
    {
        LocaleId = localeId;
        _dictionary = dictionary;
    }

    public string LocaleId { get; }

    public IEnumerable<KeyValuePair<string, string>> ReadEntries(IList<IDictionaryEntryError> errors, Dictionary<string, int> indexCounts)
    {
        return _dictionary;
    }

    public void Unload() { }
}

JSON ベースでの Localization の実装

現実の実装で比較的良く見るのは、JSON ベースでの方式である。

Locales のようなフォルダに、en-US.jsonja-JP.json といった言語ファイルを配置する。この方法は、外部の翻訳プラットフォームなどとも相性が良い。

これを Cities Skylines II で実装する方法を書いていく。この方法は FindIt の実装を参考にしており、コンパイル時に EmbeddedResource として埋め込む方法を採用しているが、独立したファイルにする方法等も考えられる。

まず Locales のようなフォルダを作り、そこにある .json を埋め込む設定をプロジェクトファイルに追加する。

<ItemGroup>
    <EmbeddedResource Include="Locales\*.json" />
</ItemGroup>

これでアセンブリにファイルが埋め込まれるようになるので、あとはそれをロードして localizationManager に登録する。

アセンブリからリソースをロードするには、まずアセンブリを取得する必要がある。いくつかやり方があるが、クラスを指定して取得すると確実。

var assembly = Assembly.GetAssembly(typeof(MyMod.Mod));

次に、Locales 以下のリソース名を取得する。

assembly.GetManifestResourceNames().Where((name) => name.Contains("Locales") && name.EndsWith(".json"));assembly.GetManifestResourceNames().Where((name) => name.Contains("Locales") && name.EndsWith(".json"));

名前がわかると、Stream が作成できるので、内容を読み込んで、JSON でパースする。名前は単純なファイル名ではなく、アセンブリ名などが含まれるため、拡張子を除去したうえで最後の . 以降を取り出す必要がある。

var resourceNames = assembly.GetManifestResourceNames().Where((name) => name.Contains("Locales") && name.EndsWith(".json"));

locales = new Dictionary<string, LocaleDictionarySource>();
foreach (var resourceName in resourceNames)
{
    var localeName = Path.GetFileNameWithoutExtension(resourceName);
    localeName = localeName.Substring(localeName.LastIndexOf('.') + 1);

    using var resourceStream = _assembly.GetManifestResourceStream(localeResourceName) ?? throw new LocaleNotFoundException(localeResourceName);
    using var reader = new StreamReader(resourceStream, Encoding.UTF8);
    JSON.MakeInto<Dictionary<string, string>>(JSON.Load(reader.ReadToEnd()), out var dictionary);
    locales.Add(localeName, new LocaleDictionarySource(localeName, dictionary));
}

あとはこれを登録する

 foreach (var locale in locales)
 {
     GameManager.instance.localizationManager.AddSource(locale.Key, locale.Value);
 }

Tips

C# のバージョンを上げる

テンプレートから生成した .csproj では、C# のバージョンが指定されておらず、かつ Target が net472 なので、自動的に古い C# が選択されます(C#7.3)。

実際にはもっと新しいバージョンを使用できます。多くの MOD で 9 を使用しており、MoveIt では 10 にしていました(ただし MoveIt は Target を net482 に指定しています)。

設定する場合、.csproj の最初のあたりに <LangVersion>9</LangVersion> を追加するだけです。

<Project Sdk="Microsoft.NET.Sdk">
	<PropertyGroup>
		<OutputType>Library</OutputType>
		<TargetFramework>net472</TargetFramework>
		<LangVersion>9</LangVersion>
		<Configurations>Debug;Release</Configurations>
zakurozakuro

UI

公式ドキュメントが存在するので、まずはそれを読む。
https://cs2.paradoxwikis.com/UI_Modding

プロジェクト作成

npx create-csii-ui-mod を実行するだけ。

npm run build または npm run watch でビルドできる。watch の場合は変更を検知すると再ビルドされる。

ビルドされたファイルは、自動的にローカルの MOD フォルダに配置され、ゲームにロードされるようになる。初回はゲームの起動が重くなるが気にしなくてよい。

コードMOD+UI

UIだけの MOD も作成できるが、たいていは C# コードと両方書くことになるはず。

https://cs2.paradoxwikis.com/Creating_UI_And_Code_Mods

やりかたはここに書いてある通り。ただ、MyUIMod と書いてある部分にだけ注意。既存の MOD では単に UI としている場合が多い。

UI Developer Mode

起動オプションに -uiDeveloperMode を指定すると、http://localhost:9444 にアクセスできるようになり、ブラウザの devtools を使用できる。

ゲーム内での更新は当然リアルタイムで反映されるほか、通常の Web ページのように、devtools 側から値を変更することもできる。

リバースエンジニアリング(UI)

コードリーディングと devtools を組み合わせて使うと良い。

コードリーディング

JS

UI のコードはビルド済みファイルがみられるため、これを Prettier 等で整形して気合で読む。パスは以下。

Cities Skylines II\Cities2_Data\StreamingAssets\~UI~\GameUI\.index.js

モジュールのファイル名が .tsx を含む文字列になっているので、それを起点とすると読みやすい。

モジュールの登録コードはおおむね以下のようになっている。

oo.add("game-ui/foo/bar.tsx", {
    get Bar() {
        return Aa;
    },
    set Bar(e) {
        Aa = e;
    },
});

ここで登録しているオブジェクトのプロパティ名は、既存のUIを拡張するとき等に指定するのに使う。

CSS

CSS も同じ場所に index.css として存在する。JS と同様にフォーマットすると読める。既存の名前は残っているので比較的読みやすい。

要素の実装を調べる

実装を調べる場合、コードを読むのに合わせて devtools を使うと便利。

既存の要素の構造を真似すると、ゲームになじむ UI を作りやすい。

コンポーネント名や props は class の名前になっていたりするため、どのコンポーネントをどう使えば良いかがなんとなく読み取れる。

GameUI Event List

誰かが作ったイベントのリスト

https://pastebin.com/m8ZTWqgk

Livereload

--uiDeveloperMode を有効にしてゲームを起動後し、npm run dev を実行すると、変更検知で自動的にビルドされ、起動中のゲーム画面にも反映される。

これにより、ゲームを再起動することなく UI 開発を行える。便利。

UI実装方法

要素の登録

moduleRegistry から、append extend override で登録する。

append はメニュー等の子要素への追加。extend は既存の要素の拡張。override は既存の要素の置換。

型定義には Menu など大きな分類しかないが、既存のUIのモジュールも使用できるため、より細かい位置に配置できる。

具体的にどんなモジュールがあるかは findModule で調べられる。あるいは、頑張ってコードを読む。

findModule(/game-ui/) を gist に置いておく。とても長いので注意。

findModule(/game-ui/)

特定の位置に配置

配置したい要素を extend することで実現できる。元のコンポーネントを受け取るので、その前 or 後に追加したい要素を書く。

コンポーネントのファイルパスやクラス名は、前述のとおり頑張って調べる。

const TimeControlsExtend: ModuleRegistryExtend = (Component) => {
    return (props) => {
        const { children, ...otherProps } = props || {};

        return (
            <>
                <div>Pre existing component</div>
                <Component {...otherProps}>
                    {children}
                </Component>
                <div>Post existing component</div>
            </>
        );
    };
}

moduleRegistry.extend('game-ui/game/components/toolbar/bottom/time-controls/time-controls.tsx', 'TimeControls', TimeControlsExtend);

既存のコンポーネントを使う(基本的なコンポーネント)

Button 等の基本的な要素は cs2/ui.dts で提供されるので、それを使用できる。

既存のコンポーネントを使う(その他のコンポーネント)

getModule でコンポーネントを取得して、JSX で使用できる。

例えば Toolbar のフィールド使う場合は以下の通り。

const Field: (props: any) => JSX.Element = getModule("game-ui/game/components/toolbar/components/field/field.tsx", 'Field')
return (
    <Field>
        <div>Hello Field</div>
    </Field>
)

型は ui.dts を参考に (props: any) => JSX.Element としている。props の型が分かるならば指定すると使いやすいかも。

props に何が使えるかは、実際のコードを頑張って読むしかない。

CSS 変数を使用して UI を構築する

Custom Properties(CSS 変数)が活用されている。画面サイズ等に合わせて自動的に調節してくれるほか、UI の設定(テキストスケールやテーマ)などが反映されるため、MOD でも積極的に活用するのを推奨。

提供される値は CSS の最初にまとまっているのでわかりやすい。また、:root で定義されているので devtools でも簡単に見つけられる。

既存の要素でどう使用されているかも、devtools を使って調査できる。

zakurozakuro

UIバインディング

UI だけあっても意味がない。ということでここからはバインディングについて。

使用できるのは単方向バインディング。C# から UI の方向には Binding による値の送信、UI からは triggercall で RPC をする。

UISystem を作る

UI との連携には UISystemBase のサブクラスを使用する必要がある。UISystemBaseGame.UI にある

登録は他の System と同じで、phase には UIUpdate を使う。

public class UISystem: UISystemBase {
        protected override void OnCreate()
        {
            base.OnCreate();
            // Binding 等のセットアップはここに書く
        }
        protected override void OnUpdate()
        {
            base.OnUpdate();
        }
}

updateSystem.UpdateAt<UISystem>(SystemUpdatePhase.UIUpdate);

バインディングキー

Binding をする際のキーには、グループ名と名前を指定する。

グループ名は名前空間、名前は変数名のようなもの。基本的には任意の文字列が指定できる。

グループ名が他の MOD と衝突する可能性に注意。既存の MOD では MOD の名前にしていることが多い。衝突の可能性をより減らすなら、UUID 等を使うのもありかも。

Getter を使って値を UI に渡す

単純に C# の変数を UI 側に渡したい場合、AddUpdateBindingGetterValueBinding を使う。Getter を関数で渡すと、Update で呼びだされ、戻り値が値として使われる。

他のシステムを参照して値を取得したりもできる。例えばシミュレーション速度を渡したいなら以下のようにする。

AddUpdateBinding(new GetterValueBinding<float>('mymod', 'speed', () => _simulationSystem.selectedSpeed));

JS 側で値を取得して使用する

JS 側で値を使用するには、bindValue でバインディングを作成し、useValue でレンダリングするためのリアクティブな値を取得する。値の変更は自動的に反映される。

const text$ = bindValue<string>('mymod', 'text')
// 第3引数に fallbackValue も指定できる

export const HelloBinding = () => {
    const text = useValue(text$)
    return (<div>{text}</div>)
}

UI にイベントを通知する

C# から UI に通知を送るには EventBinding を使う。

ValueBinding では前回と同じ値で Update を呼び出すと UI への通知が必要ないと判断するため、イベント通知を送るには適さない。

EventBinding では、Trigerで通知を行う。

private EventBinding<bool> _eventBinding;
protected override void OnCreate() {
    AddBinding(_eventBinding = new EventBinding<uint>("mymod", "event"));
}

void triggerEvent() {
    _eventBinding.Trigger(true)
}

UI 側では bindValue で作ったバインディングの subscribe を使う

const event$ = bindValue<bool>('mymod', 'event')
event$.subscribe((v) => {
    console.log('event', v);
}

UI からアクションを行う

C# における Action なら TriggerBinding を、Func なら CallBinding を使用する。

引数はどちらも5つまで使用できる。

UI 側からは triggercall で呼びだす。最初の2つの引数は group と name なので注意。第三引数からが実際に送る引数。call は C# 側の実行を待つため Promise を返す。

なお、引数の型は問答無用で any なので注意。型引数で指定したりもできない。心配な場合はラッパー関数を作ると良い。

AddBinding(new TriggerBinding<string>("mymod", "trigger", (text) => { log.Info("onTrigger:" + text ); }));
AddBinding(new CallBinding<int, int, int>("mymod", "add", (int a, int b) => a + b));
import { trigger, call } from 'cs2/api'

trigger('mymod', 'trigger', 'hello trigger')
const sum = await call<number>('mymod', 'add', 1, 2)

既存のバインディングを使う

ゲーム内には最初から複数の UISystem が実装されており、これらはバインディングを実装している。

それらは cs2/bindings.d.ts 定義されており、値を使用したり、アクションを起こしたりできる。$ で終わるものは Binding 自体なので、必要に応じて useValue を使用すること。関数のものは triggercall のように使える。

名前空間で区切られているため、比較的探しやすい。

zakurozakuro

Publish

自分用の MOD なら良いが、使ってもらうなら publish しないといけない。意外と面倒。

PublishConfiguration.xml

公開のために必要な情報を PublishConfiguration.xml に記述する。サムネイル等ファイルが必要なものもある。

メタデータ

ModID

更新する場合には必要。新規の場合は空でよい

DisplayName

表示される MOD の名前

ShortDescription

短い説明

LongDescription

MOD ページに表示される長い説明。Markdown で書ける。

Changelog

変更履歴。Markdown で書ける。前回からの差分のみを書くと、そのバージョンの変更履歴として表示される。過去のものは自動的に表示されるので、publishConfiguration.xml からは消しておくこと。

Tag

コードMOD なら <Tag Value="Code" /> と書いておけば問題ない。フィルタの部分で使用される。

画像

Thumbnail

一覧に表示されるサムネイル。大事。

正方形画像で最大1024x1024くらい。

作り方

MOD においてはサムネイルに関係なく便利なら使われるという側面はあるが、それでも第一印象は大事なので少しは工夫したいところ。

既に生成AIを用いたサムネイルのMODが多くあるが、正直やめたほうがいい。無題豪華なサムネイルが並んでいるが、豪華なだけだと埋もれる。

サムネイルだけで機能がイメージできるのが望ましい。客観的に見て自分なら使いたいと思うかを考えてみる。

フリー素材などを積極的に活用したり、いらすとやを使ってもいい。

公式で配っているプレスキットにロゴ等の素材があるため、必要に応じて使用できる。

Cities Skylines II\Cities2_Data\StreamingAssets\~UI~\GameUI\Media\Glyphs にはゲーム内で使用されている素材がある。ロゴの SVG もある。