mcpField
S
starwards-colyseus

starwards-colyseus

github.com/starwards開発4447 回インストール2 日前に更新
リポジトリ

starwards-colyseus について

ルーム、状態、クライアントコマンドを扱うStarwards開発者向けの、Colyseus同期スキルです。同期ルールやよくある問題に加え、状態複製のテストとデバッグ方法を説明します。

主な機能

  1. 01@gameFieldによる同期状態の定義
  2. 02ルームとクライアントコマンドの構成
  3. 03ネストしたコレクションと状態更新の処理
  4. 04型の精度と同期時の注意点
  5. 05同期テストとデバッグ手順

活用シーン

  1. 01マルチプレイヤー同期状態の追加や変更
  2. 02クライアント状態が更新されない問題の調査
  3. 03複数クライアント間の状態一致をテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/starwards/starwards --skill starwards-colyseus

インストール後、Claude Code で/starwards-colyseusを入力して読み込まれたか確認します。

詳細

Starwards 向け Colyseus マルチプレイヤーパターン

概要

Starwards は、リアルタイムのマルチプレイヤー状態同期に Colyseus v0.15 を使用します。デコレーター、ルーム、状態同期について理解しておくと、バグを防げます。

基本原則: 状態が唯一の信頼できる情報源です。コマンドは状態を変更します。クライアントは自動的に更新を受け取ります。

アーキテクチャ

Client (Browser)
  ↓ WebSocket connection
Room (Server)
  ↓ owns
State (Schema)
  ↓ syncs to
Client State (Mirror)

フロー:

  1. クライアントがコマンドを送信 → ルーム
  2. ルームが状態を変更
  3. Colyseus がパッチを送信 → クライアント
  4. クライアントの状態が自動的に更新
  5. UI が状態の変更に反応

@gameField デコレーター

目的: Colyseus による自動同期の対象としてプロパティを指定します

場所: modules/core/src/game-field.ts

使用方法:

import { gameField } from '../game-field';

class Shield extends SystemState {
  // Primitive types
  @gameField('float32') strength = 1000;
  @gameField('float32') power = 1.0;
  @gameField('boolean') broken = false;

  // Nested Schema
  @gameField(ShieldDesign) design = new ShieldDesign();

  // Arrays
  @gameField([Emitter]) emitters = new ArraySchema<Emitter>();

  // Maps
  @gameField({ map: Target }) targets = new MapSchema<Target>();
}

型:

  • 'float32' - 32 ビット浮動小数点数(64 ビット数値より精度が低くなります!)
  • 'float64' - 64 ビット浮動小数点数
  • 'int8'、'int16'、'int32' - 符号付き整数
  • 'uint8'、'uint16'、'uint32' - 符号なし整数
  • 'boolean' - ブール値
  • 'string' - 文字列
  • SchemaClass - ネストされた Schema
  • [SchemaClass] - Schema の配列
  • {map: SchemaClass} - Schema のマップ

@gameField に関する重要なルール

ルール 1:@gameField は最後のデコレーターにする
// CORRECT order
@range([0, 1])           // 1st
@tweakable('number')     // 2nd
@gameField('float32')    // 3rd - LAST
power = 1.0;

// WRONG order
@gameField('float32')    // Can't be first
@range([0, 1])
power = 1.0;

理由: デコレーターは下から上へ実行されます。@gameField は最終的なプロパティをラップする必要があります。

ルール 2:コレクションを初期化する
// CORRECT
@gameField([Thruster])
thrusters = new ArraySchema<Thruster>();

@gameField({map: Spaceship})
ships = new MapSchema<Spaceship>();

// WRONG
@gameField([Thruster])
thrusters: ArraySchema<Thruster>;  // Not initialized!

理由: Colyseus は undefined ではなく、インスタンスを必要とします。

ルール 3:正しい型を使う
// CORRECT
@gameField('float32') speed = 0;      // 32-bit float
@gameField('int16') count = 0;        // 16-bit int

// WRONG
@gameField('number') speed = 0;       // No 'number' type
@gameField('float') speed = 0;        // No 'float' type (use float32/float64)

理由: Colyseus のスキーマ型は明示的に指定します。

ルール 4:すべてを同期しない
// Only sync state that clients need
@gameField('float32') health = 100;   // ✅ Clients need this
@gameField('float32') damage = 10;    // ❌ Internal calculation, don't sync

// Derive internally
get damage() {
  return this.weapon.baseDamage * this.effectiveness;
}

理由: 同期が少ないほど、パフォーマンスが向上します。

2 階層のネストされた ArraySchema

別の ArraySchema<Parent> のアイテム内にある ArraySchema<Child> は正しく同期されます。2 階層の深さに特別な対応は不要で、中間階層のスキーマに通常の @gameField([Child]) を指定するだけです。実例:modules/core/src/ship/armor.ts の Armor.armorPlates[i].layers[j].health。

  • colyseus-events は両方のインデックスを含むリーフイベント(例:/armor/armorPlates/3/layers/0/health)を発行し、EventEmitter2 のワイルドカードリスナー(/armor/armorPlates/*/layers/*/health、区切り文字 '/')がこれに一致します。
  • wireEvents の後に新しいネストされた子を追加すると、自動的にイベントが配線されます。

modules/core/test/armor-layers-sync.spec.ts でテストされています。

Float32 の精度に関する注意点

問題: JavaScript の数値は 64 ビットですが、Colyseus の float32 は 32 ビットです

@gameField('float32') speed = 123.456789;
console.log(speed);  // 123.46 (precision lost!)

解決策: テストでは toBeCloseTo() を使います

// WRONG
expect(ship.speed).toBe(123.456789);

// CORRECT
expect(ship.speed).toBeCloseTo(123.46, 1);

状態と非状態

状態(同期対象):

class ShipState extends Schema {
  @gameField('float32') health = 100;   // Synced
  @gameField(Reactor) reactor!: Reactor;  // Synced
}

非状態(サーバーのみ):

class ShipManager {
  updateRate = 60;                      // Not synced
  lastUpdate = Date.now();              // Not synced

  update(dt: number) {
    this.state.health -= 10 * dt;       // Modify state → syncs
  }
}

ルール: @gameField を持つ Schema クラスのみが同期されます。通常のプロパティは同期されません。

コマンド:クライアント → サーバー

次の 2 つのパターンがあります。

1. JSON Pointer(動的)

クライアント:

room.send({
  type: '/Spaceship/ship-1/reactor/power',
  value: 0.8
});

サーバー(ShipRoom で自動処理):

// No code needed - JSON Pointer auto-applies to state

使用する場面: 単純なプロパティ更新、GM インターフェース、デバッグ

2. 型付きコマンド(最適化)

定義(core 内):

export const setShieldPower: StateCommand<number, ShipState, void> = {
  cmdName: 'setShieldPower',
  setValue: (state, value) => {
    state.shield.power = value;
  }
};

サーバー(ルームに登録):

this.onMessage(setShieldPower.cmdName, cmdReceiver(this.manager, setShieldPower));

クライアント(送信):

const send = cmdSender(room, setShieldPower, undefined);
send(0.5);  // Type-safe!

使用する場面: 高頻度のコマンド、複雑な検証、型安全性

ルームのアーキテクチャ

AdminRoom

目的: ゲーム管理、マップ選択、開始/停止

状態: AdminState(SpaceState を持ちます)

クライアント: GM インターフェース、管理パネル

コマンド: startGame、stopGame、loadMap

SpaceRoom

目的: 宇宙レベルのゲームプレイ、物理シミュレーション

状態: SpaceState(すべての宇宙オブジェクト)

クライアント: 戦術ディスプレイ、概要画面

管理者: GameManager、SpaceManager

ShipRoom(船ごと)

目的: 個々の船の操作

状態: ShipState(船のシステム)

クライアント: 船の各ステーション(兵器、機関など)

roomId: shipId と同じです(ship-0、ship-1 など)

コマンド: JSON Pointer のみ(柔軟性のため)

状態同期のパターン

パターン 1:サーバーが変更し、クライアントが反応

サーバー:

class ShieldManager {
  update(dt: number) {
    // Modify state → auto-syncs
    this.state.shield.strength += rechargeRate * dt;
  }
}

クライアント:

// Listen for changes
ship.state.shield.onChange(() => {
  updateUI(ship.state.shield.strength);
});
パターン 2:クライアントがコマンドを送信し、サーバーが検証

クライアント:

// User adjusts power slider
powerSlider.on('change', (value) => {
  room.send({type: '/Spaceship/ship-0/reactor/power', value});
});

サーバー:

// Receives command, validates, applies
this.onMessage((client, message) => {
  const value = clamp(0, 1, message.value);  // Validate
  applyJsonPointer(this.state, message.type, value);  // Apply
  // Auto-syncs to all clients
});

クライアント:

// UI updates automatically from synced state
ship.state.reactor.listen('power', (value) => {
  powerSlider.value = value;
});
パターン 3:マルチプレイヤーのテスト

ShipTestHarness を使う:

import { ShipTestHarness } from './ship-test-harness';

test('client receives server state updates', async () => {
  const harness = new ShipTestHarness();
  await harness.connect();

  // Server modifies
  harness.shipManager.state.shield.strength = 750;

  // Wait for sync
  await harness.waitForSync();

  // Client receives
  expect(harness.shipDriver.state.shield.strength).toBe(750);

  await harness.cleanup();
});

MultiClientDriver を使う:

import { MultiClientDriver } from '@starwards/server/test/multi-client-driver';

test('multiple clients see same state', async () => {
  const driver = new MultiClientDriver();
  await driver.start();

  const [c1, c2] = await Promise.all([
    driver.joinShip('ship-1'),
    driver.joinShip('ship-1')
  ]);

  // Modify server state
  driver.getShipManager('ship-1').state.shield.strength = 800;
  await driver.waitForSync();

  // Both clients updated
  expect(c1.state.shield.strength).toBe(800);
  expect(c2.state.shield.strength).toBe(800);

  await driver.cleanup();
});

API の全容については、docs/testing/UTILITIES.md を参照してください。

Colyseus でよくある落とし穴

落とし穴 1:@gameField を使わずに状態を変更する
// WRONG - doesn't sync
class Shield {
  strength = 1000;  // No decorator
}

// CORRECT - syncs
class Shield extends Schema {
  @gameField('float32') strength = 1000;
}
落とし穴 2:プロパティではなくオブジェクトを設定する
// WRONG - breaks references
state.velocity = {x: 10, y: 0};

// CORRECT - update properties
state.velocity.setValue({x: 10, y: 0});
// Or:
state.velocity.x = 10;
state.velocity.y = 0;

理由: Colyseus はオブジェクトの置き換えではなく、プロパティの変更を追跡します。

落とし穴 3:await harness.waitForSync() を忘れる
// WRONG - client not updated yet
harness.shipManager.state.health = 50;
expect(harness.shipDriver.state.health).toBe(50);  // FAILS

// CORRECT - wait for replication
harness.shipManager.state.health = 50;
await harness.waitForSync();
expect(harness.shipDriver.state.health).toBe(50);  // PASSES
落とし穴 4:クライアント側のロジックで状態を使う
// WRONG - client shouldn't have business logic
if (ship.state.health < 50) {
  ship.state.broken = true;  // Don't modify from client
}

// CORRECT - send command, server decides
if (ship.state.health < 50) {
  room.send({type: 'checkBroken'});  // Server validates & applies
}

理由: サーバーが権威を持ちます。クライアントは表示専用です。

落とし穴 5:テストでの Float32 の精度
// WRONG - exact match fails
expect(ship.speed).toBe(123.456789);

// CORRECT - close enough
expect(ship.speed).toBeCloseTo(123.46, 1);
落とし穴 6:テストハーネスをクリーンアップしない
// WRONG - leaves connections open
test('something', async () => {
  const harness = new ShipTestHarness();
  await harness.connect();
  // Test code
});  // MISSING cleanup()!

// CORRECT
test('something', async () => {
  const harness = new ShipTestHarness();
  await harness.connect();
  // Test code
  await harness.cleanup();  // Clean up
});

Colyseus の問題をデバッグする

1. Colyseus Monitor
http://localhost:2567/colyseus-monitor
Login: admin / admin

確認できる項目:

  • アクティブなルーム
  • 接続中のクライアント
  • 状態ツリー(ライブ値)
2. Chrome DevTools の Network タブ

WebSocket メッセージ:

  1. DevTools を開きます(F12)
  2. Network タブ → WS フィルター
  3. 接続をクリックします
  4. Messages を確認します:送信されたコマンド、受信したパッチ
3. 状態のログ記録

サーバー:

console.log('[SERVER] Shield strength:', this.state.shield.strength);

クライアント:

console.log('[CLIENT] Shield strength:', ship.state.shield.strength);

値を比較して同期の問題を特定します。

4. JSON Pointer のパス検証

パスをテスト:

const path = '/Spaceship/ship-1/shield/power';
const obj = resolveJsonPointer(state, path);
console.log('Resolved:', obj);  // Should not be undefined

パフォーマンスに関する考慮事項

同期頻度を最小限に抑える
// WRONG - syncs 60 times/sec
update(dt: number) {
  this.state.position.x += velocity.x * dt;
  this.state.position.y += velocity.y * dt;
}

// BETTER - sync only when significant change
update(dt: number) {
  const newX = this.state.position.x + velocity.x * dt;
  const newY = this.state.position.y + velocity.y * dt;

  if (Math.abs(newX - this.state.position.x) > 0.1) {
    this.state.position.x = newX;
  }
  if (Math.abs(newY - this.state.position.y) > 0.1) {
    this.state.position.y = newY;
  }
}

ただし: Starwards の更新はすでに最適化されています。時期尚早な最適化は避けてください。

適切な型を使う
// WRONG - wastes bandwidth
@gameField('float64') health = 100;  // 8 bytes

// CORRECT - sufficient precision
@gameField('float32') health = 100;  // 4 bytes
コマンドをまとめる
// WRONG - multiple round trips
room.send({type: '/Spaceship/ship-0/reactor/power', value: 0.8});
room.send({type: '/Spaceship/ship-0/thrusters/0/enabled', value: true});
room.send({type: '/Spaceship/ship-0/thrusters/1/enabled', value: true});

// BETTER - single batch command
room.send('batchUpdate', {
  '/reactor/power': 0.8,
  '/thrusters/0/enabled': true,
  '/thrusters/1/enabled': true
});

ただし: 実際にボトルネックになっている場合に限ります。通常の使用では JSON Pointer で問題ありません。

他のスキルとの連携

  • starwards-tdd - ハーネスを使って状態の同期をテストします
  • starwards-debugging - ツールを使って同期の問題をデバッグします
  • starwards-verification - マルチプレイヤーのシナリオを検証します

クイックリファレンス

タスクパターン
同期するプロパティを追加する@gameField('type') prop = value
ネストされた Schema@gameField(Class) obj = new Class()
配列@gameField([Class]) arr = new ArraySchema()
マップ@gameField({map: Class}) map = new MapSchema()
コマンドを送信する(クライアント)room.send({type: '/path', value})
変更をリッスンする(クライアント)state.onChange(() => {})
同期をテストするawait harness.waitForSync()
状態をデバッグするColyseus Monitor(ポート 2567)

要点

覚えておくこと:

  1. @gameField は最後のデコレーターにする必要があります
  2. 状態が唯一の信頼できる情報源です(サーバーが権威を持ちます)
  3. コマンドはクライアント → サーバー、パッチはサーバー → クライアントに送られます
  4. マルチプレイヤーのテストにはハーネスを使います
  5. Float32 では精度が失われます(toBeCloseTo を使います)
  6. テスト接続をクリーンアップします(await cleanup())
  7. 判断に迷ったら Colyseus Monitor を確認します

コメント