Colyseusを使ったリアルタイムマルチプレイヤー
「create game」を選択し、新しいゲームを立ち上げます。床のいずれかをクリックしてオブジェクトを移動します。
このチュートリアルでは、次のことを学びます:
- Colyseusサーバーの設定
- サーバーとクライアント間での状態同期
- クライアントとサーバー間でのメッセージ交換
- マッチメイキング: ゲームの作成、参加、利用可能なゲームのリストアップ
必要なもの
開始する前に
前提知識
- 基本的なPlayCanvasの知識 (PlayCanvas開発者リソースを参照してください)
- 基本的なJavaScript/TypeScriptの理解 (TypeScript Handbookを参照してください)
- 基本的なNode.jsの理解 (Introduction to Node.jsを参照してください)
ソフトウェア要件
サーバーの作成
プレイヤーの状態を保持するために、基本的なサーバーをローカルに作成します。変更はクライアントと自動的に同期されます。
新しいColyseusサーバーを作成するには、コマンドラインから以下を実行します:
npm init colyseus-app ./playcanvas-demo-server
次にnpm start
を実行して、ローカルでサーバーを動かすことができるか確認しましょう。
cd playcanvas-demo-server
npm start
成功すれば、コマンドラインに以下のような出力が表示されます。
> my-app@1.0.0 start
> ts-node-dev --respawn --transpile-only src/index.ts
✅ development.env loaded.
✅ Express initialized
🏟 Your Colyseus App
⚔️ Listening on ws://localhost:2567
Colyseus JavaScript SDKのインポート
PlayCanvasにColyseus JavaScript SDKを追加する必要があります。
"PlayCanvasプロジェクトの設定"を使用して、"外部スクリプト"として追加できます。
**「メニュー」→ 「設定」**を開いてください:
設定パネルから、**「外部スクリプト」を展開し、「URL」**の数を増やします。
新しい**「URL」**フィールドに、CDNからColyseus JavaScript SDKを含めてください:
https://unpkg.com/colyseus.js@^0.15.0-preview.2/dist/colyseus.js
これにより、PlayCanvasスクリプトの Colyseus
JavaScript SDK を使用できます。
クライアント - サーバー接続の確立
新しいPlayCanvasスクリプトから、Colyseus.Client
インスタンスを作成しましょう(「新しいスクリプトの作成方法」を参照してください)。
このスクリプトは、「NetworkManager」という新しい空のエンティティにアタッチできます。
var NetworkManager = pc.createScript('networkManager');
NetworkManager.prototype.initialize = async function () {
//
// SDKをインスタンス化します
//(接続はまだ確立されていません)
//
this.app.colyseus = new Colyseus.Client("ws://localhost:2567");
//
// ルーム「my_room」を作成または参加するようにリクエストします
//(サーバーとの接続を確立します)
//
this.room = await this.app.colyseus.joinOrCreate("my_room");
}
ここで、ローカルの
ws://localhost:2567
エンドポイントを使用しています。他の人とオンラインでプレイするには、 サーバーをデプロイして、公共のインターネットを使用する必要があります。Glitchを使ってサーバーを公開することもできます。
PlayCanvasプロジェクトを**「起動」**すると、クライアントはサーバーと接続し、サーバーは必要に応じてmy_room
という部屋を作成します。
my_room
は、Colyseusサーバーのデフォルトのルーム識別子です。 arena.config.ts
ファイルでこの識別子を変更することができます。
クライアントがルームに正常に参加したことを意味するサーバーログに以下のメッセージが表示されます。
19U8WkmoK joined!
ルーム状態とスキーマ
Colyseusでは、共有データを Schema
構造を使用して定義します。
Schema
はColyseusからの特別なデータ型で、その変更/変異を_増分的に_エンコードする能力があります。エンコードとデコードのプロセスはフレームワークとそのSDKによって内部的に行われます。
ステート同期のループは次のようになります。
- 状態の変更(変異)は、サーバー→クライアント間で自動的に同期されます。
- クライアントは、ローカルの_読み取り専用_の
Schema
構造体にコールバックをアタッチすることで、状態の変化を観察し、それに対応することができます。 - クライアントは任意のメッセージを サーバーに送信することができます - それが何をするかはサーバーが決定します - そして状態を変化させることができます(ステップ**1.**に戻ります)
サーバーコードを編集して、サーバー側でのルーム状態を定義しましょう。
複数の Player
インスタンスを処理する必要があります。各 Player
には、 x
、y
、z
座標があります。
// MyRoomState.ts
import { MapSchema, Schema, type } from "@colyseus/schema";
export class Player extends Schema {
@type("number") x: number;
@type("number") y: number;
@type("number") z: number;
}
export class MyRoomState extends Schema {
@type({ map: Player }) players = new MapSchema<Player>();
}
スキーマ構造についてもご覧ください。
次に、サーバーサイドで onJoin()
メソッドを変更して、ルームとの新しい接続が確立されるたびに Player
インスタンスを作成します。
// MyRoom.ts
// ...
onJoin(client: Client, options: any) {
console.log(client.sessionId, "joined!");
// create Player instance
const player = new Player();
// place Player at a random position
const FLOOR_SIZE = 4;
player.x = -(FLOOR_SIZE/2) + (Math.random() * FLOOR_SIZE);
player.y = 1.031;
player.z = -(FLOOR_SIZE/2) + (Math.random() * FLOOR_SIZE);
// place player in the map of players by its sessionId
// (client.sessionId is unique per connection!)
this.state.players.set(client.sessionId, player);
}
// ...
}
また、クライアントが切断された場合には、プレイヤーをプレイヤーマップから削除するようにしましょう。
// MyRoom.ts
// ...
onLeave(client: Client, consented: boolean) {
console.log(client.sessionId, "left!");
this.state.players.delete(client.sessionId);
}
// ...
サーバーサイドで行った状態変化は、クライアントサイドで 観察できます 。次のセクションでやることです。
同期のためのシーンのセットアップ
このデモ用に、シーンに2つのオブジェクトを作成する必要があります。
- 床を表す Plane
- プレイヤーを表す Capsule。新しいプレイヤーがルームに参加するたびに複製します。
Planeの作成
スケール8
のPlaneを作成しましょう。
Playerの作成
スケール1
のPlayerカプセルを作成しましょう。
"Enabled"
プロパティのチェックを外すことを確認してください。サーバーとのアクティブな接続があるまでは、Playerのインスタンスは有効化されません。
Stateの変更を監視
ルームとの接続が確立した後、クライアント側はStateの変更を監視し、サーバー上のデータの視覚的な表現を作成できます。
Playerの追加
Room State and Schemaセクションによると、サーバーが新しい接続を受け入れると、 onJoin()
メソッドがState内に新しいPlayerインスタンスを作成します。
これをクライアント側でリッスンするようにします。
// ...
this.room.state.players.onAdd((player, sessionId) => {
//
// プレイヤーが参加しました!
//
console.log("A player has joined! Their unique session id is", sessionId);
});
// ...
Sceneをプレイすると、新しいクライアントがルームに参加するたびに、ブラウザのコンソールにメッセージが表示されます。
視覚的な表現については、"Player"オブジェクトをクローンし、そのsessionId
に基づいてクローンされたオブジェクトのローカル参照を保持しておく必要があります。これにより、後で操作できます。
// ...
// `sessionId`ごとにそれぞれのプレイヤーのビジュアル表現を割り当てます
this.playerEntities = {};
// 新しいプレイヤーをリッスンします
this.room.state.players.onAdd((player, sessionId) => {
// 基本のPlayer表現を検索します(有効になっていません)
const playerEntityToClone = this.app.root.findByName("Player");
// Player表現をクローンし、有効にします!
const entity = playerEntityToClone.clone();
entity.enabled = true;
// サーバーデータに基づいて位置を設定します
entity.setPosition(player.x, player.y, player.z);
// クローンをSceneに追加します
playerEntityToClone.parent.addChild(entity);
// `sessionId`によってビジュアル表現を割り当てます
this.playerEntities[sessionId] = entity;
});
// ...
現在のプレイヤー
sessionId
を接続されたroom.sessionId
と照合して、現在のプレイヤーオブジェクトに対する特別な参照を保持することができます。
// ...
this.room.state.players.onAdd((player, sessionId) => {
// ...
if (this.room.sessionId === sessionId) {
this.currentPlayerEntity = this.playerEntities[sessionId];
}
// ...
});
接続が切断されたプレイヤーの削除
プレイヤーがStateから削除された場合(サーバーサイドで onLeave()
が呼び出された場合)、その視覚的な表現も削除する必要があります。
// ...
this.room.state.players.onRemove((player, sessionId) => {
// destroy entity
this.playerEntities[sessionId].destroy();
// clear local reference
delete this.playerEntities[sessionId];
});
// ...
プレイヤーの移動
新しい位置をサーバーに送信する
マウスダウンイベントを許可し、ray castを使用して、プレイヤーが移動するべき正確なVec3
位置を決定し、それをメッセージとしてサーバーに送信するようにします。
// ...
this.app.mouse.on(pc.EVENT_MOUSEDOWN, (event) => {
// 床の「バウンディングボックス」を作成します
const boundingBox = new pc.BoundingBox(new pc.Vec3(0, 0, 0), new pc.Vec3(4, 0.001, 4));;
// rayを初期化し、rayの方向を決定します
// スクリーン位置からのrayの方向を決定します
const ray = new pc.Ray();
const targetPosition = new pc.Vec3();
const cameraEntity = this.app.root.findByName("Camera");
cameraEntity.camera.screenToWorld(event.x, event.y, cameraEntity.camera.farClip, ray.direction);
ray.origin.copy(cameraEntity.getPosition());
ray.direction.sub(ray.origin).normalize();
// 地面に対してrayをテストします
const result = boundingBox.intersectsRay(ray, targetPosition);
if (result) {
// 位置の高さを調整
targetPosition.y = 1.031;
//
// 新しい目標プレイヤー位置をサーバーに送信します。
//
this.room.send("updatePosition", {
x: targetPosition.x,
y: targetPosition.y,
z: targetPosition.z,
});
}
});
サーバーからのメッセージの受信
サーバーから "updatePosition"
メッセージを受信するたびに、メッセージを送信したプレイヤーをそのsessionId
を通じて変更します。
// MyRoom.ts
// ...
onCreate(options: any) {
this.setState(new MyRoomState());
this.onMessage("updatePosition", (client, data) => {
const player = this.state.players.get(client.sessionId);
player.x = data.x;
player.y = data.y;
player.z = data.z;
});
}
// ...