メインコンテンツまでスキップ

ワールド同期

ワールド同期システムは、プレイヤー位置のリアルタイム同期、リモートプレイヤーの描画、NPC 状態の調整、チャンネル/インスタンスシステムを扱います。Nakama のリアルタイムマッチ API の上に構築されており、1 マップにつき 1 マッチです。

トランスポートは 2 種類あり、OnlineConfig.WorldSyncMode で選びます。

モード位置の流れ方誰が誰を見るかを決めるもの
Authoritative(デフォルト)各クライアントが位置をサーバーのオーバーワールドマッチ(Go プラグイン)に送り、サーバーは各受信者に、その受信者が見えるものだけを含むティックごとに 1 つのバッチを送るサーバー(Go マッチ内の関心領域)
Relayedマッチがすべての位置を、そのマップのすべてのプレイヤーに中継する各クライアント(OnlineConfig.AOIRadius)

Authoritative は数千人のプレイヤーにスケールするほうで、中継型は 1 つのマッチに約 100 人を超えたあたりから劣化します。このページの残りでは、どの部分がどちらかのモードに固有かを示します。

ワールド同期 HUD が表示された、オンラインセッションのオーバーワールド

このページが説明するすべては、その 1 枚のフレームに見えています。左上のチャンネルバッジと Online — Channel 1、関心領域(Area-of-Interest) のフィルタリングが生み出す近くのプレイヤー数、そして下部の遅延/サーバーのインジケーター。仲間がいるはずなのに 数が 0 の場合は、まず AOI を確認してください。

アーキテクチャの概要​

ワールド同期のスタックは、次のコンポーネントで構成されます。

クラスファイル責務
WorldSyncServiceWorld/WorldSyncService.csマップのマッチへの参加、位置のブロードキャスト、マッチ状態のルーティング、クライアント側の AOI(中継モード)
WorldBatchDecoderWorld/WorldBatchDecoder.csサーバーのティックごとのバッチ(権威モード)を、同じ AOI イベントにデコードする
PlayerNetworkEntityWorld/PlayerNetworkEntity.csリモートプレイヤーの描画、補間、ネームプレート、フォロワースプライト
PlayerSpawnManagerWorld/PlayerSpawnManager.csAOI イベントに基づくリモートプレイヤーの GameObject の生成/破棄と、そのエモートの描画
ChannelManagerWorld/WorldSyncService.csサーバーのチャンネル/インスタンスの切り替えと人数の問い合わせ

さらに、NPCSyncService(World/PlayerNetworkEntity.cs 内)がプレイヤー間での NPC の対話ロックを扱い、FollowerUpdateData がモンスターのフォロワーを同期するための通信プロトコルを定義します。どちらのトランスポートも同じイベントを発生させるため、プレイヤーを描画するコードはどちらが有効かを知りません。

WorldSyncService​

初期化​

WorldSyncService は、NakamaManager.FinalizeConnection() の間に自動的に初期化されます。

await WorldSync.InitializeAsync(userId);

これはサービスを準備するだけです。マッチへの参加自体は、マップがわかった時点(プレイヤーのシーン)ですぐに行われ、プレイヤーがマップを変えるたびに再び行われます。

  • 権威モード:クライアントは { "mapId": "<map>" } を付けて world_join RPC を呼びます。サーバーは、そのマップの権威マッチの matchId を返し(まだ存在しなければ作成し、国勢調査データのないマップは拒否します)、クライアントはソケット経由でそれに参加します。サーバーの Go プラグインが読み込まれていない場合、参加はコンソールにエラーを出して失敗します。中継トランスポートへの黙ったフォールバックはありません。
  • 中継モード:クライアントは world_<mapId>(WorldSyncService.WorldMatchNameFor)という名前の中継マッチを作成または参加します。

プレイヤーがマップを変えると、古いマッチを離れ、近隣のプレイヤーを忘れ(スポーンマネージャーがそれらを破棄します)、新しいマッチで自分の位置とフォロワーを告知します。

マッチ状態プロトコル​

ワールド同期のすべての通信は、次のオペコードを持つ Nakama のマッチ状態メッセージを使います。

OpCode定数方向目的
1OP_POSITIONクライアント → マッチプレイヤー位置の更新(x、y、z、向き、マップ、加えて名前、見た目、レベル、ギルド、走る/自転車/泳ぐのフラグ)
2OP_MAP_CHANGE双方向プレイヤーが別のマップに移った
3OP_EMOTE双方向プレイヤーのエモート:オンラインエモート を参照
4OP_FOLLOWER_UPDATE双方向プレイヤーのモンスターフォロワーが変わった
10WorldBatchDecoder.OP_BATCHマッチ → クライアント権威マッチのティックごとのバッチ

受信したマッチ状態は HandleMatchState() でルーティングされます。バッチ(op 10)は送信者がなく、マッチ自体から来るため、最初に解決されます。ほかのすべてのメッセージはローカルプレイヤーを無視し、オペコードに基づいて振り分けられます。op 2、3、4 は、サーバーが届いたままほかのプレイヤーに転送するイベントです。

権威バッチ​

権威モードでは、サーバーのオーバーワールドマッチは毎秒 10 ティックで動作し、受信者ごと、ティックごとに 1 つの op 10 バッチを送ります。その受信者が見られるものだけが含まれます。

  • 相互のフレンドが最優先(遠く離れていても)。
  • 次に、関心領域の半径(20 タイル)内で最も近いプレイヤーが、上限(デフォルトは 50、サーバー側の MCE_WORLD_MAX_VISIBLE)まで。
  • ヒステリシス付き:すでに見えているプレイヤーは、半径の 4 タイル外まで見え続けるため、境界でちらつきません。

バッチはコンパクトです。プレイヤーは、マッチごとのスロットと、スロットが再利用されるたびに増える世代で識別されます(クライアントが、前のプレイヤーについて知っていたことを新しいプレイヤーに適用することがないように)。

{ "t": tick, "k": 1 on a keyframe,
"p": [[slot, gen, x, y, dir, flags(, z)]], positions; flags: run=1 bike=2 swim=4
"s": [[slot, gen, userId, name, look, lvl, guild, gm]], identity, only when the recipient does not know it yet
"f": [[slot, gen]] } left the recipient's area (or the match)

静止しているプレイヤーは再送されず、領域に入ってきたプレイヤーは静止していても完全な情報で届き、5 秒ごとのキーフレームで位置が再送されます。WorldBatchDecoder は、バッチを、中継経路が発生させるのと同じ OnPlayerEnterAOI、OnPlayerPositionUpdate、OnPlayerExitAOI イベントに変換します。

サーバーは、受け取ったすべての位置をループ内で検証する(後述)ため、クライアントが好きな場所に自分を置くことはできません。

位置のブロードキャスト​

ローカルプレイヤーの位置は、UpdateLocalPosition() で送られます。

NakamaManager.Instance.WorldSync.UpdateLocalPosition(
position, mapId, direction);

スロットリングのルール:

  • レート制限:OnlineConfig.PositionSyncRate で制御されます(デフォルト:毎秒 10 メッセージ)。
  • デッドゾーン:最後に同期した位置から 0.01 ユニット以内の位置は抑制されます。

ペイロードは、JSON にシリアライズされた PositionData で、フィールドは x、y、z、dir(向き)、map に加えて、受信側がプレイヤーを描画するために必要なもの:name(ネームプレート用)、look(描画する CharacterType の名前)、lvl(手持ちで最も強いモンスターのレベル)、ギルドタグ、プレイヤーがゲームマスターかどうか、そして run、bike、swim のフラグです。

フォロワーのブロードキャスト​

ローカルプレイヤーのモンスターフォロワーが変わったら、更新をブロードキャストします。

var data = FollowerUpdateData.FromMonster(monster, form, gender);
NakamaManager.Instance.WorldSync.BroadcastFollowerUpdate(data);

// To clear (hide follower for remote players):
NakamaManager.Instance.WorldSync.BroadcastFollowerUpdate(
FollowerUpdateData.CreateClear());

FollowerUpdateData には、speciesId、formIndex、gender、isShiny、isEgg、nickname が含まれます。speciesId が 0 の場合は「クリア」の合図です。

フォロワーのブロードキャストは重複排除されます:同じ種は、5 秒のクールダウン期間内には抑制されます。

関心領域(AOI)フィルタリング​

AOI フィルタリングは、クライアントが遠すぎるプレイヤーの更新を処理するのを防ぎます。

権威モードでは、サーバーがフィルタリングを行います(上のバッチを参照)。クライアントは描画すべきプレイヤーだけを受け取り、OnlineConfig.AOIRadius はサーバーが送る内容を変えません。

中継モードでは、クライアントがフィルタリングします。半径は OnlineConfig.AOIRadius(デフォルト:20 タイル)で設定されます。リモートプレイヤーの位置更新が届くと:

  1. ローカルプレイヤーからの距離が計算されます。
  2. AOI の半径内にあり、まだ追跡されていなければ、OnPlayerEnterAOI が発生します。
  3. AOI 内であれば、(userId, position, direction) を伴って OnPlayerPositionUpdate が発生します。
  4. AOI の外にあり、以前に追跡されていた場合、OnPlayerExitAOI が発生し、プレイヤーは NearbyPlayers から削除されます。

イベント​

WorldSync.OnPlayerEnterAOI += (OnlinePlayerInfo info) => { /* spawn */ };
WorldSync.OnPlayerExitAOI += (string userId) => { /* despawn */ };
WorldSync.OnPlayerPositionUpdate += (string userId, Vector3 pos, int dir) => { /* move */ };
WorldSync.OnPlayerMapChanged += (string userId, string mapId) => { /* map transition */ };
WorldSync.OnFollowerUpdate += (string userId, FollowerUpdateData data) => { /* follower change */ };

NearbyPlayers 辞書は、読み取り専用のスナップショットを提供します。

IReadOnlyDictionary<string, OnlinePlayerInfo> nearby =
NakamaManager.Instance.WorldSync.NearbyPlayers;

PlayerNetworkEntity​

各リモートプレイヤーは、生成された GameObject 上の PlayerNetworkEntity MonoBehaviour で表現されます。次を扱います。

初期化​

entity.Initialize(onlinePlayerInfo);

OnlinePlayerInfo から、userId、displayName、guildTag、level、バトル状態、観戦可能フラグを設定します。

補間された移動​

ReceivePositionUpdate(position, direction) が呼ばれると:

  • 距離が snapDistance(デフォルト:5 タイル)を超える場合、エンティティは瞬時にテレポートします(マップ変更やワープを処理)。
  • それ以外の場合、エンティティは interpolationSpeed(デフォルト:10)で目標に向かって補間されます。

スプライトアニメーション​

リモートプレイヤーは、4 方向 × 4 フレームの歩行サイクル(合計 16 スプライト)を使います。エンティティが移動中は、アニメーションが 1 フレームあたり 0.2 秒で動きます。静止しているときは、現在の向きの最初のフレームを表示します。左右の反転は SpriteRenderer.flipX で処理されます。

フォロワーの表示​

リモートプレイヤーのフォロワーは、SetFollower(FollowerUpdateData) で管理されます。

  • speciesId > 0 の非 null のデータは、フォロワースプライトの子を表示します。
  • null または speciesId == 0 は ClearFollower() を呼び、スプライトを非表示にします。
  • HasActiveFollower と CurrentFollowerSpeciesId のプロパティが、現在の状態を公開します。

インタラクション​

リモートプレイヤーをクリックすると、OnClicked が発生します。IsInBattle プロパティと SetBattleStatus() メソッドは、プレイヤーがバトル中かどうかを追跡します(ネームプレートに視覚的なインジケーターを表示)。

PlayerSpawnManager​

PlayerSpawnManager は、WorldSyncService の AOI イベントを購読し、リモートプレイヤーの GameObject を自動的に管理する MonoBehaviour です。

  • OnPlayerEnterAOI:playerNetworkPrefab をインスタンス化し(または SpriteRenderer + PlayerNetworkEntity を持つ空の GameObject を作成し)、Initialize() を呼んで、登録します。
  • OnPlayerExitAOI:対応する GameObject を破棄し、追跡用の辞書から削除します。
  • OnPlayerPositionUpdate:対応するエンティティの ReceivePositionUpdate() を呼びます。

現在生成されているすべてのプレイヤーにアクセスします。

var spawned = spawnManager.GetSpawnedPlayers();

NPC の同期​

NPCSyncService は、接続しているすべてのプレイヤー間で NPC の状態に一貫性を持たせます。

対話ロック​

オンラインワールドでプレイヤーが NPC に話しかけると、その NPC は「ロック」され、ほかのプレイヤーが同時にやり取りできなくなります。

npcSync.RegisterNPC("npc_nurse_joy", position, "PalletTown");

// Player starts talking
bool engaged = npcSync.TryEngageNPC("npc_nurse_joy", userId);

// Player finishes conversation
npcSync.ReleaseNPC("npc_nurse_joy");

NPCNetworkState は、NpcId、Position、MapId、IsEngaged、EngagedByUserId、DialogState を追跡します。

OnNPCStateChanged は、NPC の対話状態が変わるたびに発生します。

チャンネルシステム​

ChannelManager は、人気のモンスター捕獲 MMO と同様の、MMO スタイルのチャンネル/インスタンスシステムを提供します。

設定​

設定デフォルト説明
DefaultChannel1接続時に参加するチャンネル
MaxChannels10ワールドごとの最大チャンネル数

チャンネルの切り替え​

bool switched = await NakamaManager.Instance.Channels.SwitchChannelAsync(3);
int current = NakamaManager.Instance.Channels.CurrentChannel;

チャンネルの値は 1 から MaxChannels の間でなければなりません。切り替えに成功すると、OnChannelChanged イベントが発生します。

チャンネルの人数​

Dictionary<int, int> populations =
await NakamaManager.Instance.Channels.GetChannelPopulationsAsync();

OnChannelPopulationUpdate イベントは、(channel, playerCount) の更新を提供します。

帯域幅の最適化​

いくつかの仕組みが、ネットワークトラフィックを最小化します。

  1. 位置のスロットリング:PositionSyncRate が毎秒のメッセージ数を制限します(デフォルト:10/秒)。
  2. デッドゾーンフィルタリング:最後の同期から 0.01 ユニット以内の位置は抑制されます。
  3. AOI によるカリング:権威モードではサーバーが受信者の見えるものだけを送ります(ティックごとに 1 バッチ)。中継モードでは AOIRadius タイル以内のプレイヤーだけが処理されます。
  4. 最大可視プレイヤー数:MaxVisiblePlayers が、PlayerSpawnManager が画面上に保持するリモートプレイヤーの数を制限します(デフォルト:50)。
  5. フォロワーの重複排除:5 秒以内の同じ種のフォロワーのブロードキャストは抑制されます。
  6. メインスレッドへのディスパッチ:すべての Unity API 呼び出しは、NakamaManager.RunOnMainThread() を通じてメインスレッドにマーシャリングされ、スレッド間の問題を防ぎます。

サーバー権威のテレポート検証​

マップをまたぐワープやテレポートは、mce_validate_teleport RPC (RpcIds.ValidateTeleport)を通じて、サーバー側で検証されます。これは src/main.ts の rpcMceValidateTeleport が処理します。これは、プレースホルダーではなく、実際に強制される チェックであり、サーバーは決して成功を偽りません。

クライアントは TeleportValidateRequest を送ります。

[Serializable]
public class TeleportValidateRequest
{
public string player_id; // must equal the caller's session user id
public string target_scene; // e.g. "pallet_town"
public float target_x;
public float target_y;
}

サーバーは、順に次を適用します。

  1. ID:player_id は ctx.userId と等しくなければなりません(偽造された ID や セッションの欠落は拒否されます)。
  2. 座標の妥当性:target_x / target_y は有限で、±100000 ワールドユニット以内でなければなりません (偽造された Infinity もこれで拒否されます)。
  3. レート制限:ユーザーごとの、ストレージに裏付けられた 10 秒あたり 30 回のテレポートの上限 (バケット validate_teleport)。クライアントはこれを無効にできません。計時にはサーバー時刻を使うため、 ウィンドウを偽造することはできません。
  4. シーンの許可リスト:target_scene は既知のシーンでなければなりません。小さな組み込みの フォールバック(pallet_town、route_1、viridian_city)、または、投入可能な scene_registry ストレージコレクション(システムユーザー、公開読み取り/サーバー書き込み)内のレコードです。 未知のシーンは、サーバー権威の dev_mode で制御されます。開発ノードでは寛容(許可して警告)で、 prod-sim ノードでは失敗時に閉じる(code: "UNKNOWN_SCENE" で拒否)。

成功すると、サーバーは { success:true, scene, position_x, position_y } を返します。 失敗すると、要求されたシーン/位置をそのまま添えて { success:false, error, code? } を返します。 クライアントは、success:false ならテレポートをブロックします。従来の NOT_IMPLEMENTED のソフト許可 (OnlineConfig.TeleportSoftAllowWhenUnimplemented)は、このハンドラーより前の古いサーバー向けの フォールバックとしてのみ残されています。

サーバー側の移動ガード(テレポート対策)​

通常の位置ブロードキャストでさえ監視されています。同じチェックが両方のトランスポートで実行されます:権威マッチのループ内と、中継マッチの beforeMatchDataSend フック(src/world/presence.ts)です。OP_POSITION の更新ごとに、サーバーは、送信者の最後に記録された位置との直線距離を、サーバーが計測した経過時間を使って比較します。

  • ジャンプは、その距離が 30 u/s × elapsed + 4 u を超えたときにのみ中継から破棄されます。これは本来 mce_validate_teleport を通るべきものです。正当な走行/自転車(自転車は <~12 u/s、AOI の半径は 20 u)が、誤検知されることは決してありません。
  • 常に受け入れられる(かつ基準が更新される)もの:初めて見るプレイヤー、マップ変更(マップをまたぐ ワープは別の座標空間)、座標の欠落、> 5 s の間隔 (判断不能であり、これは誤検知も自己修復します)。
  • 拒否されたとき、基準は進められないため、テレポートした位置が 伝わることはありません。検証はフェイルオープンです(中継をブロックすることは決してありません)。
  • サイズが大きすぎるペイロード(8 KB 超)は、ほかの何よりも先に破棄されます。

サーバーは、受理した各 OP_POSITION(マップ + 位置)を world_presence コレクションにも ミラーリングし(変更時に書き込み + 60 秒ごとのリフレッシュ)、バックエンドに権威あるマップ分布を提供します。

正常なシャットダウン​

切断時には、WorldSyncService.StopSyncAsync() が自動的に呼ばれます。

await WorldSync.StopSyncAsync();

これにより、ワールドマッチから退出し、worldMatchId をクリアし、nearbyPlayers 辞書を空にします。

2 つの実クライアントで検証済み​

上記はすべて、コードがどう配線されているかを説明したものです。このセクションは、2 つの ビルド済みゲームクライアントが、稼働中の Nakama サーバーを相手に、2026-09-01 に実際に行ったことを記録します。 配線の説明と動作する機能は、同じ主張ではないからです。

このセクションが存在する理由

このページは、プレイヤー同士が互いに見えなかった間も存在し、コードについては正確でした。 リモートのアバターは、持ち主の位置ではなくワールドの原点にインスタンス化されていたのです。 ログ行 [Spawn] Remote player spawned: JugadorB は、動作している場合も壊れている場合も書き出されたため、 ログにはページと矛盾するものが何もありませんでした。

以下の修正と証拠は、実際のビルドを操作して得られたものです。このページが「動作する」と 主張するものはすべて、2 クライアントでの実行がそれを示したから主張されています。

操作した内容​

2 つの別々のビルド済みクライアント(MCE_Online.exe。フォルダーごとに 1 つ -- 同じフォルダーで 2 つのインスタンスは起動に失敗します)。それぞれ独自のログを持ち、別々のアカウントでサインインし、 同じマップに入りました。

各クライアントは、相手を実際の位置に見ている​

client A log:  [Spawn] Remote player spawned: JugadorB en (-2.00, 13.00, 0.00), sprite=SI, activo=True
client B log: [Spawn] Remote player spawned: NegProg en (-3.00, 7.00, 0.00), sprite=SI, activo=True

2 つの座標は異なります。これが重要な対照です。受信側が相手の位置ではなく 自分の位置を繰り返しているなら、両方の行は同じになるはずです。

移動が伝わる​

A が歩いている間、サーバーはそこから 32 個の異なる位置を受信しました。補間も 含めてです。

"x":-1.0,"y":8.87   "x":-1.0,"y":8.43   "x":-1.0,"y":8.00
"x":-1.0,"y":7.67 "x":-1.0,"y":7.27 "x":-1.0,"y":7.00

位置の更新は、クライアントのログではなくサーバー上で検証されます。クライアントは すべての更新を出力しないため、そこで探しても何も見つからず、何も証明されません。

画面上で​

プレイヤー A の視点:ネームプレートとフォロワーを伴うリモートプレイヤー

同じ瞬間のプレイヤー B の視点

リモートのアバターは、ネームプレート(名前とレベル)とフォロワーモンスターを伴って描画されます。

既知の制限を率直に述べる​

  • 中継モードでは、AOI のフィルタリングはクライアント側です。 すべての位置ブロードキャストは、マップのマッチ内の すべてのクライアントに届き、各クライアントが範囲外のものを破棄します。そのため、改造されたクライアントは、 意図されたよりも遠くのプレイヤーを見られる可能性があります。権威トランスポート(デフォルト)には この制限はありません。サーバーは、各クライアントが見てよいものだけを送ります。以下の証拠は、 中継トランスポートで記録されたものです。
  • 立っているプレイヤーは 1 回だけブロードキャストします。 位置は、変化時と定期的な リフレッシュで送られるため、まったく動かないプレイヤーは 1 回だけ告知します。その最初のパケットを 破棄した受信側には二度目の機会がなく、まさにそれが原点のバグが生き残った理由です。