前書き
当記事はtModLoader(1.4.x)でModを制作する際に意識するべきネットコードについての解説です。
とりあえずModPacket
とかを使用しない浅いところの解説の盛り合わせになっていると思います。わかるように言語化すんのめんどくさいし
ぼくちんはModが一通り完成してからマルチに対応していくぞ! っていうのは後々痛い目を見るので、早い段階でなんとなくでいいから理解しておくと未来の自分を苦しめずに済むかもしれない。経験者は語る。
また、当記事は以下のページをベースに作成されています。読んでおくといいんじゃないですかね。
Basic Netcode · tModLoader_tModLoader Wiki
あと当記事は各項目についてある程度の知識や経験があることを前提に話が進んでいきま~す。
概要
マルチ対応、マルチ対応って...そもそもおまえ、なんなんだよ!
...
マルチプレイの構造を知る
まずTerrairaのマルチプレイの構造について説明しよう!
マルチプレイはおおまかに、サーバーとクライアントに分類されるものから成る。
- サーバー
- ゲーム全体の管理、クライアント間のデータの仲介及び同期を行う。
- クライアント
- 参加者側が起動、実行しているアプリケーション。各クライアント間で直接データの送受信は出来ない。クライアントはサーバーから受け取った情報を基に、サーバーでの実際の様子を再現する。
で、前書きでちょろっと出てきたGitHubのwikiから抜粋して説明すると...
要するに、ネットワークパケット(情報) をクライアント間で送信して、すべてのプレイヤーのゲームが同期していることを確認するのです。これはマルチプレイヤーでは常に起こっていることで、バニラでも同じです。クライアントは他のクライアントと直接送受信することができないので、サーバーが通信の仲介役となる必要があります。
- Terrariaをプレイする時、あなたのアプリケーションは "クライアント" とされます。
- マルチプレイでは、あなたのクライアントと他のプレイヤーのクライアントは "サーバー" に接続します。
- サーバーに接続している間、接続されたクライアントはサーバーを介してうまく通信して、ゲーム内で起こっていることが全クライアント間で同期される必要があります。
目的
...で目的はというと、だいたいこんなん。
- クラッシュ防止
- 意図しない動作の抑制
- 各クライアントとサーバーの持つ情報に齟齬が生じるのを防ぐ
- サーバーに必要以上の負荷をかけない
これらの目的を果たすための留意点を当記事で説明しようという訳であーる。
実行サイドの確認
適切なコードを書くには、実行されているコードがサーバーサイドで動作しているのか、それともクライアントサイドで動作しているのか、はたまた両サイドで動作しているのかを知る必要がある。
これらはオーバーライドメソッドのsummary
やMain.netMode
を確認することで判別可能。
クライアントでのみ実行される(サーバーでは実行されない) などの実行環境に制限があるオーバーライドメソッドには大抵の場合、その旨の説明がなされている。特に何も説明が無い場合は両サイドで実行されているものが多い。
幸いマルチプレイ環境での動作は自分一人でもある程度確認できるので、不安になったらマルチプレイを開始して後述の方法で確認すること。
手間はかかるが逆コンパイルしたtModLoaderのコードを確認するという方法もある。
Main.netMode
Main.netMode
は実行されているサイドのIDを保持しているint
型のフィールド。で、NetModeID
には各サイドのIDが定数フィールドとして定義されている。こいつらを照合すれば一発である。はい解散。
大抵のネットコードはこいつを使って条件分岐を行ったうえ書いていくことになる。
以下は実行されている場所に応じてNewText
でゲーム内チャットに文字列を出力する例である。このような方法で動作している場所の把握などを行うことが出来る。
尚、サーバーはそもそもNewText
の出力文字を確認するための画面となるインターフェースが無いので、コンソールに書き込む形をとるものとする。
using System;
using Terraria;
using Terraria.ID;
...
if (Main.netMode == NetmodeID.SinglePlayer)// 0
{
//シングルプレイで動作している場合はゲーム内チャットに文字列を出力
Main.NewText("Running on SinglePlayer.");
//クライアント側のコンソールが起動されているならこちらも使用可能
//Console.WriteLine("Running on SinglePlayer.");
}
if (Main.netMode == NetmodeID.MultiplayerClient)// 1
{
//マルチプレイヤーのクライアントで動作している場合はゲーム内チャットに文字列を出力
Main.NewText("Running on MultiplayerClient.");
//クライアント側のコンソールが起動されているならこちらも使用可能
//Console.WriteLine("Running on MultiplayerClient.");
}
if (Main.netMode == NetmodeID.Server)// 2
{
//サーバーで動作している場合は鯖のコンソールに文字列を出力
Console.WriteLine("Running on server.");
}
//NetmodeIDわかってるなら別にこれだけでもよくね?
//Console.WriteLine(Main.netMode);
条件分岐の括弧外のコメントアウトされている数字がその時のnetMode
の値である。
そしてこれはtmodでのデバッグ全般の話になるが、ロード中やワールド生成中などNewText
の出力を確認することが出来ない場合には、コンソールに出力させることで確認することができる。確認したいものや状況に応じて使いやすい方を使うと良い。
同期処理
ようやく本題。当然、制作物の対象や目的に応じて対処は異なる。
Projectile
netUpdate
Projectile.netUpdate
は発射体の場所や速度等のステータスを同期するかどうかを設定するbool
型のフィールド。同期したい際に同期元のそれにtrue
を代入しておくと、アップデート終了後にNetMessage
を送信して同期処理を行ってくれる。
また、これはフレーム毎にfalse
にリセットされる。
負荷や不安定な動作を避けるために、必要な時にだけ同期させる。
使いどころは変更の重要性によって変わってくるが、主な例としてはai[]
配列への代入によって動作が大きく変わる場合や、発射体のダメージを変更した場合、位置や速度を直接変更した場合とかが使いどころになってくる。
また、この処理では全情報が同期されるわけではない点に留意するよう。
例えば、ai[]
配列は同期されるが、localAI[]
配列は同期対象外である。(一応同期する方法はあるがまたそのうち)
同期される情報の一覧
フィールド名1 | 備考 |
---|---|
identity | |
position | |
velocity | |
owner | 所有者(プレイヤー) のインデックス(whoAmI) |
type | |
damage | |
knockBack | |
originalDamage | |
ai[] | |
bannerIdToRespondTo | |
projUUID |
ProjectileID.Sets.NeedsUUID[type] がtrue の場合 |
プレイヤーの操作に追従する処理を行う場合
マジックミサイルのような、プレイヤーのマウスに追従するような処理を行う場合は注意が必要。
マウスの位置はクライアント毎に異なり同期されない。
そのため、プレイヤーのマウスに追従する処理を所有者のクライアント以外の場所でも実行してしまうと、他人の発射体が自分のマウスに追従してきたり、突然どっかいったり消えたりする。
故に所有者側でのみ実行し、その処理を必要に応じて同期する必要がある。
マジックミサイルを例にとると、速度の変更処理は発射体の所有者側でのみ行われている。また、速度が十分に変更されたと判定される場合にだけnetUpdate
にtrue
を代入し、同期している。
netUpdate
を常にtrue
にするのは負荷につながるうえ、逆に挙動が狂うのでしてはいけない(戒め)
1.3時代のExample ModのMagicMissile.cs
にはこれについての解説コメントがついているので見てみるといいかもしれない。
Projectile から NewProjectile を使うとき
Projectile
からNewProjectile
を使用して発射体を生成するときには、発射体の所有者のクライアント側で処理を行う。
これは発生元の発射体がプレイヤーのものだろうがNPCのものだろうが、発射体から発射体を発生させる場合は必ず以下の例のようなサイドチェックを必要とする。
if (Projectile.owner == Main.myPlayer)
Projectile.NewProjectile(entitySource, position, velocity, type, damage, kb, Projectile.owner);
その他の場所から NewProjectile を使うとき
owner
の値を使用できない場合はnetMode
の確認を行い、サーバーまたはシングルでのみ実行する。
if (Main.netMode != NetmodeID.MultiplayerClient)
Projectile.NewProjectile(entitySource, position, velocity, type, damage, kb, Main.myPlayer);
NPC / ModNPC
netUpdate
NPC.netUpdate
。概要はこの項目と同じ。
持ち得るフィールドが違うので同期される情報も変わってくる。
尚、この同期処理はサーバーからクライアントへの一方通行である。クライアントから送信した情報はサーバーサイドでは受け取り処理が実行されない。1
同期される情報の一覧
フィールド名1 | 備考 |
---|---|
position | |
velocity | |
target | 標的となるプレイヤーのインデックス(whoAmI) |
direction | |
directionY | |
spriteDirection | |
SpawnedFromStatue | 石像産 OR NOT 石像産 |
ai[] | |
netID |
type とほぼ同義。 |
NPC.plantBoss | ワールドに存在するPlanteraのインデックス。同期元がPlanteraの場合のみ。 |
NPC.golemBoss | ワールドに存在するGolemのインデックス。同期元がGolemの場合のみ。 |
NPC.deerclopsBoss | ワールドに存在するDeerclopsのインデックス。同期元がDeerclopsの場合のみ。 |
releaseOwner | アイテムを使用して発生させたプレイヤーのインデックス。ウサギや蝶など網で捕まえられるNPC用。 |
NPC
はowner
を持ち得ないので、それによる条件分岐を使用することはできない。そのためnetMode
で同期用の条件分岐を行う。
同期の基本的なタイミングは、大きく速度が変わる場合(突進をさせる場合) やテレポートする時や、特定のai[]
に定数を代入する時、他には特定のai[]
の値によっておおまかな行動パターンや状況が変化するとき。
ai[]
の数値を増加させ続ける程度なら必要ない。一定値以上になった場合に特定のai[]
の値をリセットするなどの動作が加わる場合はその際に同期できるように記述する必要がある。
とはいえ結局、発射体と同様にai[]
の数値によるnetUpdate
の使用は、実際の処理に応じて必要性が変わってくる。
時間経過用カウンターのインクリメントや、発射体発生用のカウンターをランダムで増加させる場合には不要。
ランダムな値を使用して位置や速度を制御する場合など、すべてのクライアントが同じ処理を行うと断定できない場合が必要になるケースの一例ではあるが...
以下の例ではサイドチェックを行ったうえで、0~3のランダムな値をai[0]
に代入して同期を行っている。
予め決まった定数を代入するだけの場合のサイドチェックはぶっちゃけなくてもいい。...ないほうがいいかもしれない。
例のようにランダムな値を使用する時はあった方が良い。
if (Main.netMode != NetmodeID.MultiplayerClient)// シングルまたはサーバー
{
NPC.ai[0] = Main.rand.Next(4);// のような変更
NPC.netUpdate = true;// 同期スイッチオン シングルでオンにしても問題ない
}
複雑な例
1.「毎フレーム増加するai[1]
が一定値以上に達した場合に、サウンドを再生して、プレイヤーの方向に突進しつつ発射体を発生させ、ai[1]
をリセットしたい」場合
if (++NPC.ai[1] >= 600)
{
SoundEngine.PlaySound(...);// 効果音処理
...// 突進処理
if (Main.NetMode != NetModeID.MultiplayerClient)// 発射体発生
{
Projectile.NewProjectile(...);
}
NPC.ai[1] = 0;
NPC. netUpdate = true;
}
2.「毎フレーム1~4ランダムで増加するai[2]
が一定値以上に達した場合に、サウンドを再生して、プレイヤーの方向に突進しつつ発射体を発生させ、ai[2]
をリセットしたい」場合
この例はサーバー依存の条件を基に、クライアント側で聴覚効果を発生させなければならない。
そのため、文字通りの順番に処理を行うことは難しい。
そこで、ai[1]
に、0の場合は「ai[2]
の増加フェイズ」、1の場合「突進やサウンドの再生を行うフェイズ」として現在の行動を管理してもらう。
連続するフレームにnetUpdate
が入っているのでこれで上手くいくかは正直不安。理論上はこれでいいはず...
そもそもこんな条件で作らんから知らんわ
if (NPC.ai[1] == 0f)
{
NPC.ai[2] += Main.rand.Next(1, 5);
// ai[2]はランダムで増加し続けているため、値が一定でない。シングルまたはサーバー上でai[2]に基づく動作を行う
if (NPC.ai[2] >= 600 && Main.NetMode != NetModeID.MultiplayerClient)
{
// 発射体は(ai[1] == 1f)のネストもしくはここ、どちらで実行してもいい。誤差だよ誤差。大差ねぇよ。
NPC.ai[1] = 1;
NPC.ai[2] = 0;
NPC.netUpdate = true;
}
}
else if (NPC.ai[1] == 1f)
{
// elseなしだと、マルチプレイでは同フレーム内でサーバーがここの処理を行うため、クライアントでは音声が流れない。
// そのため、変更を同期したうえで次のフレームに実行させる。
SoundEngine.PlaySound(...);// 効果音処理
...// 突進処理
if (Main.NetMode != NetModeID.MultiplayerClient)// 発射体発生
{
Projectile.NewProjectile(...);
}
NPC.ai[1] = 0f;
NPC.netUpdate = true;
}
NewNPC を使うとき
ボスから取り巻きを召喚したり、アイテム経由などで定位置にNPCをスポーンさせる際にはサイドチェック。
マルチプレイヤーのクライアント側では実行しない。
というか実行してもサーバーでは受け取られないので不具合につながるだけ。
もしクライアント側でしか実行されない場所からNPCを発生させたい場合は、AIにその処理を行うよう記述した発射体を発生させるなどの代替手段を考える必要がある。
こういった工夫もmod制作のコツの一つである。うふふ。
if (Main.netMode != NetmodeID.MultiplayerClient)
NPC.NewNPC(entitySource, x, y, type);
NPC から NewProjectile を使うとき
NPCからNewProjectile
を使用してProjectileを生成するときにはこの項目で述べた方法と同じ方法を用いる。
if (Main.netMode != NetmodeID.MultiplayerClient)
Projectile.NewProjectile(entitySource, position, velocity, type, damage, kb, Main.myPlayer);
Tile / ModTile
タイル情報の同期
第二、第三引数として渡した座標のタイルと、それを中心とした周囲の8タイルの情報を同期する。
例えば、追加した植物の成長やソリューションなどによるタイルの置換、ゲームの進行に合わせてタイルを変更した場合に使用する。
当然実行する場所は変更を加えた送信元であり、処理の内容や実行されるメソッドによって変わる。
NetMessage.SendTileSquare(-1, i, j, 1);// i, jはそれぞれ変更を加えたタイルのx, y座標
タイルや壁の破壊とかワイヤー周りとか
第五引数の数値によって同期するデータが変化する。タイル/壁の破壊や各色ワイヤー、アクチュエータの設置、破壊等...
タイル破壊の同期は破壊用メソッドに包括されているため、破壊時の同期に使うことはまずない。
そのほかの場合でも上記の同期方法で事足りるうえに、特定の動作の実行メソッドには大抵その同期処理が包括されているため、よほど高度なものを作っていない限り不要。
詳しく知りたい人は受信部処理1を見てほしい。
// i, jはそれぞれ変更を加えたタイルのx, y座標
NetMessage.SendData(MessageID.TileManipulation, -1, -1, null, 0, i, j, 0, 0);
ModTileEntity
そもそも大半の人は使わないと思うけどtModのwikiがちょっとだけ説明しているのでします。
ここからはModTile
(以下タイル) の仕様や作りも知っていることが前提の話になります。
まずはModTileEntity
(以下TE) を作成します。その際に最低限、設置時同期用のオーバーライドメソッドとして以下の記述が必要になります。
...
public class TEHoge : ModTileEntity
{
public override int Hook_AfterPlacement(int i, int j, int type, int style, int direction, int alternate)
{
i -= x;// タイルの左上の端のx座標をとるように、タイルの原点からの水平距離を減算してください
j -= y;// タイルの左上の端のy座標をとるように、タイルの原点からの垂直距離を減算してください
if (Main.netMode == NetmodeID.MultiplayerClient)
{
NetMessage.SendTileSquare(Main.myPlayer, i, j, w, h);// wにはタイルの横幅、hには縦幅を入れてください。
NetMessage.SendData(MessageID.TileEntityPlacement, -1, -1, null, i, j, Type, 0f, 0, 0, 0);
return -1;
}
return Place(i, j);
}
public override void OnNetPlace()
{
NetMessage.SendData(MessageID.TileEntitySharing, -1, -1, null, ID, Position.X, Position.Y);
}
...
}
TEと紐づけしたいタイルのSetStaticDefaults()
でTileObjectData.newTile.HookPostPlaceMyPlayer
にHook_AfterPlacement
を設定します
public override void SetStaticDefaults()
{
...
TileObjectData.newTile.HookPostPlaceMyPlayer = new PlacementHook(ModContent.GetInstance<TEHoge>().Hook_AfterPlacement, -1, 0, false);
...
}
以上、最低限。
持たせたフィールドの同期方法は、そのためのメソッドが用意されているので自分でTE作るときに調べて下さい。
World / ModWorld
ワールド情報の同期
時間を変更したり、ワールドのフラグ周りを変更した際に必ず行う必要がある。
if (Main.netMode == NetModeID.Server)
NetMessage.SendData(MessageID.WorldData);
ModSystem
のNetSend
及びNetReceive
に同期用のコードを書いておけば、自前のワールドのフラグを同期したい場合にこの手段で同期できる。詳しくは公式のサンプルであるExample Mod2のDownedBossSystem.cs
の説明を見てほしい。
...ただしこの同期処理はNPCと同じくサーバーからクライアントへの一方通行である。この方法でクライアントから送信されたワールド情報はサーバーサイドでは受け取り処理が実行されない。
クライアント側でしか実行されない場所からワールドデータを変更及び同期させたい場合は、NewNPCの項で述べたような工夫が必要。
同期される情報に関しては、量がProjectile
やNPC
の比でないので君たちの目で確かめてくれ!
Item / ModItem / GlobalItem
NewItem を使うとき
NewItem
はアイテムを直接スポーンさせる際に使うメソッドである。
こいつ単体だと、マルチプレイにおいてはサーバー側での実行だけでしか自動的に同期されない。
そのため、クライアント側で実行する場合はSendData
を使って手動で同期させる必要がある。
またアイテムの同期の場合は、SendData
の第五、第六引数が、同期する情報に関わってくる。
第五引数は、同期するアイテムのインデックスである。Main.item[]
の何番目のアイテムかを示す値である。
第六引数は、0をとる場合とそれ以外の値をとる場合で同期後のアイテムの動作が変わる。
0の場合は、送信元のプレイヤーがそのアイテムを拾えるまでのインターバルが発生する。
アイテムをインベントリからワールドに投げ捨てた時ってすぐ拾えないじゃん?あんな感じ。
1の場合は、NPCやブロックが落としたアイテムのように誰であろうと即座に回収できるようになる。
以下はシングルまたはマルチプレイヤーのクライアントでのみ実行される場所からNewItem
を実行し、マルチの場合のみ同期を行う場合の例である。
int whoAmI = Item.NewItem(entitySource, x, y, width, heigth, type, stack);// 発生、インデックスの取得
bool noGrabDelay = true;// 送信元のプレイヤーが即座にアイテムを拾えるか否か
if (Main.netMode == NetModeID.MultiplayerClient)// マルチの場合だけはSendDataで同期する
NetMessage.SendData(MessageID.SyncItem, -1, -1, null, whoAmI, noGrabDelay.ToInt());
両サイドで実行されるメソッド内に書く場合は、以下のようにサーバーまたはシングル環境でだけNewItem
を実行する。
前述したとおり、その際のSendData
は不要。
if (Main.netMode != NetModeID.MultiplayerClient)
Item.NewItem(entitySource, x, y, width, heigth, type, stack);
同期される情報の一覧
フィールド名1 | 備考 |
---|---|
position | |
velocity | |
stack | |
prefix | |
netID |
type とほぼ同義。 |
shimmered | 1.4.4 |
shimmerTime | 1.4.4 |
Dust / ModDust / Gore / ModGore
知らない人のために説明しておくと、Dust
は炎とかブロック壊したときに出てくる破片とか紙吹雪とかのパーティクル、Gore
はNPCの死骸の破片とか爆発によって発生する大きめの煙とか木の葉とか天井から降ってくる水滴とか。
総じて視覚エフェクトのことである。
Dust
Dust
を発生させる際は発射体等からfor
やwhile
といったループを使用して発生させることが殆どである。
筆者は当初、視覚的なエフェクトをサーバーで発生させるのは無駄に負荷かかるしまずいんじゃねーか? とか思っていた。
しかしながらDust
を発生させるためのDust.NewDust
やDust.NewDustDirect
はそもそもサーバーでは発生処理を行わないように配慮されている。
そのため、Dust
を発生させるにあたって特にサイドチェックは必要ない。
ただ、発生のために大規模な計算なんかや多重ループを挟むようであればそこはネストした方が良いのかもしれない。
Gore / ModGore
Gore.NewGore
はDust
と同様にサーバーであれば発生しないようになっているが...
ModGore
はサーバーサイドで発生させようとしてはいけない。
結果に関わらず、引数にModGore
を入れてNewGore
を実行した時点でサーバーが死にます。
ModGore
を発生させる前には必ずサーバーではないことを確認すること。
余談 Main.dedServ
実はサーバーで動作しているかを示すMain.dedServ
(曰くdedicated server) というbool
のフィールドが存在する。
殆どの場合、netMode == 2
と同義である。じゃあ例外は何なのだよ
こいつは各種ロード時に、クライアント側のみでテクスチャやサウンドといったアセットの類に関係する処理を行う際に使用されている印象がある。
if (Main.dedServ)
return;
//テクスチャ絡みのロード処理とか
tModLoaderのDiscordサーバーで調べてみてもtml開発者が同じこと言ってたので多分そうきっとそう
Example Mod2にも何か所か使われてるところがあったはずなので探してみるといいかもしれない。
後書き
以上で頻出箇所の押さえておくべきところは説明できたと思います。長すぎる!
もし独自のクラスとかなんかで同期する必要のあるフィールドなんかを作成した場合は、ModPacket
でそれを同期するための独自の処理を追加することもできる。それについてはそのうち書くかもしれないし書かないかもしれない。
ちなみにModPlayer
の同期についてここで触れていないのはModPacket
が絡んでくるためである。
もしあなたがそれをつかうときは、Intermediate netcode · tModLoader_tModLoader Wiki とかExample Mod2とかがきっとあなたのやくにたってくれるでしょう。しらんけど
-
受信部処理: Method
GetData(int start, int length, out int messageType)
inTerraria.MessageBuffer.cs
送信部処理: MethodSendData(int msgType, int remoteClient = -1, int ignoreClient = -1, NetworkText text = null, int number = 0, float number2 = 0f, float number3 = 0f, float number4 = 0f, int number5 = 0, int number6 = 0, int number7 = 0)
inTerraria.NetMessage.cs
↩ ↩2 ↩3 ↩4 ↩5 -
Example Mod for 1.4 (GitHub) (Steam Workshop) ↩ ↩2 ↩3