最終確認日

オンラインゲームの仕組みの本ー第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に置き換えながら読む

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