最終確認日

online game book 3 godot memo

オンラインゲームのしくみ-Unityで覚えるネットワークプログラミングの第3章「通信プログラムを作ってみよう」をGodot+GDScriptに置き換えながら読む。

3.1 のメモ

TCPソケット通信

  • ソケット
  • ソケット通信
  • TCPソケット通信は携帯電話で電話をかけるまでに行う手続きと似ている。
  • Unityにおける new Socket 部分のサンプルコードをGodotで書いてみる。
    • TCPServerリスニングソケット。待ち受けを行う専用のソケット
    • StreamPeerTCPTCPソケットのクライアント側を抽象化したクラス
  • Socket クラスの Accept 関数はクライアントからの接続要求があるまで処理をブロッキングするため、Poll関数を使って、データを受信したときだけ実行するようにする必要がある。
    • Poll関数Godotの場合には必要がない?(デフォルトで非ブロッキングため)
    • でも Godot 4.x にはPoll関数は存在している
    • クライアント側に Poll関数を使わないと CONNECTING から CONNECTED に変わらなかった
  • クライアント側の実装 p40
    • Godotでは NoDelaySendBufferSize にあたるものは用意されていない。
  • receive 関数を呼び出すまでは、データはシステム側のバッファに保存され続ける
    • つまり以下のコードで send_data() を2回呼び出してから reveice_data() を実行すると、2つのテキストが取り出される。
  • 通信の切断
    • Godotでは disconnect_from_host()socket への null 代入を使う
    • 待ち受けの終了は server.stop()

p38,39のコードをGodotに書き換え

extends Node
class_name MyTCPServer

var server: TCPServer
var socket: StreamPeerTCP = null

func _process(delta: float) -> void:
    accept_client()


func start_listner(port: int):
    server = TCPServer.new()
    
    # 待ち受けを開始する
    var error := server.listen(port)

    if error != OK:
        print("TCPサーバー起動失敗:", error)
    else:
        print("OK!")


func accept_client():
    if server != null && server.is_connection_available():
        socket = server.take_connection() # 接続ソケットを取得
        print("クライアント接続!",socket.get_connected_host())
        # このpeerは StreamPeerTCP のインスタンス
        


func receive_data():
    if socket == null || socket.get_status() != StreamPeerTCP.STATUS_CONNECTED:
        print("受け取り失敗", socket)
        return
    var available := socket.get_available_bytes()
    if available > 0:
        var result := socket.get_data(available)
        # 0にはエラーが入る
        var error = result[0]
        var buffer = result[1]

        if error == OK and buffer.size() > 0:
            var message = buffer.get_string_from_utf8()
            print("クライアントからのメッセージ:", message)
        else:
            print("受信エラー:", error)
    else:
        print("available:", available)

クライアント側

extends Node
class_name MyTCPServer

var server: TCPServer
var socket: StreamPeerTCP = null

func _process(delta: float) -> void:
    accept_client()


func start_listner(port: int):
    server = TCPServer.new()
    
    # 待ち受けを開始する
    var error := server.listen(port)

    if error != OK:
        print("TCPサーバー起動失敗:", error)
    else:
        print("OK!")


func accept_client():
    if server != null && server.is_connection_available():
        socket = server.take_connection() # 接続ソケットを取得
        print("クライアント接続!",socket.get_connected_host())
        # このpeerは StreamPeerTCP のインスタンス
        


func receive_data():
    if socket == null || socket.get_status() != StreamPeerTCP.STATUS_CONNECTED:
        print("受け取り失敗", socket)
        return
    var available := socket.get_available_bytes()
    if available > 0:
        var result := socket.get_data(available)
        # 0にはエラーが入る
        var result_code = result[0]
        var buffer = result[1]

        if result_code == OK and buffer.size() > 0:
            var message = buffer.get_string_from_utf8()
            print("クライアントからのメッセージ:", message)
        else:
            print("受信エラー:", result_code)

サーバーの待ち受け終了。

func stop_listener():
    # 待ち受けを終了する
    if server != null:
        server.stop()
        server = null
        socket = null

    print("End server communication.")

クライアントの切断。

func disconnect_from_server():
    if socket == null or socket.get_status() != StreamPeerTCP.STATUS_CONNECTED:
        return
    socket.disconnect_from_host()
    socket = null
    print("End client communication.")

こんな感じで確認できる物を作った。

オンラインゲームの仕組みの本ー第3章をGodotに置き換えながら読む-1750235289843

UDPソケット通信

  • UDPは接続処理を行わずに通信することができる。
  • ソケットを生成して使用するポート番号を指定するだけ。
  • 1つのソケットで複数の通信先に送信ができる。
  • 受信時にはIPアドレスポート番号を取得して送信元を識別
    • PacketPeerUDPでは udp.get_packet_ip()udp.get_packet_port()

受け取り側

extends Node
# 便宜上サーバーという名前にする
class_name MyUDPServer

const IP_ADDRESS = "127.0.0.1"
const PORT = 12346

var udp: PacketPeerUDP = null

func _ready() -> void:
    udp = PacketPeerUDP.new()
    udp.bind(PORT)


func _process(delta):
    if udp == null:
        return
    # 常に監視しておく
    if udp.get_available_packet_count() > 0:
        var packet := udp.get_packet()
        var sender_ip := udp.get_packet_ip()
        var sender_port := udp.get_packet_port()
        var message := packet.get_string_from_utf8()
        print("受信:", message, " ← ", sender_ip, ":", sender_port)

送信側

extends Node
class_name MyUDPClient

func send_message(text: String, address: String, port: int):
    # サーバーへ接続
    var udp = PacketPeerUDP.new()
    udp.bind(port)
    udp.set_dest_address(address, port)
    # メッセージを送信
    var data = text.to_utf8_buffer()
    udp.put_packet(data)
    # 切断
    udp.close()
    print("End client communication.")

ボタンを押してデータを送信

extends Node2D

@export var server: MyUDPServer
@export var client: MyUDPClient

@onready var send_button = %SendButton


func _ready() -> void:
    send_button.pressed.connect(_on_pressed_send_button)


func _on_pressed_send_button():
    if server.udp == null:
        return
    var address = server.IP_ADDRESS
    var port = server.PORT
    client.send_message("Hello, this is client.", address, port)

Godot におけるソケット通信のためのクラス

Godotクラス名 UnityでのSocket種別に対応 用途
StreamPeerTCP SocketType.Stream TCP通信(ストリーム型)
PacketPeerUDP SocketType.Dgram UDP通信(パケット型)
WebSocketPeer WebSocket ブラウザ対応や中継用のソケット

3.2のメモ


#ChatGPTより引用

データの種類 エンディアン考慮必要? 備考
テキスト(UTF-8) ❌ 不要 バイト列のままでOK
整数(int32等) ✅ 必要 ネットワーク標準はビッグ
float(実数) ✅ 必要 同上(ビット順も含め)
JSONやXML ❌ 不要 テキスト形式なので関係ない
自作バイナリ形式 ✅ 必要 プロトコル設計時は必須
[[Notes/Godot Godot]]での対策
func int_to_big_endian_bytes(val: int) -> PackedByteArray:
    var bytes := PackedByteArray()
    bytes.append((val >> 24) & 0xFF)
    bytes.append((val >> 16) & 0xFF)
    bytes.append((val >> 8) & 0xFF)
    bytes.append(val & 0xFF)
    return bytes

スコアなどを送信するとき

# 変換が必要
socket.put_data(int_to_big_endian_bytes(100))

大変難しいおはなし。

3.3のメモ

簡単な通信ライブラリを作る

  • Socket クラスのインスタンスをゲームプログラムから見えないようにする
    • そうそうこれがやりたかった。
  • 通信ライブラリが処理を行うもの
    • 待ち受け開始 (UDPは受信ポートの指定のみ) / 待ち受け終了
    • 接続 / 切断
    • 送信 / 受信
  • 発生するイベント
    • 接続
    • 切断
    • 送信エラー
    • 受信エラー
      • これらはGodotではシグナルを使ってライブラリからゲームプログラム側へ通知するとよさそう
  • 通信処理をゲーム側のスレッド(メインスレッド)に負荷をかけずに別のスレッドで行ってみよう
  • メインスレッドと通信スレッドで共有するためのキューを用意する
  • MemoryStreamクラスはGDScriptではないので、代わりに StreamPeerBufferPackedByteArrayを使うとよさそう?
  • 通信スレッドは5msごとに実行されるように設定した。
    • whileの内部でThread.Sleep(5) を呼び出すことで5ms間隔で実行している
  • MonoBehaviour を継承した TransportTCPクラスとTransportUDP クラスをそれぞれ作る
    • GDScriptでは Node を継承したクラスで良さそう。
    • しかしインタフェースがないので難しいぞ。

サポートファイル群

.
├── NetworkLibrary
│   ├── Assembly-CSharp-vs.csproj
│   ├── Assembly-CSharp.csproj
│   ├── Assets
│   │   ├── Prefab
│   │   │   ├── TransportTCP.prefab
│   │   │   ├── TransportTCP.prefab.meta
│   │   │   ├── TransportUDP.prefab
│   │   │   └── TransportUDP.prefab.meta
│   │   ├── Prefab.meta
│   │   ├── Scene
│   │   │   ├── NetworkLibrary.unity
│   │   │   └── NetworkLibrary.unity.meta
│   │   ├── Scene.meta
│   │   ├── Script ✅ ここに入っている!
│   │   │   ├── LibrarySample.cs
│   │   │   ├── LibrarySample.cs.meta
│   │   │   ├── NetworkDef.cs
│   │   │   ├── NetworkDef.cs.meta
│   │   │   ├── PacketQueue.cs
│   │   │   ├── PacketQueue.cs.meta
│   │   │   ├── TransportTCP.cs
│   │   │   ├── TransportTCP.cs.meta
│   │   │   ├── TransportUDP.cs
│   │   │   └── TransportUDP.cs.meta
│   │   └── Script.meta
│   ├── bin

GDScriptにはインターフェースはないのでabstractクラスを作る形にする

待ち受け開始/待ち受け終了

extends Node
class_name ClientInterface

# 接続を開始する(例: address = "127.0.0.1", port = 1234)
func connect_to_server(address: String, port: int) -> bool:
    assert(false, "connect_to_server() must be overridden")
    return false

# 接続を切断する
func disconnect_from_server() -> bool:
    assert(false, "disconnect_from_server() must be overridden")
    return false

接続/切断

extends Node
class_name ClientInterface

# 接続開始
func connect(address: String, port: int) -> bool:
    assert(false, "connect() must be overridden")
    return false

# 接続終了
func disconnect() -> bool:
    assert(false, "disconnect() must be overridden")
    return false

送信/受信

extends Node
class_name TransportInterface

# データ送信
func send(data: PackedByteArray, size: int) -> int:
    assert(false, "send() must be implemented by subclass")
    return -1

# データ受信
func receive(buffer: PackedByteArray, size: int) -> int:
    assert(false, "receive() must be implemented by subclass")
    return -1

イベント定義

enum NetEventType {
    CONNECT = 0,
    DISCONNECT,
    SEND_ERROR,
    RECEIVE_ERROR
}

イベント結果

enum NetEventResult {
    Failure = -1,
    Success = 0
}

通知するイベントの状態

class_name NetEventState

var type: int
var result: int

イベント通知のデリゲート定義(Signal(Godot))

signal net_event(state: NetEventState)

# 発火
net_event.emit(state)

もしくはハンドラで

# 任意の関数をハンドラとして受け取る
func invoke_event(handler: Callable, state: NetEventState) -> void:
    if handler.is_valid():
        handler.call(state)

メインスレッドと通信スレッドで共有するためのパケットキュー

extends Node
class_name PacketQueueInterface

# パケットをキューに追加する
func enqueue(data: PackedByteArray, size: int) -> int:
    assert(false, "enqueue() must be implemented by subclass")
    return -1

# パケットをキューから取り出す
func dequeue(buffer: PackedByteArray, size: int) -> int:
    assert(false, "dequeue() must be implemented by subclass")
    return -1

p55 PacketQueue.csGDScriptで書き換え

extends Node
class_name PacketQueue

## 内部パケット情報構造
class PacketInfo:
    var offset: int
    var size: int

## 内部バッファとパケット管理
var _buffer := StreamPeerBuffer.new()
var _packet_infos: Array[PacketInfo] = []
var _write_offset := 0

## パケットを追加
func enqueue(data: PackedByteArray, size: int) -> int:
    var info := PacketInfo.new()
    info.offset = _write_offset
    info.size = size
    _packet_infos.append(info)

    _buffer.seek(_write_offset)
    _buffer.put_data(data.slice(0, size))
    _write_offset += size

    return size


## パケットを取り出す(取り出せたサイズを返す)
func dequeue() -> PackedByteArray:
    if _packet_infos.is_empty():
        return PackedByteArray()

    var info: PacketInfo = _packet_infos.pop_front()
    _buffer.seek(info.offset)
    var packet := _buffer.get_data(info.size)

    # 全パケット取り出し済みならリセット
    if _packet_infos.is_empty():
        clear()

    return packet


## キューを空にしてメモリを解放
func clear() -> void:
    _buffer.clear()
    _write_offset = 0

3.4のメモ

まとめ。

  • 3章を読んだだけではライブラリが完成しなかった。TransportUDP.csはでてきてもないぞ?
  • サポートページでサンプルコードを見つけるぞい。
    • あった。
    • サポートファイルがUnityプロジェクトまるごとなので該当ファイルを探すのが大変
    • ITransportインターフェースはいずこへ?!?!?!?
    • コメントはすごい書いてあるので丁寧だけど、あまりに本の内容と違いすぎるかも。

サンプルコードとあまりに違いすぎる問題があるが、ChatGPTに助けてもらいながらGodotで同様なライブラリを作ってみるぞい。

Godotへの移植

  • GDScriptにはインターフェースが存在しない
  • Godotのシーン(.tscn) を機能ごとに作り、Instantiate Node で紐づけるのが良さそう。
  • Node(Scene)単位で機能ごとに分離し、それらを動的に紐づける
  • Godotでプラグインを自作してみる

色々頑張ってみたあと。

  • インターフェースっぽく実装してみようと試みたけれど、非常に難しい。
  • extends を使ってひとつのクラスのみしか継承できない。
  • Node をつけたり外したりして、テスト用のNodeを作れるとベストなわけだ。
    • ってことはやっぱりいまのであってるかな
  • う〜ん、ちゃんとGodotらしいなら MultiplayerPeer とかをラップしたライブラリを作るほうがいいね
  • とりあえず本を読み進めていきたいので、本の通りに書いて実装してみる。インターフェースとか設計は諦める。

第4章へ

ライブラリ化されたサンプルコードが本書に書かれていない部分が多くあったため、まずは先に進んでみることにする。

-> オンラインゲームの仕組みの本ー第4章をGodotに置き換えながら読む

サイトアイコン
公開日
更新日