最終確認日
オンラインゲームの仕組みの本ー第6章をGodotに置き換えながら読む
概要
ちょこっとアクションゲーム「たたいて!かわして!じゃんけんぽん!」を作る
- オンラインゲームのしくみ-Unityで覚えるネットワークプログラミング
- オンラインゲームの仕組みの本ー第3章をGodotに置き換えながら読む
- オンラインゲームの仕組みの本ー第4章をGodotに置き換えながら読む
- オンラインゲームの仕組みの本ー第5章をGodotに置き換えながら読む
まずは読む
つくるのは、いわゆる叩いてかぶってじゃんけんぽん。
- まずはゲームデザイン。じゃんけんパートとアクションパートがある。
- 厳密に既存のルールを適用すると、技術的な問題が発生したり、仕様に矛盾が生じることがある。
- 現実世界では、じゃんけんの手を選ぶ瞬間はじゃんけんポンのポンの一瞬
- じゃんけんの手を選ぶ時間を設けてタイミングゲームにならないようにする
- 早く出しすぎた場合や出さなかったときのパターンの話は難しすぎて何を言われているのかわからなくなるレベル。考えもつかずに無視しそうなじゃんけんの仕様。
- 今回のゲーム仕様では間違えて叩いてもペナルティポイントはなく、単純化したゲームにする
- オンラインゲームの仕様を検討する前に、1台の端末で複数人がプレイする状況を検討する。
ジャンケンパート
- 出す手が決まったらお互いに送信する。
- お互いの出す手が揃ったタイミングでジャンケンが始まる。
- 選択のタイミングでは、先に相手の手を受け取っている状態になるが、ユーザーに見えているわけではないので、問題なし。
アクションパート
- どちらが先に攻撃/防御アクションを入力できるかを競う
- さまざまな状況を考えなければいけない
- 端末Aは攻撃、端末Bは何もしなかった場合
- 何もしない場合は情報が届かないので防御できていない判定とする
- 端末Aは何もしない、端末Bが防御した場合
- 攻撃の情報が届かなかったので、引き分け
- 端末Aの攻撃の入力が早く、端末Bの防御が遅かった場合
- このときは遅延の影響を受けるので、結果が異ならないように注意
- 端末Aは攻撃、端末Bは何もしなかった場合
- 同期させるための情報を加えて通信する
- アクション開始から入力までの経過時間を相手に送る
オンラインゲームで生じる問題の解決
- ローカルではボタンを押した瞬間にローカル側のモーションが再生される
- リモートの操作は届いたあとにモーションが再生される
- 見た目と結果がズレる可能性がある
- 攻撃したのにあたっていない、避けたのに当たった問題が起きる。
- このゲームでは、モーションの再生速度を速くすることで解決する。
- もともと矛盾しているものを矛盾がないように見せるので、いい妥協点を見つけることが重要
- 通信遅延の許容値を設定する
- 許容時間分は予備のアクション動作を入れる
- もちろんこれにより若干ゲームのテンポは悪くなるが、それは妥協点になる。
- 仕様が決まったらゲームのフローチャートとゲームの状態enumを定義するのはよさそう
private enum GameProgress
{
None = 0,
Ready, // ゲーム事前準備.
SelectRPS, // ジャンケン選択.
WaitRPS, // 受信待ち.
Shoot, // ジャンケン演出.
Actions, // 叩いて被っての選択. 受信待ち.
EndAction, // 叩いて被って演出.
Result, // 結果発表
EndGame // おわり.
}
- じゃんけんの手を選ぶ前から受信待機をしていないといけない。
- 「お互いの情報を送受信し合って」は同タイミングの送受信のようだけど、実際は違うからね
RPSKind
でじゃんけんの手を定義Kind
って単語いいね。- シーケンス
m_inputData[m_playerId ^ 1] = rps;
という記述は、「m_playerId
が 0 または 1 であることを前提に、もう一方のプレイヤーIDにアクセスする」ためのテクニック。- じゃんけん情報にの受信タイミングによっては、じゃんけんの開始タイミングが端末ごとに異なるが、アクションパートで経過時間を送って判断するのでok
- このズレによってアクションパートを受け取れない可能性はないのかな??
- 経過時間(ms)はリアルタイムクロックを使う(マシンスペックやフレームレートの影響を受けないようにするため)
- バタンと経過時間のデータはセットで送信する。3バイトで。
- エンディアンに気をつけて。HostToNetworkOrder で変換する
たいせつ!
- オンラインゲームの作成はの1台の端末で複数人が遊ぶ時の仕様を先に考える!
- 結果と表示がズレる現象はオンラインゲームでしか発生しない問題!
- 通信/遅延処理を含めたゲームの仕様にする必要があるため、通信機能は後付けできない!
サンプルコードを見る
含まれるScripts
- ActionController.cs
- AsciiCharacter.cs
- BattlePanel.cs
- BattleSelect.cs
- BGM.cs
- BoardYou.cs
- Damage.cs
- HeavyDamage.cs
- InputData.cs
- Kurukuru.cs
- LightDamage.cs
- NetworkController.cs
- NetworkDef.cs
- PacketQueue.cs
- Player.cs
- ResultScene.cs
- RockPaperScissor.cs
- RPSPanel.cs
- RPSSelector.cs
- RPSSpriteChanger.cs
- Score.cs
- ShootCall.cs
- Timer.cs
- TransportTCP.cs
ネットワーク系
- TransportTCP.cs
- NetworkController.cs
- NetworkDef.cs
- PacketQueue.cs
アニメーション操作系
- ActionController.cs
- Kurukuru.cs
- LightDamage.cs
- RPSSpriteChanger.cs
- Damage.cs
- HeavyDamage.cs
- BoardYou.cs
- ShootCall.cs
- Score.cs
- Player.cs
UIコンポーネント系
- BattlePanel.cs
- RPSPanel.cs
- RPSSelector.cs
- じゃんけんの選択パネルを管理する
じゃんけんとアクションの処理
- RockPaperScissor.cs
- じゃんけんのメイン処理
- GameState を持つ
- BattleSelect.cs
- SelectWait / Selected の2つのStateをもつ
- InputData.cs
- じゃんけんの手の種類やアタックの種類、送信するデータなどを定義
- ここにジャンケンの結果と、攻撃/防御から勝敗を求めるも書いてある
リザルト処理
- ResultScene.cs
その他
- AsciiCharacter.cs
- BGM.cs
- Timer.cs
- タイマーは経過時間を取得する処理などのutil
Godotに置き換える
とりあえずメインのシーンから作る。 GameState
は RockPaperScissor.gd
内に定義する。他からは意識しないようにしてね。
- RockPaperScissor.tscn
まずは GameState の定義から各関数を実装していく。
extends Node2D
class_name RockPaperScissor
enum GameState {
NONE = 0,
READY, # 対戦相手のログイン待ち.
SELECT_RPS, # ジャンケン選択.
WAIT_RPS, # 受信待ち.
SHOOT, # ジャンケン演出.
ACTION, # 叩いて被っての選択・受信待ち.
END_ACTION, # 叩いて被って演出.
RESULT, # 結果発表.
END_GAME, # おわり.
DISCONNECT, # エラー.
}
var game_state: GameState = GameState.NONE
func _ready() -> void:
game_state = GameState.NONE
func _process(delta: float) -> void:
match game_state:
GameState.NONE:
pass
GameState.READY:
update_ready()
GameState.SELECT_RPS:
update_select_rps()
GameState.WAIT_RPS:
update_wait_rps()
GameState.SHOOT:
update_shoot()
GameState.ACTION:
update_action()
GameState.END_ACTION:
update_end_action()
GameState.RESULT:
update_result()
GameState.END_GAME:
update_end_game()
GameState.DISCONNECT:
pass
NeworkController.cs
にあたるものを実装するRPSNetworkController.tscn
にしておく
- UDPを使っているのかと思ったら、今回もTCPでいいのね。
class_name
を使った enum 専用スクリプトならAutoloadにしなくても使えるのね。- これができるならもう3~5章ももう少しマシに実装できそう。
_init(a: int, b: String) -> void
といったように_init
コンストラクタが使える!- よかった。
var hoge = RPSActionInfo.new(RPSType.ActionKind.ATTACK, 3000)
print("hoge: ", hoge.action_time)
# hoge: 3000
# ちゃんと使える。
Godotでスプライトシートを使って画像を設定する
HBoxContainer 内のアイテムは
visible = false
にすると真ん中寄せの場合は真ん中へ寄るTransportTCP.data_received.connect(_on_data_received)
は受け取りたい画面が表示されていない時はオフにしても良さそう。負けと引き分けになるときがある。
- このジャッジのテストを作りたい。
- Godotで単体テストをしてみたい
AnimationPlayer は Control ノードに対しても使ってok
単体テスト
GUTを導入したので、じゃんけんのロジックのユニットテストを作る
rps_result_checker.gd
のユニットテストを作成する- これはAIに書いてもらった
次のようなコードをテストしてみるよ。
extends Node
class_name RPSResultChecker
# ジャンケンの勝敗を決める.
static func get_rps_winner(remote: RPSType.RPSKind, local: RPSType.RPSKind) -> RPSType.Winner:
var remote_rps := int(remote)
var local_rps := int(local)
if remote_rps == local_rps:
return RPSType.Winner.DRAW # 引き分け
# 数値の差分で判定
if remote_rps == (local_rps + 1) % 3:
return RPSType.Winner.REMOTE_PLAYER # リモートの勝ち
return RPSType.Winner.LOCAL_PLAYER # ローカルの勝ち
# アクションの勝敗を決める.
static func get_action_winner(remote: RPSActionInfo, local: RPSActionInfo, rps_winner: RPSType.Winner) -> RPSType.Winner:
var remote_action := remote.action_kind
var local_action := local.action_kind
var remote_time := remote.action_time
var local_time := local.action_time
match rps_winner:
RPSType.Winner.REMOTE_PLAYER:
if remote_action != RPSType.ActionKind.ATTACK:
return RPSType.Winner.DRAW # 攻撃側が攻撃しなかった
if local_action != RPSType.ActionKind.BLOCK:
return RPSType.Winner.REMOTE_PLAYER # 防御してないのでリモートの勝ち
# BLOCKしている → 時間勝負
if remote_time < local_time:
return RPSType.Winner.REMOTE_PLAYER
else:
return RPSType.Winner.DRAW
RPSType.Winner.LOCAL_PLAYER:
if local_action != RPSType.ActionKind.ATTACK:
return RPSType.Winner.DRAW
if remote_action != RPSType.ActionKind.BLOCK:
return RPSType.Winner.LOCAL_PLAYER # 防御してないのでローカルの勝ち
# BLOCKしている → 時間勝負
if local_time < remote_time:
return RPSType.Winner.LOCAL_PLAYER
else:
return RPSType.Winner.DRAW
RPSType.Winner.DRAW:
return RPSType.Winner.DRAW
push_error("勝敗が正しく定義されていません。")
return RPSType.Winner.NONE
- 原因発見?
- テストを自分で書き直す。
- 当たり前だが勝敗のケースを自分でちゃんと把握していないとダメよ。
ジャンケンのテスト
extends GutTest
# Drawのテスト.
func test_get_rps_winner_draw():
assert_eq(RPSResultChecker.get_rps_winner(RPSType.RPSKind.ROCK, RPSType.RPSKind.ROCK), RPSType.Winner.DRAW)
assert_eq(RPSResultChecker.get_rps_winner(RPSType.RPSKind.SCISSOR, RPSType.RPSKind.SCISSOR), RPSType.Winner.DRAW)
assert_eq(RPSResultChecker.get_rps_winner(RPSType.RPSKind.PAPER, RPSType.RPSKind.PAPER), RPSType.Winner.DRAW)
# リモートが勝つパターンのテスト.
# 引数は (リモート, ローカル)
func test_get_rps_winner_remote_wins():
assert_eq(RPSResultChecker.get_rps_winner(RPSType.RPSKind.ROCK, RPSType.RPSKind.SCISSOR), RPSType.Winner.REMOTE_PLAYER)
assert_eq(RPSResultChecker.get_rps_winner(RPSType.RPSKind.PAPER, RPSType.RPSKind.ROCK), RPSType.Winner.REMOTE_PLAYER)
assert_eq(RPSResultChecker.get_rps_winner(RPSType.RPSKind.SCISSOR, RPSType.RPSKind.PAPER), RPSType.Winner.REMOTE_PLAYER)
# ローカルが勝つパターンのテスト.
func test_get_rps_winner_local_wins():
assert_eq(RPSResultChecker.get_rps_winner(RPSType.RPSKind.ROCK, RPSType.RPSKind.PAPER), RPSType.Winner.LOCAL_PLAYER)
assert_eq(RPSResultChecker.get_rps_winner(RPSType.RPSKind.PAPER, RPSType.RPSKind.SCISSOR), RPSType.Winner.LOCAL_PLAYER)
assert_eq(RPSResultChecker.get_rps_winner(RPSType.RPSKind.SCISSOR, RPSType.RPSKind.ROCK), RPSType.Winner.LOCAL_PLAYER)
アクションのテスト(長いので一部のみ)
# じゃんけんがリモートが勝った場合のテスト.
# アタックのタイミングはリモートが早いタイミングとする.
func test_get_action_winner_remote():
var remote_attack = RPSActionInfo.new(RPSType.ActionKind.ATTACK, 1000)
var remote_block = RPSActionInfo.new(RPSType.ActionKind.BLOCK, 1000)
var remote_none = RPSActionInfo.new(RPSType.ActionKind.NONE, 1000)
var local_attack = RPSActionInfo.new(RPSType.ActionKind.ATTACK, 2000)
var local_block = RPSActionInfo.new(RPSType.ActionKind.BLOCK, 2000)
var local_none = RPSActionInfo.new(RPSType.ActionKind.NONE, 2000)
assert_eq(RPSResultChecker.get_action_winner(remote_attack, local_attack, RPSType.Winner.REMOTE_PLAYER), RPSType.Winner.REMOTE_PLAYER)
assert_eq(RPSResultChecker.get_action_winner(remote_attack, local_block, RPSType.Winner.REMOTE_PLAYER), RPSType.Winner.REMOTE_PLAYER)
assert_eq(RPSResultChecker.get_action_winner(remote_attack, local_none, RPSType.Winner.REMOTE_PLAYER), RPSType.Winner.REMOTE_PLAYER)
assert_eq(RPSResultChecker.get_action_winner(remote_block, local_attack, RPSType.Winner.REMOTE_PLAYER), RPSType.Winner.DRAW)
assert_eq(RPSResultChecker.get_action_winner(remote_block, local_block, RPSType.Winner.REMOTE_PLAYER), RPSType.Winner.DRAW)
assert_eq(RPSResultChecker.get_action_winner(remote_block, local_none, RPSType.Winner.REMOTE_PLAYER), RPSType.Winner.DRAW)
assert_eq(RPSResultChecker.get_action_winner(remote_none, local_attack, RPSType.Winner.REMOTE_PLAYER), RPSType.Winner.DRAW)
assert_eq(RPSResultChecker.get_action_winner(remote_none, local_block, RPSType.Winner.REMOTE_PLAYER), RPSType.Winner.DRAW)
assert_eq(RPSResultChecker.get_action_winner(remote_none, local_none, RPSType.Winner.REMOTE_PLAYER), RPSType.Winner.DRAW)
テストコードを正しく書くのがむずすぎる。
とりあえず全部パスしてることが確認できた。
- 自分が勝ち、相手は操作なしのときにおかしい。
- テストは全部通っているけどおかしい。
- タイマーの部分がバグっていた。
できた!
無事に完成!
チャットに招待
ジャンケンをする
攻撃/防御をする
3本叩いた方が勝ち
頑張りました。

公開日
更新日