最終確認日

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

概要

ターン制ゲームを作る。三目並べを作ろう!

まずは読む

  • まずは1つの端末で2人で遊ぶ場合の処理を考えてみる
  • 次に2つの端末で2人で遊ぶ場合の処理を考えてみる
  • 離れた場所の場合は、○ × をつけた位置を届けるメッセンジャー君が必要
  • プレイヤーAの行動をメッセンジャー君がプレイヤーBに届けて書き写すイメージ
  • 通信相手の知らない情報を適切なタイミングで送受信し合う!
    • なにをいつ をはっきりさせればおk!
  • XY方向に番号を割り振って2次元でマス目番号を指定する
    • int型が縦横2つで合計サイズは 4バイト x 2方向 = 8バイト
    • 通信時は1次元の通し番号としてマス目番号に変換することで送信時のデータを小さくする
  • できるだけ少ない情報をできるだけ少ない回数で送信しよう!
  • 勝敗の判定はそれぞれの端末で別々に行っても必ず同じ結果になるので通信しなくて良い
  • サーバーとクライアントでそれぞれlocalMarkremoteMarkが逆転するようにする。
  • 相手が入力するまで何もできない。待たされるストレスを考えよう
  • 一定時間名にマークを置かなかった場合は…を考える
    • これも、ゲームルールを考える最初の段階から想定して考えておこう
  • 10秒でタイムアウトをして、10秒を超えたらマウス入力を無効にし、ランダムな位置マークを配置する。
    • ちゃんとユーザーに見えるように残り時間を表記しよう
  • 回線切断と通信タイムアウトのエラーが発生する。

要点

  • 通信相手の知らない情報を適切なタイミングで送受信し合う!
  • できるだけ少ない情報をできるだけ少ない回数で送信しよう!
  • 相手が知っている情報は送る必要がないよ!
  • ターン制ゲームでは待たされている時間のことを考えてゲームデザインをしよう
  • 回線切断と通信タイムアウトのエラーハンドリングをしよう

置き換えてみる

画面

  • TicTacToeRoom.tscn 三目並べを行うゲームルーム
    • エラー画面を持つ

通信

  • TransportTCP.tscn 通信ライブラリ(3章/4章で使った)
  • TicTacToe.tscn ゲーム処理を行う

ゲームの開始の接続処理は第4章をGodotに置き換えながら読むのところを再利用する。

メモ

  • HBoxContainer は 子要素の幅を Expand すれば、等しいサイズで詰め込むことができる。
  • 4章で作ったチャットの中に組み込もうとしているので、送ったデータの種類を区別する必要がある
  • ゲーム途中では、なるべく少ない通信で送るべきだけど、チャット中やゲーム招待時は遅延があっても大丈夫なので、JSONで送るようにしてみる。
  • ボードを画面中央に配置する時に座標計算をしておいていたけど、Camera2Dを使えばいいのでは?!?!
  • 画面内でリトライするのが難しくてあきらめてしまった。(2人ともリトライを押している必要があるってことよね)

SendType を追加してみた

TransportTCPSendType を追加してみた。

enum SendType {
	CHAT,
	TICTACTOE_INVITE,
	TICTACTOE_REJECT,
	TICTACTOE_JOIN
}

送るとき

func send(type: SendType, content: String):
	var message = {
		"type": type,
		"content": content
	}
	var data = JSON.stringify(message).to_utf8_buffer()
	_send(data)

受け取るとき

func _on_data_received(data: PackedByteArray):
	var text = data.get_string_from_utf8()
	var msg = JSON.parse_string(text)
	if msg == null:
		return
	var type = msg["type"]
	match int(type):
		TransportTCP.SendType.CHAT:
			var message = msg["content"]
			received_message.emit(message)
		TransportTCP.SendType.TICTACTOE_INVITE:
			received_message.emit("三目並べに招待されました")
		TransportTCP.SendType.TICTACTOE_REJECT:
			received_message.emit("三目並べへの招待を拒否されました")
		TransportTCP.SendType.TICTACTOE_JOIN:
			received_message.emit("三目並べをはじめます")

複数のメッセージを同時に送信した場合

例えば招待をキャンセルした場合に次のように2回同時に送ったとする

func _on_pressed_cancel_button():
	TransportTCP.send(TransportTCP.SendType.TICTACTOE_REJECT, "")
	TransportTCP.send(TransportTCP.SendType.CHAT, "三目並べの招待をキャンセルしました")
	invite_view.visible = false

そうすると、受け取り側の text は次のようにまとめて届くことになる

func _on_data_received(data: PackedByteArray):
	var text = data.get_string_from_utf8()
	print("受信したデータ: ", text)
	# ここでJSONをパースできずにエラーになる
	var msg = JSON.parse_string(text)
	if msg == null:
		return
	var type = msg["type"]
	
	if type == TransportTCP.SendType.CHAT:
		var message = msg["content"]
		received_message.emit(message)
受信したデータ: {"content":"","type":2}{"content":"三目並べの招待をキャンセルしました","type":0}

JSONのパースに失敗することになる。

とりあえず、同時には送らないようにした。

ゲームボードを画面の中心に配置したい

最初は座標計算を頑張ってやってみた。

extends Node2D

@onready var game_board = $GameBoard

const ROW = 3
const COLUMN = 3
const CELL_SIZE: float = 100
const SPACING: float = 16

var cell_scene = preload("res://scripts/tic_tac_toe/cell/tic_tac_toe_cell.tscn")
var viewport_size: Vector2

func _ready() -> void:
	viewport_size = get_viewport().get_visible_rect().size
	setup_board()


func setup_board():
	var game_board_width = CELL_SIZE * COLUMN + SPACING * (COLUMN - 1)
	var game_board_height = CELL_SIZE * ROW + SPACING * (ROW - 1)
	var board_center_offset = Vector2(game_board_width, game_board_height) / 2
	var cell_center_offset = Vector2(CELL_SIZE, CELL_SIZE) / 2

	game_board.position = get_viewport_rect().size / 2

	for row in ROW:
		for col in COLUMN:
			var cell = cell_scene.instantiate()
			var x = col * (CELL_SIZE + SPACING)
			var y = row * (CELL_SIZE + SPACING)
			cell.position = Vector2(x, y) - board_center_offset + cell_center_offset
			game_board.add_child(cell)

この方法の欠点は

  • 画面に合わせたセルサイズを考慮しなきゃいけない。
  • 計算が少し複雑
  • 大きい端末の場合にも、固定サイズになる。

つづいて、Camera2Dを使った方法に書き換えてみた。

書き換え1

extends Node2D

@onready var game_board = $GameBoard
@onready var camera = $Camera2D

const ROW = 3
const COLUMN = 3
const CELL_SIZE: float = 100
const SPACING: float = 16

var cell_scene = preload("res://scripts/tic_tac_toe/cell/tic_tac_toe_cell.tscn")
var viewport_size: Vector2

func _ready() -> void:
	viewport_size = get_viewport().get_visible_rect().size
	setup_board()
	adjust_camera_to_fit_board()


func setup_board():
	# ゲームボードの中身が原点にくるように調整しておく
	var step = CELL_SIZE + SPACING
	var total_size = Vector2(COLUMN, ROW) * step
	var offset = total_size / 2

	for row in ROW:
		for col in COLUMN:
			var cell = cell_scene.instantiate()
			var x = col * step - offset.x + CELL_SIZE / 2
			var y = row * step - offset.y + CELL_SIZE / 2
			cell.position = Vector2(x, y)
			game_board.add_child(cell)


# カメラをゲームボードのサイズに合わせる.
func adjust_camera_to_fit_board():
	var step = CELL_SIZE + SPACING
	var total_size = Vector2(COLUMN, ROW) * step
	var zoom_x = total_size.x / viewport_size.x
	var zoom_y = total_size.y / viewport_size.y
	var zoom_factor = max(zoom_x, zoom_y)

	# 少し余裕を持たせる
	zoom_factor *= 1.1

	camera.zoom = Vector2(zoom_factor, zoom_factor)

この書き換えのメリット

  • 少し計算が楽になった?
  • 画面上に思ったサイズで表示されていない場合には、camera_zoom をいじればいい。
  • スマホ・PCなどの両対応に備える場合には、Camera2Dは使った方がいい。
  • ズームイン・ズームアウトなどのアニメーションをするならCamera2Dは必須。
  • そもそも Control で作っちゃいなよということを言われちゃったが。

でも基本はCamera2Dは使う!で良さそう。

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

  • adjust_camera_to_fit_board() なしにすると、画面と同じサイズになる。
  • カメラはviewport のサイズに紐づく。縦長なら縦長、横長なら横長の範囲をうつす。
  • なのでゲームボードの場所を少し上に配置したいなどの場合は、ゲームボード自体を上に中心配置ではなく、y座標をあげないといけないね。

背景画像をつけてみた

extends Node2D

@onready var game_board = $GameBoard
@onready var camera = $Camera2D
@onready var background_sprite = $GameBoard/BackgroundSprite

const ROW = 3
const COLUMN = 3
const CELL_SIZE: float = 100
const SPACING: float = 16

var cell_scene = preload("res://scripts/tic_tac_toe/cell/tic_tac_toe_cell.tscn")
var viewport_size: Vector2

func _ready() -> void:
	viewport_size = get_viewport().get_visible_rect().size
	setup_board()
	adjust_camera_to_fit_board()


func setup_board():
	# ゲームボードの中身が原点にくるように調整しておく
	var step = CELL_SIZE + SPACING
	# 左上と左横にも余白を与える
	var total_size = Vector2(
		(COLUMN * CELL_SIZE) + (SPACING * (COLUMN + 1)),
		(ROW * CELL_SIZE) + (SPACING * (ROW + 1))
	)
	var offset = total_size / 2
	
	# 背景画像のサイズを調整する
	var texture_size = background_sprite.texture.get_size()
	background_sprite.scale = total_size / texture_size

	for row in ROW:
		for col in COLUMN:
			var cell = cell_scene.instantiate()
			var x = col * step - offset.x + CELL_SIZE / 2 + SPACING
			var y = row * step - offset.y + CELL_SIZE / 2 + SPACING
			cell.position = Vector2(x, y)
			game_board.add_child(cell)


# カメラをゲームボードのサイズに合わせる.
func adjust_camera_to_fit_board():
	var step = CELL_SIZE + SPACING
	var total_size = Vector2(COLUMN, ROW) * step
	var zoom_x = total_size.x / viewport_size.x
	var zoom_y = total_size.y / viewport_size.y
	var zoom_factor = max(zoom_x, zoom_y)

	# 少し余裕を持たせる
	zoom_factor *= 1.5

	camera.zoom = Vector2(zoom_factor, zoom_factor)

GameBoard オブジェクトの位置を少し上に配置して調整した。

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

この TicTacToeGameBoard を使う方のシーンで、場所を変えたとしても、正しく表示される。

TicTacToeGameBoard はデフォルトではこの位置に表示される。

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

これを、カメラの角を左上に合わせて、実際のUIと重ねてみやすくすることができる。

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

とりあえずゲーム自体はできた。

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

今後の課題や気づき

  • コードがとても汚い気がする。
    • 本書のサンプルコードが非常に参考になる
    • 例えばステートマシンとして GameProgress を用意しているため流れが見えやすい。
  • リトライの処理
  • チャットに組み込んでいるのもあり、切断の処理が甘い
  • ボードを理想のサイズにして配置する方法をもっと学びたい
    • 今回はCamera2Dを使うことを学んだ
  • Assets Store 等からアセットを取得して、アセットを使う練習もしないとマズイ。
  • そういえば Godotのリソースを全く使ってないな
  • やればやるほど、GDScriptってつらくね?と思ってきてしまっている。

次へ

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