最終確認日

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

概要

キー入力同期通信ゲーム「フロクのいなり寿司」をつくる ブロック崩しゲームらしい。

まずは読む

  • キー入力同期のゲームは「キーボードやコントーラなどのボタンや方向キーの押下状態のフラグなどを同期するゲーム」
  • それぞれの端末の同じフレームで入力されたものなら処理する結果も同じになる。
  • 入力->キー入力情報を受信->キー入力情報受信 -> ゲーム処理(お互いの情報が揃っている -> 描画処理
    • 1台の端末で2人のプレイヤーがプレイしているのと同じ状態にできる。
  • 開始タイミングのずれや通信遅延は起きる
  • お互いのキー入力状態が揃うまでは処理を進めずに停止しないといけない。
  • 同期が開始されるまで画面をフェードアウトしておくといいよ
  • ステータス
    • NotStarted
    • WaitSynchronize
      • 後述の1,2フレーム目をバッファに入れている状態までここにする
    • Synchronized
  • コントローラの情報を抽象化する
    • Unity の Input クラスからゲームに必要な情報だけを抽象化する。
  • マウスでパドルを掴んで移動させる
    • マウスの座標
    • マウスボタンの押下情報
      • 自分が作りたいゲームはやっぱりこれで実装できそう。
  • お互いのキー入力情報は1フレームの処理時間以内に送受信されなければいけない。
    • 60FPSなら 16.666..ms 以内
  • 1フレーム以上配信遅延がある場合
    • キー入力の遅延を許容して、キー入力情報を受信するまでの時間を確保する方法
  • キー入力遅延:コントローラで入力した情報がゲームに反映されるまでの遅延
    • 1フレーム目は入力されているが、バッファに溜めるだけ
    • 2フレーム目も入力されているが、バッファに溜める
    • 3フレーム目の入力もバッファに溜まり、ここでようやく1フレーム目の処理をする
      • 3フレーム分、コントローラからの入力の反映にラグがある状態にある。
      • 操作感覚に違和感は出るので、遅延させることのできるフレームを決める必要がある。
  • 使用するキー入力情報が受信できていない場合は受信するまでゲーム処理を停止するなどの整合性をとる仕組みが必要
    • このときローカル端末のキー入力も停止する必要があるよ
    • 受信ができたら再開、一定時間受信できなかったらゲームを終了とする。
  • 今回は通信スピードが重要なのでUDPで通信する!
    • コネクションレスで通信することができるから通信相手にいきなりデータを送信できるよ
  • キー入力の情報にフレーム番号を含めて送信する
  • 冗長データを用いた再送信処理、つまり過去に送信したフレームの入力情報も一緒に送信しちゃう!
    • 例えば、過去3フレーム分のキー入力情報を冗長なデータとして送信する
    • そうするとパケットロストをした場合にも冗長データによって補完できる
  • パケットに収めるデータは複数の変数から成り、さまざまな型をしている
  • エンディアンの判定はシリアライザーの内部で行う。
    public bool Serialize(MouseData packet)
    {
        // 各要素を順番にシリアライズします.
        // retはreturnの略なのね
        bool ret = true;
        // ret = ret && Serialize(...); と同じ意味
        // 全ての処理がtrueを返した時だけ ret = true となる。
        ret &= Serialize(packet.frame);
        ret &= Serialize(packet.mouseButtonLeft);
        ret &= Serialize(packet.mouseButtonRight);
        ret &= Serialize(packet.mousePositionX);
        ret &= Serialize(packet.mousePositionY);
        ret &= Serialize(packet.mousePositionZ);

        return ret;
    }

キー入力同期の仕組みを実装する

  • キー入力同期の処理
    • フレームの同期制御
    • リモート端末とのキー入力情報の送受信
    • キー入力からのキー入力情報の取得
  • キー情報
    • マウスの座標
    • マウスボタンの押下情報
  • 状態が Synchronizedになるまでは Update 処理を実行しないようにする。
  • Unityは基本的にゲームの処理をUpdate関数で行う
    • 全てのGameObjectのUpdate関数処理が完了してからLastUpdate 関数がある。
    • LastUpdate関数を使うと、スクリプトの実行順を気にしなくて済む
  • ResumeSuspend を使って次のフレームに進めるかを処理する。
  • レイキャストを使ってマウス座標を計算しているのね。
  • C#、キャメルケースだったりスネークだったりするの何
  • inputDataをマイフレーム送信すると通信量が多くなってしまうので、バッファ数よりも少ない数のフレーム感覚で送信をおこうなようにする。
if ((loopCounter % bufferNum / 2) != 0)
{
    // 送信するフレームではない.
    return;
}
  • loopCounter:おそらく毎フレームインクリメントされているカウンター

  • bufferNum / 2:一定間隔(たとえば5フレームおき)を指定している

  • loopCounter % (bufferNum / 2) が 0 のときだけ送信する

  • バッファ数に基づいた送信間隔制御は、設計上の安定性・整合性を保つためらしい。

  • ゲームが終了した後は通信の切断をする

    • 終了直後だと、片方が受信待ちになりゲームが終了できない可能性がある。
    • 切断応答を待ってから切断するようにする。
    • 同期通信を終了するためのフラグをパケットにのせて送信する。
  • 同期終了フラグを送る -> 切断可能フラグを受け取ったら通信終了

    • TCPの確認応答のようなものをUDPで行う。
  • ビットフラグ読むの難しい。

    • isSynchronized = (suspendSync & 0x02) > 0;
  • キー入力とシリアライザのセクションは、シリアライザーのセクションのときに出てきたコードと微妙に違うコードの紹介でかなり読みづらい。さっきみなかったっけ?ってなる。

通信プログラムの動作確認とデバッグの仕方

  • 欧米ではさらに遅延が大きくなると言われている...!
  • 通信遅延をわざと発生させる遅延エミュレータが必要。
    • これも作れるよ とな。
    • 遅延させるフレーム数分バッファに情報を溜めることで遅延させる
  • 通信遅延で必要となるのは、送信時刻と送信データ
    • 指定した配信遅延分の時間が過ぎるまで送信を行わないようにする
  • 今回のようなオンラインゲームではブレイクポイントで停止させてステップ実行が難しい
    • 片方を止めることができても、もう片方は処理を続けてしまうので
  • ログ出力をしよう。
  • 画面への表示も有効
    • この場合は画面キャプチャするといいよ
  • パケットキャプチャツールを利用するのも有効

まとめ

  • ブロック崩しを題材として?!?!ゲーム全く出てきてないよ!マウスでパドルを握ることしかでてきてないよ!
  • とりあえず読了。

サンプルコードを見る

含まれるスクリプト

.
├── AsciiCharacter.cs
├── BallScript.cs
├── BarScript.cs
├── BGM.cs
├── BlockCreator.cs
├── BlockScript.cs
├── DebugPrint.cs
├── DebugScript.cs
├── EffectControl.cs
├── GameController.cs
├── InputManager.cs
├── InputSerializer.cs
├── NetworkController.cs
├── NetworkDef.cs
├── Number.cs
├── PacketQueue.cs
├── PingPong.cs
├── PlayerInfo.cs
├── ResultController.cs
├── ResultScore.cs
├── ScoreSushi.cs
├── Serializer.cs
├── Sushi.cs
├── TransportUDP.cs
└── UserScore.cs

メモ

  • NetworkDef.cs を見ると PLAYER_MAX = 4; で最大プレイヤー人数4人のゲームらしい?!
    • 他のコードを見ると2人プレイっぽい。
  • GameController.csPingPong.cs がある。
    • GameController.cs の方はタイマー管理とかゲーム本体の処理をしている
    • PingPong.cs はゲーム内の通信部分の処理
    • このタイマーの同期をどうしているのか調査する
      • 同期はしていないが、Suspendで止めた時にタイマーも止まるため、実質同期しているようなものっぽい。
  • BlockScript.cs より寿司のライフは2あり、ボールを2回当てたらポイントになるっぽい。
  • PingPong.cs より 1p と 2p で利用するカメラが違う
    • 自分が手前になるようにカメラを切り替えているみたい。
  • 3回戦まであるらしい
  • ミスしたらポイントが減るらしい
  • Unityでの実行が難しい。どうやって動かせばいいんだ。
  • ブロック全破壊 or 時間切れ
  • 右クリックするとボールが発射される
    • 1秒に1個いう制限のみ。あとは好きなだけ発射できる
  • ボールがどちらのユーザー側で場外に出たかによりポイントがマイナスされる。
  • 寿司ポイントとミスポイントの両方があるのが微妙ではないか?

ここまでの所感

  • こういうブロック崩しはボールの座標を同期させると思っていたけど、ボールの座標は一切同期されていない。あくまで入力のみでいいのね。
    • フレーム同期(Lockstep)方式というらしい。
    • ただ、Rigidbody2Dを使っているとやはりズレる可能性はあるっぽいね?
  • Unity のオンラインゲームのデバッグの仕方を知らないせいで、サンプルプロジェクトが動かせない。オワタ
  • ゲーム自体の仕様がむずかしい。

Godotに置き換える

置き換えるといっても、ゲームの仕様がコードから紐解くしかない。わりと遊んだことのないようなピンポンとブロック崩しの両方の機能を持つゲームみたいで、そのまま作るのも微妙そう。

代わりにエアホッケーとかがいいんじゃないかと思ったけど、エアホッケーは速いからキー入力同期は微妙らしい。

⭐️ ボール1つで協力型のブロック崩しを作ることにする。

仕様を考える

協力型オンラインブロック崩し

  • 画面上部と画面下部にそれぞれのプレイヤーのバー、中央にブロックを配置
  • 弾は1つ
  • 全てのブロックを崩したらステージクリア
  • 3面ステージをクリアしたらゲームクリア
  • ステージ制
  • 球を弾き返すことができなかった場合、ステージ失敗。
  • ブロックを崩すと、稀にアイテムを獲得する
    • 貫通弾
  • 崩したブロックの数は関係ない。

ゲームの流れ

  • ゲーム開始後、Player 1 or 2 のバーからボールが発射される
  • ボールが 中央のブロックに当たると反射
    • ボールが当たったブロックは削除される
  • 上下プレイヤーが協力してボールを返し合う
  • ボールがどちらかのプレイヤーのバーを抜けるとミス(ゲームオーバー)

↓ GPTにまとめてもらった


🎮 協力型オンラインブロック崩し 仕様書

📝 概要

2人プレイヤーが協力して、1つのボールを使って中央に配置されたブロックをすべて破壊するリアルタイム協力型ブロック崩しゲーム。
オンライン通信により、プレイヤーの操作を同期しながらプレイを進める。

🧱 ゲーム画面構成

┌───────────────┐
│  プレイヤー1のバー(上) │
│                         │
│       ボール            │
│                         │
│     ブロック群(中央)   │
│                         │
│       ボール            │
│                         │
│  プレイヤー2のバー(下) │
└───────────────┘

🎯 ゲーム目的

  • 2人で協力して、すべてのブロックを破壊する

  • 1ステージごとに構成され、3面クリアでゲームクリア

🕹️ プレイ仕様

項目 内容
プレイヤー数 2人(オンライン協力)
操作 左右移動のみ(バー)
同期方法 Lockstep方式(入力同期のみ)
ボール 常に1つだけ存在。共有ボール
バーの位置 プレイヤー1:上部/プレイヤー2:下部
ブロックの配置 画面中央に密集して配置
ステージ数 3ステージ制
勝利条件 ステージ内のすべてのブロックを破壊すること
敗北条件 ボールをバーで受け損ねた場合(どちらかがミス)

🔁 ゲームの流れ

  1. ゲーム開始時、Player 1 または Player 2 のバーからボールが発射される

  2. ボールはブロックに当たると反射し、当たったブロックは消える

  3. 上下のプレイヤーがバーを操作してボールを打ち返し続ける

  4. 全ブロック破壊でステージクリア → 次のステージへ

  5. 3ステージ全てをクリアでゲームクリア

  6. ボールがバーをすり抜けるとステージ失敗 → リトライ

🎁 アイテム要素

  • ブロック破壊時に確率でアイテム出現

    • 貫通弾:一定時間ブロックを貫通して破壊可能(反射せず直進)
  • アイテムは即時発動(自動取得)

  • アイテムはゲームクリアに必須ではない

🧮 スコア・進行

  • ブロック破壊数は評価に影響しない

  • スコア要素はなく、ステージクリアか否かのみで進行

  • プレイヤー間の役割に差はない(協力重視)

⚙️ 技術的補足(実装観点)

項目 内容
物理挙動 Rigidbody不使用、ロジックベースの反射処理
衝突判定 手動のAABB or SATによる当たり判定
入力同期 NetworkControllerでプレイヤー入力(左右)をLockstep同期
ボール挙動 全端末で同一のロジックにより決定論的に再現可能
ブロック削除 衝突判定により即時破壊、削除タイミングも同期される

とりあえずこれで作る。

制作メモ

  • 4章で作ったアプリから生やす。
    • UDPTCPは独立している
    • そのためTCPで既に接続している相手に対してもUDP側で相手のIPアドレスとポートを指定して新たに通信を開始する必要がある。
  • ブロック崩しは 英語で Braekout
  • TransportUDP.gd を作る
    • Autoloadにする。(場合によってはしなくても良い)
    • Ping/Pong で通信確認する。
  • Godotは FixedUpdate() の代わりに _physics_process(delta) を使用
  • TCPと違い、UDPではデータを送信する時には毎回アドレスとポートが必要
    • クライアント → サーバー に ping を送る(宛先が必要)
    • サーバーが ping を受け取る → 送信元のIP:PORTを得る
    • サーバー → クライアント に pong を返せるようになる
      • last_address を定義しておくことで、最後に通信した相手に送信する関数を作る
  • UDPでもPoll関数が大切。
  • ゲーム画面の幅と高さを固定しないと、端末ごとの差で崩壊することに気づいた。
  • TileMapLayerの Scale を変更することで、16x16のタイル素材を64x64で配置できる。
  • リトライボタンがお互いにある場合、どちらも押したらREADY にいくのがいいよねぇ。
    • 逆に片方が戻ったら強制的に戻る
  • 高さは関係ないかと思ったけど、配置したブロックとの距離も関係あるわ。

UDP で接続できた

  • TransportUDP (Autoload)
  • UDPServerNode
  • UDPClientNode

を作った。

TransportUDP.gd
extends Node
# Autoload: TransportUDP
# Uses internal UDPClientNode and UDPServerNode to abstract roles

signal data_received(data: PackedByteArray)
signal send_failed(error: int)
signal connection_confirmed
signal connection_lost

var _is_server := false
var _server_node: UDPServerNode = null
var _client_node: UDPClientNode = null

var _last_addr := ""
var _last_port := 0

var _ping_sent := false
var _connection_confirmed := false

## Life cycle

func _ready():
    pass


func _process(_delta: float) -> void:
    if _is_server and _server_node:
        _server_node._process(_delta)
    elif not _is_server and _client_node:
        _client_node._process(_delta)

## Method

func start_server():
    _server_node = UDPServerNode.new()
    add_child(_server_node)
    _server_node.packet_received.connect(_on_server_packet)
    _is_server = true


func connect_to_host(ip_address: String):
    _client_node = UDPClientNode.new()
    add_child(_client_node)
    _client_node.packet_received.connect(_on_client_packet)
    _client_node.start(ip_address)
    _ping_sent = true
    _connection_confirmed = false
    _client_node.send_packet("ping".to_utf8_buffer())
    _is_server = false


func disconnect_host():
    if _is_server and _server_node:
        _server_node.queue_free()
        _server_node = null
    elif _client_node:
        _client_node.queue_free()
        _client_node = null
    connection_lost.emit()


func send(data: PackedByteArray):
    if _is_server and _server_node and _last_addr != "" and _last_port != 0:
        var err = _server_node.send_to(_last_addr, _last_port, data)
        if err != OK:
            send_failed.emit(err)
    elif _client_node:
        var err = _client_node.send_packet(data)
        if err != OK:
            send_failed.emit(err)


## Signal

func _on_server_packet(data: PackedByteArray, addr: String, port: int):
    _last_addr = addr
    _last_port = port
    var msg := data.get_string_from_utf8()
    if msg == "ping":
        _server_node.send_to(addr, port, "pong".to_utf8_buffer())
    else:
        data_received.emit(data)


func _on_client_packet(data: PackedByteArray):
    var msg := data.get_string_from_utf8()
    if msg == "pong" and _ping_sent and not _connection_confirmed:
        _connection_confirmed = true
        connection_confirmed.emit()
    else:
        data_received.emit(data)
UDPServerNode.gd
class_name UDPServerNode
extends Node

signal packet_received(data: PackedByteArray, addr: String, port: int)

var server = UDPServer.new()
var peers: Dictionary = {}  # key = "ip:port", value = PacketPeerUDP

func _ready():
    server.listen(NetworkConfig.UDP_PORT)


func _process(delta):
    server.poll() # Important!
    # 新しいパケットを受け取った時に呼ばれる.
    receive()


## Method

# 受け取る.
func receive():
    if not server.is_connection_available():
        return
    var peer = server.take_connection()
    var packet = peer.get_packet()
    var address = peer.get_packet_ip()
    var port = peer.get_packet_port()
    var key = "%s:%s" % [address, port]

    if not peers.has(key):
        print("新しいクライアントを記録: ", key)
        peers[key] = peer

    print("Received from %s: %s" % [key, packet.get_string_from_utf8()])
    packet_received.emit(packet, address, port)


# 送信する.
func send_to(address: String, port: int, data: PackedByteArray):
    var key = "%s:%s" % [address, port]
    if peers.has(key):
        var peer = peers[key]
        peer.put_packet(data)
    else:
        print("送信先が peers に存在しません: ", key)
UDPClientNode
class_name UDPClientNode
extends Node

signal packet_received(data: PackedByteArray)

var udp = PacketPeerUDP.new()
var connected = false
var ip_address = "127.0.0.1"

## Life cycle

func _process(delta):
    if !connected:
        # 繋がるまでpingを送る
        # ここ微妙かも?
        udp.put_packet("ping".to_utf8_buffer())
    if udp.get_available_packet_count() > 0:
        var pkt = udp.get_packet()
        packet_received.emit(pkt)
        connected = true

## Method

func start(ip: String):
    ip_address = ip
    # 実際にはコネクションレスである
    connected = (udp.connect_to_host(ip_address, NetworkConfig.UDP_PORT) == OK)
    return connected


func send_packet(data: PackedByteArray) -> Error:
    return udp.put_packet(data)


func disconnect_from_host():
    udp.close()
    connected = false

シングルプレイで作る

  • とりあえずシングルプレイでブロック崩しを遊べる状態にする。

  • フィールドの壁を定義しないといけない

  • 計算で移動させた場合、body_entered が呼ばれない

    • RigidBody2DContact Monitor を ON(チェック) にしてください。
    • コリジョンをチェックしてみると重力の影響を受けている
    • RigidBody2D の gravity scale = 0 にする。
    • Node2Dposition が変化しても RigidBody2D は追従しないのかも。ずれる。
  • RayCast2D っていうクラスがあるのね

    • つかわなかった
  • BreakoutBar でとりあえずデバッグ用にInputMapを設定して両方ともローカルで動かせるようにした。

  • BreakoutBlock を作って、ランダムな色のテクスチャをつけたい

  • まずはシングルゲームで流れを作る

    • ボタンを押したらボールを発射
    • とりあえずシングルゲームでできた。

これでエラーがでるのが結構イヤ

func update_result():
    # クリアしているかどうか判定する.
    var ball = get_node("BreakoutBall")
    if ball != null:
        ball.queue_free()
        return
 E 0:27:49:303   breakout.gd:100 @ update_result(): Node not found: "BreakoutBall" (relative to "/root/Breakout").
  <C++ Error>   Method/function failed. Returning: nullptr
  <C++ Source>  scene/main/node.cpp:1878 @ get_node()
  <Stack Trace> breakout.gd:100 @ update_result()            breakout.gd:60 @ _process()

と思ったら、 get_node_or_null() っていう関数があった!

とりあえずシングルプレイ(両方のバーの操作をキーボードに割り当て)でブロック崩しの基盤はできた。

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

オンラインにする

  • キー入力同期方式で作るよ。
  • buffer_input には送受信問わずに全ての入力が格納されている
  • クライアント側はサーバーのinputは受け取れている
  • クライアントからの送信がない
  • カメラの反転は camera.zoom = Vector2(1, -1)
    • rotate_degree はダメ

サーバー側で1回しか受け取れない?

サーバーは UDPServer.take_connection() を使って毎回新しい PacketPeerUDP を取得しています。
これは「接続を取得する」ものなので、クライアントからの2回目以降のパケットは無視される状態です。

つまり:

  • take_connection()新しい接続があったときにのみ PacketPeerUDP を返す
  • 2回目以降の同じクライアントからのデータは take_connection() では取得できない

ということで。

UDPClientNode で次のようにしていたら受け取れなかった。

func receive():
    while server.is_connection_available():
        var peer = server.take_connection()
        if peer == null:
            continue

        var packet = peer.get_packet()
        var address = peer.get_packet_ip()
        var port = peer.get_packet_port()
        var key = "%s:%s" % [address, port]

        if not peers.has(key):
            print("新しいクライアントを記録: ", key)
            peers[key] = peer

        print("Received from %s: %s" % [key, packet.get_string_from_utf8()])
        _recv_queue.append([packet, address, port])
        packet_received.emit(packet, address, port)

登録済みのpeerから取得するようにしたら直った。

func receive():
    # 新規接続がある場合は take_connection() で取得
    while server.is_connection_available():
        var peer = server.take_connection()
        if peer == null:
            continue

        var address = peer.get_packet_ip()
        var port = peer.get_packet_port()
        var key = "%s:%s" % [address, port]

        if not peers.has(key):
            print("新しいクライアントを記録: ", key)
            peers[key] = peer
        else:
            # 重複接続(再接続)であれば古い peer を置き換える
            peers[key] = peer

    # 登録済みの peer から受信処理を行う
    for key in peers.keys():
        var peer: PacketPeerUDP = peers[key]
        while peer.get_available_packet_count() > 0:
            var packet = peer.get_packet()
            var parts = key.split(":")
            var address = parts[0]
            var port = int(parts[1])
            print("Received from %s: %s" % [key, packet.get_string_from_utf8()])
            _recv_queue.append([packet, address, port])
            packet_received.emit(packet, address, port)

同期がうまくいかない

~~進められた場合はcurrent_frameを0に戻すことで同期がうまくいった。

func update(shot_pressed: bool):
    current_frame += 1
    # 自分の入力を取得
    var local_input := get_local_input(shot_pressed)
    # 自分の入力を送信
    send_input(current_frame, local_input)
    # 自分の入力をバッファに入れる
    buffer_input(current_frame, local_input)
    
    # 入力が揃っていれば進む
    if can_step():
        var inputs := step()
        inputs_ready.emit(current_frame, inputs)
        # 進められた場合はcurrent_frameを0に戻すことで同期がうまくいった。
        current_frame = 0
        print("進められるよ!", local_player_id)
    else:
        print("進められないよ", local_player_id)

シリアライザーが間違えていた!current_frame を正しく送れていなかった。

buffer.put_32(input.frame_number) # ← 正しく4バイトで格納 input.frame_number = buffer.get_32() # ← 正しく4バイトで復元

この処理に変えたら update 関数が動かなくなった。

func update(shot_pressed: bool, retry_pressed: bool):
    # 自分の入力を取得
    var local_input := get_local_input(shot_pressed, retry_pressed)
    # 自分の入力を送信
    send_input(current_frame, local_input)
    # 自分の入力をバッファに入れる
    buffer_input(current_frame, local_input)
    
    # 入力が揃っていれば進む
    if can_step():
        var inputs := step()
        inputs_ready.emit(current_frame, inputs)
        current_frame += 1

タッチした場所

このとき、PC上では正しく動作するのに、iPhone では常に左判定になった。

    if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
        var pos = get_viewport().get_mouse_position()
        var screen_width = get_viewport().size.x
        print("pos.x:", pos.x, " / viewport width:", screen_width)
        
        if pos.x < screen_width / 2:
            print("← 左判定")
            move_dir -= 1
        else:
            print("→ 右判定")
            move_dir += 1

Godotでモバイルゲームを作るメモ(Udemy)セクション4より var screen_scale = DisplayServer.screen_get_scale() を使う

修正後。これで画面の左側を押すとmove_dir +1 されるようになった。

    # タッチ/マウスによる移動
    if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
        var pos = get_viewport().get_mouse_position()
        var screen_size = get_viewport().size
        var screen_scale = DisplayServer.screen_get_scale()
        if pos.x < screen_size.x / screen_scale / 2:
            move_dir -= 1
        else:
            move_dir += 1
        print("pos.x:", pos.x, " / viewport width:", get_viewport().size.x)

ゲームステータスを同期する

ゲームステータスを同期したい。

  • リトライボタンを押しているかどうかを送信するようにしてみた。
  • 思わぬバグが見つかった。シリアライザーが間違えていた。

位置がズレる

結果が各画面で違う

  • delta を使っちゃってたせいかも
  • ブロックからバーの距離が端末ごとに違ってもダメ
  • フィールドのサイズが端末ごとに違ってもダメ
func move(dir: int):
    # お互いの端末で get_process_delta_time() の値が違うのでズレる
    var new_x = position.x + dir * speed * get_process_delta_time()
    position.x = clamp(new_x, 8 + BAR_WIDTH * 0.5, stage_size.x + 8 - BAR_WIDTH * 0.5)
  • Area2Dの判定ではなく計算で衝突と反射を実装してみた
    • まだだめ

適当に update を間引いてデバッグしてみる。

var hoge = 0

func update_playing():
    if shot_ball_button.visible:
        shot_ball_button.visible = false

    hoge += 1
    if hoge % 500 != 0:
        return
    # 入力の送信
    lockstep.update(false, false)
    ball.update(lockstep.current_frame, stage.blocks_root.get_children(), bar1, bar2)
  • current_frame は正しく送受信ができている
  • move_dir も正しく同期できていそう
  • ボールがブロックにあたるときのタイミングがおかしかった。
  • ということはバーの座標かボールの座標がおかしい?

バーのログ バーの高さは合っている???

### STEP: 1
---> id: 1 current_frame: 430
---> current_frame 430 id: 0 input move_dir: 0
---> current_frame 430 id: 1 input move_dir: 0
---> id: 1 bar1 (242.0, 160.0) bar2 (270.0, 800.0)
### STEP: 0
---> id: 0 current_frame: 430
---> current_frame 430 id: 0 input move_dir: 0
---> current_frame 430 id: 1 input move_dir: 0
---> id: 0 bar1 (298.0, 160.0) bar2 (270.0, 800.0)
---> id: 1 bar1 (242.0, 160.0) bar2 (270.0, 800.0)

ボールの位置は?

### STEP: 0
---> id: 0 current_frame: 451
---> current_frame 451 id: 1 input move_dir: 0
---> current_frame 451 id: 0 input move_dir: 0
---> id: 0 bar1 (298.0, 160.0) bar2 (270.0, 800.0)
---> id: 0 ball (298.0, 309.2001)
### STEP: 1
---> id: 1 current_frame: 451
---> current_frame 451 id: 1 input move_dir: 0
---> current_frame 451 id: 0 input move_dir: 0
---> id: 1 bar1 (242.0, 160.0) bar2 (270.0, 800.0)
---> id: 1 ball (242.0, 309.2001)

update_playing() の呼ばれ方がなんか変じゃない?

### STEP: 0
---> id: 0 current_frame: 523
---> current_frame 523 id: 1 input move_dir: 0
---> current_frame 523 id: 0 input move_dir: 0
---> id: 0 bar1 (298.0, 160.0) bar2 (270.0, 800.0)
---> id: 0 ball (298.0, 654.8015)
### STEP: 1
---> id: 1 current_frame: 523
---> current_frame 523 id: 1 input move_dir: 0
---> current_frame 523 id: 0 input move_dir: 0
---> id: 1 bar1 (242.0, 160.0) bar2 (270.0, 800.0)
---> id: 1 ball (242.0, 654.8015)
---> SKIP
---> id: 0 bar1 (298.0, 160.0) bar2 (270.0, 800.0)
---> id: 0 ball (298.0, 658.0015)
### STEP: 1
---> id: 1 current_frame: 524
---> current_frame 524 id: 0 input move_dir: 0
---> current_frame 524 id: 1 input move_dir: 0
---> id: 1 bar1 (242.0, 160.0) bar2 (270.0, 800.0)
---> id: 1 ball (242.0, 658.0015)
### STEP: 0
---> id: 0 current_frame: 524
---> current_frame 524 id: 0 input move_dir: 0
---> current_frame 524 id: 1 input move_dir: 0
---> id: 0 bar1 (298.0, 160.0) bar2 (270.0, 800.0)
---> id: 0 ball (298.0, 661.2015)
---> SKIP
---> id: 1 bar1 (242.0, 160.0) bar2 (270.0, 800.0)
---> id: 1 ball (242.0, 661.2015)

わかった。SKIP している時にボールを動かしてしまっているから、ずれるんだ。

  • ボールを動かす処理を can_step のときにする
  • でもまだずれる
  • バーの動きを反転しているせいなのでは?
func _on_inputs_ready(frame: int, inputs: Array):
    for input_data in inputs:
        if input_data.player_id == 0:
            if is_server:
                bar1.move(input_data.move_dir)
            else:
                # 反転させる.  <- これが余計かも
                bar1.move(-input_data.move_dir)
                if input_data.retry_pressed:
                    opponent_retry_pressed = true
        elif input_data.player_id == 1:
            if is_server:
                # 反転させる. <- これが余計かも
                bar2.move(-input_data.move_dir)
                if input_data.retry_pressed:
                    opponent_retry_pressed = true
            else:
                bar2.move(input_data.move_dir)

        if input_data.shot_pressed:
            fire_ball_for(input_data.player_id)

    if ball:
        ball.update(frame, stage.blocks_root.get_children(), bar1, bar2)

これでいいんでない?

func _on_inputs_ready(frame: int, inputs: Array):
    for input_data in inputs:
        if input_data.player_id == 0:
            bar1.move(input_data.move_dir)
            if input_data.retry_pressed:
                opponent_retry_pressed = true
        elif input_data.player_id == 1:
            bar2.move(input_data.move_dir)
            if input_data.retry_pressed:
                opponent_retry_pressed = true

        if input_data.shot_pressed:
            fire_ball_for(input_data.player_id)

    if ball:
        ball.update(frame, stage.blocks_root.get_children(), bar1, bar2)

同期できた!!!!!!!!

ちゃんと結果が一致していそう

のこり

  • 次のステージに進んだ後にボールが発射できない。
    • できるときとできないときがある?
    • これはパケロスのせいで起きていたっぽい。
  • あとボールの速度が遅い
    • 環境によって速度が違う
  • どちらからでも誘えるようにする
    • TCPですでに接続している状態でUDPへ誘うのでTCPの方からIPアドレスの情報をもらう
    • どちらも StreamPeerTCP から get_connected_host() で相手のIPアドレスがとれる
  • リトライの時に位置がズレることがあるっぽい
    • 起きなくなった?
  • リトライができない時がある
    • これもパケロスか?
    • 冗長データ追加したけどダメやね
    • 相手からのフラグを受け取れていない
    • リトライボタンを押したときのフラグをそのまますぐに使うからダメ
      • 両方のフラグの受け取りが完全に完了したタイミングでリトライする
  • 一度ずれると一生スキップされちゃうっぽいな
    • っていうかもしかして、UDPだからパケロスしたときにずれるんか?
    • これは本に書いてある通りに 冗長データを送るようにしよう。
    • ENetを使おうとかがでてくるのね
  • 同期がずれるときがあるっぽい?
  • 全てのステージをクリアをした時にゲームクリアが出ていない。
    • 修正Done

冗長データを送ると次のWarning がでる。Buffer full らしい

W 0:00:32:365   udp_client_node.gd:27 @ receive(): Buffer full, dropping packets!
  <C++ Source>  core/io/packet_peer_udp.cpp:319 @ _poll()
  <Stack Trace> udp_client_node.gd:27 @ receive()
                udp_client_node.gd:16 @ _process()
                transport_udp.gd:36 @ _process()

if で判定していたので毎回1個しか取り出していなかった。届いたデータを全て受け取ったら解決した。

func receive():
    # ×: if peer.get_available_packet_count() > 0:
    while peer.get_available_packet_count() > 0:
        var pkt = peer.get_packet()
        _recv_queue.append(pkt)
        packet_received.emit(pkt)
        connected = true

冗長データを3フレーム分送るだけでだいぶ改善された。

リトライがうまくいかない問題は次のようにフラグを分けたことで解決

func update_game_over():
    if not game_over_panel.visible:
        game_over_panel.visible = true
    # local_retry_pressed というフラグにした
    lockstep.update(false, local_retry_pressed)
    
    print("# RETRY id: %d, local: %s  opponent %s" % [local_id, retry_pressed1, retry_pressed2])
    # ローカルの入力も即時ではなくステップが実行される時にtrueにする
    if retry_pressed1 and retry_pressed2:
        retry_game()

ステップが進められないとき

ステップが進められないときに警告を出すようにしたい。 一定フレーム届かなかったときにシグナルを送信することでできた。

func update(shot_pressed: bool, retry_pressed: bool):
    # 自分の入力を取得
    var local_input := get_local_input(shot_pressed, retry_pressed)
    # 自分の入力を送信
    send_input(current_frame, local_input)
    # 自分の入力をバッファに入れる
    buffer_input(current_frame, local_input)
    
    # 入力が揃っていれば進む
    if can_step():
        var inputs := step()
        inputs_ready.emit(current_frame, inputs)
        #print("---> id: %d current_frame: %d" % [local_player_id, current_frame])
        #for input in inputs:
        #	print("---> current_frame %d id: %d input move_dir: %d" % [current_frame, input.player_id , input.move_dir])
        current_frame += 1
        if is_stalled:
            is_stalled = false
            lockstep_resumed.emit()
        wait_frame_counter = 0
    else:
        wait_frame_counter += 1
        if not is_stalled and wait_frame_counter >= MAX_WAIT_FRAMES:
            is_stalled = true
            lockstep_stalled.emit()

実行環境によって速度が違う

  • _process(delta) ではなく _physics_process(delta: float)を使うことで一定にできる。

課題

とりあえず完成とする。

  • 稀に結果がズレることがある。理由はまだ不明
  • 動作がカクカク
    • lerp を使えばおけ?
      • これでもカクカク
    • これは buffer_delay が定義だけされていて使われていなかったので数フレーム分揃ったら実行するようにした
      • めちゃぬるぬるになった!
      • p146~149 の内容
      • キー入力遅延を実装するだけでここまで変わるのかという感じ

デバッグをもっとしてみないとわからないが、キー入力遅延をちゃんと実装したことにより無事に納得できる形になった。

最終的に current_frame は毎 _physics_process で increment されるようにした。

func update(shot_pressed: bool, retry_pressed: bool):
    var local_input := get_local_input(shot_pressed, retry_pressed)
    local_input.frame_number = current_frame

    send_input(current_frame, local_input)
    buffer_input(current_frame, local_input)

    var step_frame = current_frame - buffer_delay
    if step_frame >= 0 and can_step(step_frame):
        var inputs := step(step_frame)
        inputs_ready.emit(step_frame, inputs)
        if is_stalled:
            is_stalled = false
            lockstep_resumed.emit()
        wait_frame_counter = 0
    else:
        wait_frame_counter += 1
        if not is_stalled and wait_frame_counter >= MAX_WAIT_FRAMES:
            is_stalled = true
            lockstep_stalled.emit()

    current_frame += 1

つぎ!

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

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