最終確認日

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

概要

チャットプログラムを作る

まずは読む

  • 片方がサーバー、片方がクライアントとなる
  • 3章で使った TranportTCP.csPacketQueue.cs が必要になる
  • チャット参加後は 毎フレーム呼ばれるUpdate 関数で、送信と受信の処理を退出するまで繰り返す
  • ChatState を用意しておくといい
  • ユーザーからのインプットがあったときに何かを実行するのではなく、Stateを変更して、毎フレームそれを監視してアクションを行う感じ
    • 退出ボタンを押す
      • この時は ChatState のみを変更する
    • 次のUpdate関数が呼ばれるときに実際に退出処理をしている
    • UIイベントで直接重い処理をせず、状態遷移だけを行うことで、ロジックの整合性を保ち、処理の分離を実現している。
    • GUI関数(ChattingGUI())では m_state の変更にとどめ、重い/危険な処理は Update() 側に委ねる設計。
    • Swiftだとユーザーからのイベント駆動で処理をすることが多いので、ボタン押下で直接処理を呼び出しちゃいそうだけど、頭を切り替えないとダメね。

置き換えてみる

画面

  • Robby.tscn 最初のタイトル画面
    • CreateRoomButton ホストを選択して部屋を作成する
    • JoinRoomButton クライアントとして部屋に入る
      • IPアドレスを入力する
      • エラーハンドリングをする
  • ChatRoom.tscn チャットルーム画面
    • LeaveButton 退出ボタン
    • SendMessageButton メッセージを送信ボタン
    • MessageField メッセージを表示するテキストフィールド

通信

  • TransportTCP.tscn 通信ライブラリ(3章で作った)

  • ~~`PacketQueue.gd` 送受信するデータを保存するキュー(3章でつくった)

    • 使わなかった
  • Chat.tscn チャットプログラム

    • ChatState を定義する
    • ChatRoom から送信/受信処理を呼び出す
  • これらは Autoload で設定する必要がありそう。

  • Godotでモバイル向けの環境設定をする

  • 画像を使ったボタンの配置方法を忘れた。

  • call_deferred("emit_signal", "data_received", data) のようにバックグラウンドスレッドからシグナルを呼んでメインスレッドに渡して実行する場合には call_deffered をしないとエラーになる。

  • LineEdit (UIKitでいうとTextField ) と TextEdit (UIKitでいうと TextView )の2種類ある

このPoll関数がないとやっぱりうまくいかない。書く必要がある。

func _process(delta):
    if _socket == null or _socket.get_status() == StreamPeerTCP.STATUS_CONNECTED:
        return
    _socket.poll()  # ここが必要(クライアント側だけ)
    if _socket.get_status() == StreamPeerTCP.STATUS_CONNECTED:
        print("接続完了!")

チャットルーム画面

  • 自分が送る内容は送った瞬間そのまま表示して良い?
    • それとも届いた時間をタイムスタンプで返してもらって前後関係を正しくしたほうが良い?
  • チャット欄に追加するタイミングはメッセージを送信する時とメッセージを受信した時
    • 送信した内容を自分自身のチャット欄に表示するのを忘れないように。
  • 相手が切断したのを検知したい
    • 定期的な"PING/PONG"通信を行うといいらしい(ChatGPTいわく)
  • 複数人同時にはむずそう?接続したと思ったけど1人としかできてない

できた

あんまりキレイとは言えないかもだけど、とりあえずできた。

気をつけた点

  • TransportTCP の処理は Chat から呼び出すようにした
  • ChatRoomRobby からは Chat を呼び出して、直接 TransportTCP は呼ばないようにした
  • poll() でクライアントのステータスを更新するのが大切
  • 退出メッセージを送ってから退出するようにした。

transport_tcp.gd

extends Node

# 通信イベント通知
signal connection_success
signal connection_failed
signal disconnected
signal join_new_user
signal data_received(data: PackedByteArray)

# 状態フラグ
var is_server := false
var is_connected := false

# 内部メンバ
var _listener: TCPServer
var _socket: StreamPeerTCP
var _thread := Thread.new()
var _thread_loop := false

# 送受信バッファ(ゲームスレッドと通信スレッド間のやりとり)
var _send_queue: Array[PackedByteArray] = []
var _recv_queue: Array[PackedByteArray] = []

const MTU := 1400

func _ready():
    _listener = TCPServer.new()
    _socket = StreamPeerTCP.new()


func _process(delta):
    # クライアント側のみの処理
    if _socket == null or is_server:
        return
    
    if _socket.get_status() == StreamPeerTCP.STATUS_CONNECTED:
        return
    # ステータスが反映される
    _socket.poll()
    
    match _socket.get_status():
        StreamPeerTCP.STATUS_CONNECTED:
            if not is_connected:
                print("接続完了!")
                is_connected = true
                _start_thread()
                connection_success.emit()
        StreamPeerTCP.STATUS_NONE:
            print("接続されていません")
            is_connected = false
        StreamPeerTCP.STATUS_ERROR:
            print("接続エラーが発生しました")
            connection_failed.emit()
            _socket = null
            


# サーバーを起動する
func start_server(port: int) -> bool:
    print("サーバーを作成します.")
    if _listener.is_listening():
        return false

    var err := _listener.listen(port)
    if err != OK:
        push_error("サーバー起動失敗: %s" % err)
        return false

    is_server = true
    _start_thread()
    return true


# サーバーを止める
func stop_server():
    _thread_loop = false
    if _thread.is_alive():
        _thread.wait_to_finish()

    if _listener.is_listening():
        _listener.stop()

    close_connection()
    is_server = false


# ホストに接続する
func connect_to_host(address: String, port: int) -> bool:
    _socket = StreamPeerTCP.new()
    var err := _socket.connect_to_host(address, port)
    if err != OK:
        is_connected = false
        connection_failed.emit()
        return false

    return true


# 接続を解除する
func close_connection():
    if is_connected and _socket.get_status() == StreamPeerTCP.STATUS_CONNECTED:
        _socket.disconnect_from_host()

    is_connected = false
    _thread_loop = false
    _thread = Thread.new()
    # 前回のデータが残る場合がある.
    _send_queue.clear()
    _recv_queue.clear()
    disconnected.emit()


func send(data: PackedByteArray):
    _send_queue.append(data)


func _start_thread():
    if _thread.is_alive():
        _thread.wait_to_finish()

    _thread_loop = true
    _thread.start(_dispatch)


func _dispatch():
    print("Dispatch thread started.")

    while _thread_loop:
        if is_server:
            _accept_new_client()

        if _check_socket_connected():
            _process_send()
            _process_receive()
            

        OS.delay_msec(20)

    print("Dispatch thread ended.")


# サーバー側の処理.
# クライアントの待ち受けをする.
func _accept_new_client():
    if _listener.is_connection_available():
        _socket = _listener.take_connection()

        if _socket.get_status() == StreamPeerTCP.STATUS_CONNECTED:
            is_connected = true
            call_deferred("emit_signal", "join_new_user")
            print("クライアント接続成功")
        else:
            print("クライアント接続失敗 状態:")


func _check_socket_connected() -> bool:
    if is_connected and _socket and _socket.get_status() == StreamPeerTCP.STATUS_CONNECTED:
        return true
    return false


func _process_send():
    while not _send_queue.is_empty():
        var data = _send_queue.pop_front()
        _socket.put_data(data)


func _process_receive():
    while _socket.get_available_bytes() > 0:
        var result = _socket.get_partial_data(MTU)
        var error = result[0]
        var data: PackedByteArray = result[1]

        if error != OK:
            close_connection()
            break

        _recv_queue.append(data)
        call_deferred("emit_signal", "data_received", data)

## Public


func receive() -> PackedByteArray:
    if _recv_queue.is_empty():
        return PackedByteArray()
    return _recv_queue.pop_front()

chat.gd

extends Node
# Autoload: Chat
signal received_message(text: String)

enum State {
    HOST_TYPE_SELECT = 0,
    CHATTING,
    LEAVE,
    ERROR
}

var current_state: State = State.HOST_TYPE_SELECT
var _has_left := false

## Life cycle

func _ready() -> void:
    TransportTCP.data_received.connect(_on_data_received)
    TransportTCP.disconnected.connect(_on_disconnected_server)
    TransportTCP.connection_success.connect(_on_connection_success)
    TransportTCP.join_new_user.connect(_on_join_new_user)


func _process(delta: float) -> void:
    match current_state:
        State.LEAVE:
            _update_leave()

## Private

func _update_leave():
    if TransportTCP.is_server:
        TransportTCP.stop_server()
    else:
        TransportTCP.close_connection()


## Public

func leave():
    if _has_left: 
        return
    _has_left = true
    var message = "hogeが退出しました"
    TransportTCP.send(message.to_utf8_buffer())
    await get_tree().create_timer(0.25).timeout
    current_state = State.LEAVE


## Signal

func _on_disconnected_server():
    current_state = State.HOST_TYPE_SELECT
    _has_left = false
    # サーバー側/クライアント側問わずに自分自身はロビーに戻る
    get_tree().change_scene_to_file("res://scripts/screens/robby/robby.tscn")



func _on_data_received(data: PackedByteArray):
    var message = data.get_string_from_utf8()
    received_message.emit(message)


func _on_connection_success():
    print("ルームに入ります")
    var message = "hogeが参加しました。"
    TransportTCP.send(message.to_utf8_buffer())


func _on_join_new_user():
    print("新しいユーザーが参加しました")

robby.gd

extends Node2D

@onready var create_room_button = %CreateRoomButton
@onready var join_room_button = %JoinRoomButton
@onready var join_view = %JoinView
@onready var join_button = %JoinButton
@onready var ip_address_edit = %IPAddressEdit
@onready var error_popup_view = %ErrorPopupView
@onready var close_join_view_button = %CloseJoinViewButton

const PORT = 12345


## Life cycle


func _ready() -> void:
    create_room_button.pressed.connect(_on_pressed_create_room_button)
    join_room_button.pressed.connect(_on_pressed_join_room_button)
    join_button.pressed.connect(_on_pressed_join_button)
    close_join_view_button.pressed.connect(_on_pressed_close_join_view_button)
    
    TransportTCP.connection_success.connect(_on_connection_success)
    TransportTCP.connection_failed.connect(_on_connection_failed)
    log_local_ip()
    Chat.current_state = Chat.State.HOST_TYPE_SELECT


func log_local_ip():
    var local_ips = IP.get_local_addresses()
    for ip in local_ips:
        if ip.begins_with("192.") or ip.begins_with("10.") or ip.begins_with("172."):
            print("自分のIPアドレス: ", ip)	

## シグナル

func _on_pressed_create_room_button():
    var is_start_server = TransportTCP.start_server(PORT)
    if is_start_server:
        get_tree().change_scene_to_file("res://scripts/screens/chat_room/chat_room.tscn")


func _on_pressed_join_room_button():
    join_view.visible = true


func _on_pressed_join_button():
    print("IPAddress: ", ip_address_edit.text)
    var ip_address = ip_address_edit.text
    
    TransportTCP.connect_to_host(ip_address, PORT)


func _on_connection_success():
    get_tree().change_scene_to_file("res://scripts/screens/chat_room/chat_room.tscn")


func _on_connection_failed():
    error_popup_view.show_error("接続に失敗しました", "接続が失敗しました。")


func _on_pressed_close_join_view_button():
    join_view.visible = false

chat_room.gd

extends Node2D

@onready var leave_button = %LeaveButton
@onready var chat_message_view = %ChatMessageView
@onready var chat_message_edit = %ChatMessageEdit


## Life cycle


func _ready() -> void:
    leave_button.pressed.connect(_on_pressed_leave_button)
    chat_message_edit.text_submitted.connect(_on_text_submitted_chat_message_edit)
    add_message("チャットルームへようこそ")
    Chat.current_state = Chat.State.CHATTING
    Chat.received_message.connect(_on_received_message)


## Method

func add_message(new_message: String):
    if len(chat_message_view.text) == 0:
        chat_message_view.text = new_message
    else:
        chat_message_view.text = chat_message_view.text + "\n" + new_message


## Signal


func _on_pressed_leave_button():
    Chat.leave()	


func _on_text_submitted_chat_message_edit(new_text: String):
    if len(new_text) == 0:
        return
    
    Chat.send(new_text)
    # ちゃんと届いているか確認したほうがよい??
    add_message(new_text)
    # 文字列をリセットする
    chat_message_edit.text = ""


func _on_received_message(text: String):
    add_message(text)

次へ

とりあえずオンラインゲームのしくみ-Unityで覚えるネットワークプログラミングの本の4章部分はできたんじゃなかろうかと思うので、次は5章を読み始める。

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

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