Colyseus：リアルタイムマルチプレイヤー用フレームワーク

マルチプレイヤーのロビー機能やボードゲームの状態をリアルタイムでやりとり/管理できるライブラリが欲しい・・・！！！

と思い[Boardgame.io](https://qiita.com/hokutoh/items/15e76f4f430b5c1d33d8)に数年前出会い、とっても便利なライブラリだったのですが・・・
更新が止まってしまったのでReact 19.x以降に対応しなくなってしまいました。(もちろん自分で依存関係いじればいけるけど)

Socket.ioでスクラッチしようかと思いましたがColyseusというフレームワークがあるようなので触ってみました。

https://github.com/colyseus/colyseus

2025年現在では更新も頻繁だし、fork600弱、star6kと多いとは言えないけどスポンサーもついてて許容範囲だと思うので今度こそ見捨てられないことを祈る・・・


## インストール

createコマンドが用意されているので簡単
```
npm create colyseus-app@latest my-colyseus-server
cd my-colyseus-server
npm start
```
ある程度のひな形が作成される。
```
ecosystem.config.js
package.json
tsconfig.json
src
│  app.config.ts
│  index.ts
│
└─rooms
    │  MyRoom.ts
    │
    └─schema
            MyRoomState.ts
など
```

## 簡単な実装

基本的にひな形のものが既に動いちゃうので、毛をはやす程度にいじってみます。

サーバーで管理する値の定義(MyState等は自分の名前に変更して)
```typescript
// rooms/schema/MyState.ts

import { Schema, MapSchema, type } from "@colyseus/schema";

// 適当にプレイヤーに持たせたい値
export class Player extends Schema {
    @type("string") name: string = ""; // プレイヤー名
    @type("number") x: number = 0; // パラメータx
    @type("number") y: number = 0; // パラメータy
}

// ほかのなにかあれば
export class Hoge extends Schema{
  @type(["string"]) options = new ArraySchema<string>();
}

// ルーム全体の状態
export class MyState extends Schema {
    // プレイヤー情報
    @type({ map: Player })
    players = new MapSchema<Player>();
    // ほかの
    @type([Hoge])
    hoge = new ArraySchema<Hoge>();
}
```

ルームの実装
```typescript
// rooms/XMyRoom.ts
import { Room, Client } from "@colyseus/core";
import { MyState, Player, Hoge } from "./schema/MyState";
 
export class MyRoom extends Room {
    maxClients = 4;
    state = new MyState();
 
    // ルームが作られたとき
    onCreate(options) { }
 
    // 参加が押されたとき
    onJoin(client: Client, options: any) {
        const player = new Player();
        player.name = options.name; // 参加時に指定したoptionからプレイヤー名取得
        this.state.players.set(client.sessionId, player);
    }
 
    // 退室
    onLeave(client: Client, options: any) {
        this.state.players.delete(client.sessionId);
    }
 
    // ルーム破棄
    onDispose() { }

    // クライアントからの回答送信メッセージ
    this.onMessage("(任意のメッセージ名)submitAnswer", (client: Client, message: { answerIndex: number }) => {
      const player = this.state.players.get(client.sessionId);

      ...メッセージ受信時の処理...

      // 処理結果をクライアントにブロードキャスト
      this.broadcast("answerResult", { playerId: client.sessionId, correct: isCorrect });
  }

}
```
最後にapp.configに作ったRoomを定義する
```typescript
import config from "@colyseus/tools";
import { MyRoom } from "./MyRoom";
 
export default config({
    initializeGameServer: (gameServer) => {
        gameServer.define('my_room', MyRoom);
    },
});
```

## クライアント側

colyseus.jsライブラリを使用して任意のアプリでやり取りができるようになります。
今回はクライアントとはReactアプリなのでそのサンプルを記載します。

各画面で使いまわせるようにclient取得Classを用意しておきます。
もちろん各画面で直接clientをとってもいいので、人によっては不要です。
```typescript
import { useState, useEffect } from "react";
import { Client } from "colyseus.js";
import settings from "../settings";

const useColyseusClient = () => {
  const [client, setClient] = useState(null);

  useEffect(() => {
    const newClient = new Client(settings.COLYSEUS_SERVER_URL); // ColyseusサーバーのURL
    setClient(newClient);
  }, []);

  return client;
};

export default useColyseusClient;
```
App.jsで仕込んでおきます
```typescript
export const ColyseusContext = createContext({
  room: null,
  setRoom: (room) => {}
});
const App = () => {
  const [room, setRoom] = useState(null);

  return(
...
      <ColyseusContext.Provider value={{ room, setRoom }}>
        <がめん />
      </ColyseusContext.Provider>
...
  )
```


例えばロビー画面です。
```typescript
import React, { useState, useContext } from "react";
import { SceneContext, SceneNames, ColyseusContext } from "../App";
import Button from "../components/common/Button";
import Text from "../components/common/Text";
import useColyseusClient from "../hooks/useColyseusClient";
import settings from "../settings";

const LobbyScene = () => {
  const { changeScene } = useContext(SceneContext);
  const { setRoom } = useContext(ColyseusContext);
  const client = useColyseusClient();
  const [name, setName] = useState("");

  const handleJoin = async () => {
    if (!client || !name) return;
    try {
      // 参加時にオプションとして名前を渡す
      const room = await client.joinOrCreate(settings.ROOM_NAME, { name });
      // ※ 必要であれば、join後に "setName" メッセージを送信してもよい
      // room.send("setName", { name });
      setRoom(room);
      changeScene(SceneNames.GAME);
    } catch (error) {
      console.error("Failed to join room", error);
    }
  };

  return (
    <div>
      <Text text="Lobby Scene" />
      <input
        type="text"
        placeholder="Enter your name"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <Button label="Join Game" onClick={handleJoin} />
    </div>
  );
};

export default LobbyScene;
```

その後、ゲーム画面等でサーバーとのメッセージやり取りはこんな感じです。
```typescript

  useEffect(() => {
    // "answerResult" メッセージ受信処理
    room.onMessage("answerResult", (message) => {
      setFeedback(`Your answer was ${message.correct ? "correct" : "incorrect"}.`);
    });
  }, [room]);


  const handleAnswer = (index) => {
    if (!room) return;
    // サーバーにメッセージ送信
    room.send("submitAnswer", { answerIndex: index });
  };
  
  return(
...
        <Button key={idx} label={option} onClick={() => handleAnswer(idx)} />
...
  );
```