Cities: Skylines II MOD 開発用メモ
README.md
ページ内リンク
最初に
リバースエンジニアリング / コードリーディング
実装方法
CSL2 の MOD 開発ができるようになったのでチャレンジ。
現時点では日本語情報が存在しないので、色々書いておく(とはいえ英語でも解説はあんまりないので、最終的には自分で調査できないと MOD 開発は厳しそう)。
環境構築
ゲーム内の設定からインストールできる。Unity とか落としてくるので結構時間がかかる。待てば終わる
プロジェクト作成
Visual Studio 2022 か Rider
VS2022 で進める。
ウィザードに沿って進める。ここに入力した値はプロジェクトファイル内に書き込まれるため、絶対パス等は書かずに環境変数を使う(とくに認証情報)。
プロジェクトファイルに書き込まれるだけなので、すべての項目は一応あとから変更できる。
Installion Path みたいなものの入力欄があるが、原則として空にしておく。これは上書き用の設定であり、必要なパスの類はツールチェイン構築時に環境変数で設定されている。
pdx account data はお好みで。
description 系はどうせ後で編集すると思うので適等に。
設定を作るかのチェックボックスがあり、有効にすると設定画面用のコードが作られる(CS2 ではゲーム内の設定画面から MOD の設定ができる)。消すのは簡単だが作るのは面倒なので、有効にするのを推奨。
作れているかチェック
とりあえずビルドが通るか確認する。作成時にミスをしていなければとりあえずエラーは出ない。エラーが出るなら作成時に何かを間違えている。
作られるもの
最低限の Mod.cs が作られる。機能は別ファイルで作って、このファイルはMODのセットアップコードを書くことになる(System 登録とか)。
公式情報
最初は本当に情報がなかったが、時間が経つにつれ情報が増えつつはある。充実とは程遠いが、定期的にチェックするのを推奨。
コミュニティ
GitHub
コードを公開しているMODの場合、Paradox Mods や Skyve から GitHub のリポジトリにジャンプできる
Discord
Cities: Skylines
公式 Discrord。MODの話題もある
Cities: Skylines Modding
FindIt、Anarchy、529Tiles 等の作者がいる。
Cities: Skylines Modding Discord
Extra Networks and Areas や Extended Road Upgrades 等の作者がいる
コードMOD
リバースエンジニアリング(Game)
VisualStudio からでも必要になったときに逆コンパイルはできるが、全部まとめて逆コンパイルしておいて、全体を見られる状態に保存しておく方が便利。
Cities Skylines II\Cities2_Data\Managed
に使用される dll があるので、dotPeek か ILSpy 辺りのお好みの逆コンパイラで復元して保存する。VisualStudio に統合されているのは ILSpy。
Getting Started
チュートリアルに沿って環境構築し、ビルドが通るところまでを確認する。
MOD実装方法
ECS への心構え
Cities: Skylines II の MOD を作る場合、特に深い機能を作ろうとすると ECS の理解が必須になってくる。
解説等はあとに書くが、いずれ必要になるのは覚えておくと良い
設定について
プロジェクトを作るときに有効にしていると、自動的に 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 log
と Settings 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.cs
に LocaleEN
が作成される。ごく簡易的なローカライズであれば、これに従って 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.json
や ja-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>
ECS
SystemAPI
最も基本的な操作方法が SystemAPI.Query を foreach するもの。
ここで記述するコードはコンパイル段階でコードジェネレーターによる変換が入る。それによってクエリの構築などが生成される。
コードジェネレーターによるコード生成は多くの場所で使われている。デコンパイルすると出てくる __TypeHandle なども生成されたものなので、自分で書く必要はない(ただし手動で書いている Mod は多く、手動で書いてももちろん動く)。
IJobChunk
これもデコンパイルすると見つかるが、おそらく IJobEntity からコンパイラが生成したコードであるため、手動で書く必要はない。
IJobEntity
コンパイル時に IJobChunk になる。SystemAPI と foreach だけで書きづらい複雑なものの場合はこれを使うことになる
UI
公式ドキュメントが存在するので、まずはそれを読む。
プロジェクト作成
npx create-csii-ui-mod
を実行するだけ。
npm run build
または npm run watch
でビルドできる。watch
の場合は変更を検知すると再ビルドされる。
ビルドされたファイルは、自動的にローカルの MOD フォルダに配置され、ゲームにロードされるようになる。初回はゲームの起動が重くなるが気にしなくてよい。
コードMOD+UI
UIだけの MOD も作成できるが、たいていは C# コードと両方書くことになるはず。
やりかたはここに書いてある通り。ただ、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
誰かが作ったイベントのリスト
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 を使って調査できる。
UIバインディング
UI だけあっても意味がない。ということでここからはバインディングについて。
使用できるのは単方向バインディング。C# から UI の方向には Binding による値の送信、UI からは trigger
や call
で RPC をする。
UISystem を作る
UI との連携には UISystemBase
のサブクラスを使用する必要がある。UISystemBase
は Game.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 側に渡したい場合、AddUpdateBinding
と GetterValueBinding
を使う。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 側からは trigger
と call
で呼びだす。最初の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
を使用すること。関数のものは trigger
や call
のように使える。
名前空間で区切られているため、比較的探しやすい。
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 もある。