PC3 Phase 2.3: networked play path in the game
- Session autoload: carries {world_id, host, port} from the world browser
to the game scene.
- world_list.gd "Enter World": POST /api/worlds/{id}/join -> Session.begin
-> load game.tscn. On 503 (backend has no orchestration) falls back to
local single-player; other errors surface on the card.
- api_client.gd: join_world().
- game.gd (new, on game.tscn root): local single-player by default; when
Session.active, connects a NetworkClient to the world's dedicated server
and runs networked -- forwards the local Player's input each physics
frame, snaps to the server position past a generous drift threshold
(the Player keeps predicting locally with real collision), renders other
players from snapshots (labelled capsules, lerped -- avatar appearance
sync is a follow-up, the server doesn't replicate loadouts yet), and
returns to the world list on disconnect.
- player.gd: exposes net_input (the input frame it applied) and
apply_server_correction().
- network_client.gd: send_input() + get_own_server_state() for callers that
do their own collision-aware prediction.
- scripts/mp_game_flow_client.gd + run_game_multiplayer_test.sh: two real
clients each register -> join via the API -> game.tscn -> networked;
A sees B and moves. PASS.
Verified end to end against an orchestration-enabled backend. Non-networked
smokes green (enter_world 9/9, world_list 5/5, player 15/15,
avatar_in_world 10/10, main_menu_full_flow 12/12).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
@@ -15,6 +15,7 @@ agp/api_base_url="http://127.0.0.1:8000"
|
|||||||
|
|
||||||
ApiClient="*res://scripts/api_client.gd"
|
ApiClient="*res://scripts/api_client.gd"
|
||||||
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
|
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
|
||||||
|
Session="*res://scripts/session.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,9 +1,11 @@
|
|||||||
[gd_scene load_steps=3 format=3 uid="uid://game_scene_pc2"]
|
[gd_scene load_steps=4 format=3 uid="uid://game_scene_pc2"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/game.gd" id="0_game"]
|
||||||
[ext_resource type="PackedScene" uid="uid://c49a36fc7db_test_level" path="res://scenes/worlds/test_level.tscn" id="1_level"]
|
[ext_resource type="PackedScene" uid="uid://c49a36fc7db_test_level" path="res://scenes/worlds/test_level.tscn" id="1_level"]
|
||||||
[ext_resource type="PackedScene" uid="uid://player_scene_001" path="res://scenes/player.tscn" id="2_player"]
|
[ext_resource type="PackedScene" uid="uid://player_scene_001" path="res://scenes/player.tscn" id="2_player"]
|
||||||
|
|
||||||
[node name="Game" type="Node3D"]
|
[node name="Game" type="Node3D"]
|
||||||
|
script = ExtResource("0_game")
|
||||||
|
|
||||||
[node name="Level" parent="." instance=ExtResource("1_level")]
|
[node name="Level" parent="." instance=ExtResource("1_level")]
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,13 @@ func get_avatar_loadout() -> Dictionary:
|
|||||||
return await request(HTTPClient.METHOD_GET, "/api/avatar/loadout", "", "application/json", true)
|
return await request(HTTPClient.METHOD_GET, "/api/avatar/loadout", "", "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Ask the backend for a dedicated server to host [param world_id] and return
|
||||||
|
## its address. On success result.data is {"world_id", "host", "port"}.
|
||||||
|
## 503 if this backend has no game-server orchestration.
|
||||||
|
func join_world(world_id: int) -> Dictionary:
|
||||||
|
return await request(HTTPClient.METHOD_POST, "/api/worlds/%d/join" % world_id, "", "application/json", true)
|
||||||
|
|
||||||
|
|
||||||
## Save (replace) the current user's avatar loadout (requires auth).
|
## Save (replace) the current user's avatar loadout (requires auth).
|
||||||
## @param loadout A dict in the AvatarLoadout.to_json() shape
|
## @param loadout A dict in the AvatarLoadout.to_json() shape
|
||||||
## ({"base_body": String, "slots": {...}, "skin_tone"?: {...}}).
|
## ({"base_body": String, "slots": {...}, "skin_tone"?: {...}}).
|
||||||
|
|||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
extends Node3D
|
||||||
|
## Root controller for scenes/game.tscn.
|
||||||
|
##
|
||||||
|
## Local single-player by default (walk around the test level). When
|
||||||
|
## Session.active (set by world_list.gd after POST /api/worlds/{id}/join),
|
||||||
|
## connects a NetworkClient to the world's dedicated server and runs
|
||||||
|
## networked: the local Player keeps predicting with its own collision-aware
|
||||||
|
## movement, its input is forwarded to the server each physics frame, the
|
||||||
|
## server's authoritative position is a correction backstop, and other
|
||||||
|
## players are rendered from snapshots.
|
||||||
|
##
|
||||||
|
## Appearance sync is a follow-up -- remote players show as labelled capsules,
|
||||||
|
## not their own avatars (the server doesn't replicate loadouts yet).
|
||||||
|
|
||||||
|
const REMOTE_LERP := 12.0
|
||||||
|
## Snap the local player to the server position past this drift (metres).
|
||||||
|
## Generous -- only catches real desync / teleport / a wall the client walked
|
||||||
|
## through, not normal prediction noise.
|
||||||
|
const CORRECTION_THRESHOLD := 1.5
|
||||||
|
|
||||||
|
@onready var _player: Player = $Player
|
||||||
|
|
||||||
|
var _net: NetworkClient = null
|
||||||
|
var _networked := false
|
||||||
|
var _remotes: Dictionary = {} # peer_id -> Node3D
|
||||||
|
var _status_label: Label = null
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if Session.active:
|
||||||
|
_setup_networked()
|
||||||
|
|
||||||
|
|
||||||
|
func _setup_networked() -> void:
|
||||||
|
_show_status("Connecting to %s…" % Session.host)
|
||||||
|
_net = NetworkClient.new()
|
||||||
|
_net.name = "NetworkClient"
|
||||||
|
add_child(_net)
|
||||||
|
_net.server_disconnected.connect(_on_server_lost)
|
||||||
|
_net.connection_failed.connect(_on_server_lost)
|
||||||
|
|
||||||
|
var ok: bool = await _net.connect_to_server(Session.host, Session.port)
|
||||||
|
if not ok:
|
||||||
|
_on_server_lost()
|
||||||
|
return
|
||||||
|
_networked = true
|
||||||
|
_hide_status()
|
||||||
|
# Physics-process AFTER the player so net_input reflects this frame.
|
||||||
|
process_physics_priority = 10
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
if not _networked or _net == null or not _net.is_connected_to_server():
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Forward the local player's input to the server.
|
||||||
|
var inp: Dictionary = _player.net_input
|
||||||
|
_net.send_input(inp["move_x"], inp["move_z"], inp["jump"], inp["look_yaw"])
|
||||||
|
|
||||||
|
# 2. Server-authoritative correction backstop.
|
||||||
|
var own: Dictionary = _net.get_own_server_state()
|
||||||
|
if own.has("position"):
|
||||||
|
var server_pos: Vector3 = own["position"]
|
||||||
|
if _player.global_position.distance_to(server_pos) > CORRECTION_THRESHOLD:
|
||||||
|
_player.apply_server_correction(server_pos)
|
||||||
|
|
||||||
|
# 3. Remote players.
|
||||||
|
_sync_remotes(_net.get_remote_states(), _delta)
|
||||||
|
|
||||||
|
|
||||||
|
func _sync_remotes(states: Dictionary, delta: float) -> void:
|
||||||
|
for pid in states:
|
||||||
|
var st: Dictionary = states[pid]
|
||||||
|
var node: Node3D = _remotes.get(pid)
|
||||||
|
if node == null:
|
||||||
|
node = _make_remote(pid)
|
||||||
|
_remotes[pid] = node
|
||||||
|
add_child(node)
|
||||||
|
node.global_position = st.get("position", Vector3.ZERO)
|
||||||
|
var target: Vector3 = st.get("position", node.global_position)
|
||||||
|
node.global_position = node.global_position.lerp(target, clampf(REMOTE_LERP * delta, 0.0, 1.0))
|
||||||
|
node.rotation.y = st.get("rotation_y", node.rotation.y)
|
||||||
|
|
||||||
|
for pid in _remotes.keys():
|
||||||
|
if not states.has(pid):
|
||||||
|
_remotes[pid].queue_free()
|
||||||
|
_remotes.erase(pid)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_remote(pid: int) -> Node3D:
|
||||||
|
var root := Node3D.new()
|
||||||
|
root.name = "Remote_%d" % pid
|
||||||
|
|
||||||
|
var body := MeshInstance3D.new()
|
||||||
|
var capsule := CapsuleMesh.new()
|
||||||
|
capsule.radius = 0.35
|
||||||
|
capsule.height = 1.7
|
||||||
|
body.mesh = capsule
|
||||||
|
body.position = Vector3(0, 0.85, 0)
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.6, 0.9)
|
||||||
|
body.material_override = mat
|
||||||
|
root.add_child(body)
|
||||||
|
|
||||||
|
var tag := Label3D.new()
|
||||||
|
tag.text = "Player %d" % pid
|
||||||
|
tag.position = Vector3(0, 2.0, 0)
|
||||||
|
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
|
tag.no_depth_test = true
|
||||||
|
tag.pixel_size = 0.004
|
||||||
|
root.add_child(tag)
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
func _on_server_lost() -> void:
|
||||||
|
_networked = false
|
||||||
|
_show_status("Disconnected from the server. Returning to worlds…")
|
||||||
|
await get_tree().create_timer(1.5).timeout
|
||||||
|
Session.clear()
|
||||||
|
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── tiny on-screen status ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func _show_status(text: String) -> void:
|
||||||
|
if _status_label == null:
|
||||||
|
var layer := CanvasLayer.new()
|
||||||
|
layer.name = "StatusLayer"
|
||||||
|
add_child(layer)
|
||||||
|
_status_label = Label.new()
|
||||||
|
_status_label.position = Vector2(16, 16)
|
||||||
|
_status_label.add_theme_font_size_override("font_size", 18)
|
||||||
|
layer.add_child(_status_label)
|
||||||
|
_status_label.text = text
|
||||||
|
_status_label.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
func _hide_status() -> void:
|
||||||
|
if _status_label:
|
||||||
|
_status_label.visible = false
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## One client process for the full networked game-flow test
|
||||||
|
## (scripts/run_game_multiplayer_test.sh): register -> POST /api/worlds/{id}/join
|
||||||
|
## -> Session.begin -> load scenes/game.tscn -> run networked.
|
||||||
|
##
|
||||||
|
## godot --headless --path . --script res://scripts/mp_game_flow_client.gd -- \
|
||||||
|
## --role a|b --world 1
|
||||||
|
##
|
||||||
|
## role b: joins, holds still, prints RESULT b in_world and stays ~40 s.
|
||||||
|
## role a: joins, moves forward, prints RESULT a remotes=<n> moved=<bool>;
|
||||||
|
## exits 0 iff it saw a remote player node AND moved.
|
||||||
|
|
||||||
|
var role := "b"
|
||||||
|
var world_id := 1
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var args := OS.get_cmdline_user_args()
|
||||||
|
for i in args.size():
|
||||||
|
if args[i] == "--role" and i + 1 < args.size():
|
||||||
|
role = args[i + 1]
|
||||||
|
elif args[i] == "--world" and i + 1 < args.size():
|
||||||
|
world_id = int(args[i + 1])
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
var api := root.get_node("ApiClient")
|
||||||
|
var session := root.get_node("Session")
|
||||||
|
|
||||||
|
var uname := "mpflow_%s_%d" % [role, int(Time.get_unix_time_from_system() * 1000) % 1000000]
|
||||||
|
var reg: Dictionary = await api.register(uname, uname + "@e.com", "secret123", true)
|
||||||
|
if not reg.ok:
|
||||||
|
print("RESULT %s register_failed %s" % [role, reg.detail])
|
||||||
|
quit(1); return
|
||||||
|
|
||||||
|
var join: Dictionary = await api.join_world(world_id)
|
||||||
|
if not (join.ok and join.data is Dictionary):
|
||||||
|
print("RESULT %s join_failed status=%d %s" % [role, join.status, join.detail])
|
||||||
|
quit(1); return
|
||||||
|
var d: Dictionary = join.data
|
||||||
|
session.begin(world_id, str(d["host"]), int(d["port"]))
|
||||||
|
|
||||||
|
change_scene_to_file("res://scenes/game.tscn")
|
||||||
|
# Wait for the scene + NetworkClient connect (game.gd awaits internally).
|
||||||
|
for i in 180:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var game := current_scene
|
||||||
|
var netc = game.get_node_or_null("NetworkClient") if game else null
|
||||||
|
if netc == null or not netc.is_connected_to_server():
|
||||||
|
print("RESULT %s not_connected" % role)
|
||||||
|
quit(1); return
|
||||||
|
|
||||||
|
if role == "b":
|
||||||
|
print("RESULT b in_world")
|
||||||
|
for i in 1200:
|
||||||
|
await process_frame
|
||||||
|
quit(0); return
|
||||||
|
|
||||||
|
# role a: move and look for a remote node.
|
||||||
|
var player = game.get_node("Player")
|
||||||
|
var start: Vector3 = player.global_position
|
||||||
|
var max_remotes := 0
|
||||||
|
for i in 120:
|
||||||
|
# Drive the player forward by faking held input.
|
||||||
|
player._input_direction = Vector2(0, 1)
|
||||||
|
await process_frame
|
||||||
|
var n := 0
|
||||||
|
for c in game.get_children():
|
||||||
|
if c.name.begins_with("Remote_"):
|
||||||
|
n += 1
|
||||||
|
max_remotes = maxi(max_remotes, n)
|
||||||
|
var moved: bool = player.global_position.distance_to(start) > 1.0
|
||||||
|
print("RESULT a remotes=%d moved=%s" % [max_remotes, str(moved)])
|
||||||
|
quit(0 if (moved and max_remotes >= 1) else 1)
|
||||||
@@ -56,6 +56,8 @@ var _on_floor: bool = true
|
|||||||
## peer_id -> latest authoritative state dict from the most recent snapshot,
|
## peer_id -> latest authoritative state dict from the most recent snapshot,
|
||||||
## for every peer that isn't us. The game reads this to render other players.
|
## for every peer that isn't us. The game reads this to render other players.
|
||||||
var _remote_states: Dictionary = {}
|
var _remote_states: Dictionary = {}
|
||||||
|
## Our own latest authoritative state from the most recent snapshot.
|
||||||
|
var _own_state: Dictionary = {}
|
||||||
var _last_snapshot_tick: int = -1
|
var _last_snapshot_tick: int = -1
|
||||||
|
|
||||||
|
|
||||||
@@ -188,6 +190,7 @@ func _handle_snapshot(snap: Dictionary) -> void:
|
|||||||
var mine: Variant = players.get(my_peer_id, players.get(str(my_peer_id), null))
|
var mine: Variant = players.get(my_peer_id, players.get(str(my_peer_id), null))
|
||||||
if not (mine is Dictionary):
|
if not (mine is Dictionary):
|
||||||
return
|
return
|
||||||
|
_own_state = mine
|
||||||
var auth_pos: Vector3 = mine.get("position", position)
|
var auth_pos: Vector3 = mine.get("position", position)
|
||||||
var auth_vel: Vector3 = mine.get("velocity", velocity)
|
var auth_vel: Vector3 = mine.get("velocity", velocity)
|
||||||
var acked_seq: int = int(mine.get("last_seq", 0))
|
var acked_seq: int = int(mine.get("last_seq", 0))
|
||||||
@@ -238,3 +241,19 @@ func _handle_snapshot(snap: Dictionary) -> void:
|
|||||||
## recent snapshot. Read this each frame to spawn/update remote avatars.
|
## recent snapshot. Read this each frame to spawn/update remote avatars.
|
||||||
func get_remote_states() -> Dictionary:
|
func get_remote_states() -> Dictionary:
|
||||||
return _remote_states
|
return _remote_states
|
||||||
|
|
||||||
|
|
||||||
|
## Our own latest authoritative state from the most recent snapshot, or {}.
|
||||||
|
func get_own_server_state() -> Dictionary:
|
||||||
|
return _own_state
|
||||||
|
|
||||||
|
|
||||||
|
## Send an input frame to the server WITHOUT running NetworkClient's own
|
||||||
|
## flat-plane prediction -- for callers that predict locally with their own
|
||||||
|
## collision-aware CharacterBody3D (scripts/game.gd) and only want the server
|
||||||
|
## as an authority/correction backstop.
|
||||||
|
func send_input(move_x: float, move_z: float, jump: bool, look_yaw: float) -> void:
|
||||||
|
_seq += 1
|
||||||
|
if _player_proxy and is_instance_valid(_player_proxy):
|
||||||
|
_player_proxy.rpc_id(1, "rpc_request_input",
|
||||||
|
{"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq)
|
||||||
|
|||||||
@@ -154,14 +154,23 @@ func _handle_mouse_look(event: InputEventMouseMotion) -> void:
|
|||||||
# Physics — gravity, jump, move_and_slide
|
# Physics — gravity, jump, move_and_slide
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## The input frame this player applied last physics tick, in the shape the
|
||||||
|
## network layer sends: world-space move direction (already yaw-transformed and
|
||||||
|
## normalized), jump edge, body yaw. scripts/game.gd reads this each frame in
|
||||||
|
## networked mode and forwards it to the server.
|
||||||
|
var net_input: Dictionary = {"move_x": 0.0, "move_z": 0.0, "jump": false, "look_yaw": 0.0}
|
||||||
|
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
# Apply gravity.
|
# Apply gravity.
|
||||||
if not is_on_floor():
|
if not is_on_floor():
|
||||||
velocity.y -= GRAVITY * delta
|
velocity.y -= GRAVITY * delta
|
||||||
|
|
||||||
# Jump.
|
# Jump.
|
||||||
|
var jumped := false
|
||||||
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
|
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
|
||||||
velocity.y = JUMP_VELOCITY
|
velocity.y = JUMP_VELOCITY
|
||||||
|
jumped = true
|
||||||
|
|
||||||
# Build movement direction from input. Godot's local "forward" is -Z, not
|
# Build movement direction from input. Godot's local "forward" is -Z, not
|
||||||
# +Z, so W (input.y = +1) must use -basis.z -- using +basis.z here would
|
# +Z, so W (input.y = +1) must use -basis.z -- using +basis.z here would
|
||||||
@@ -173,3 +182,16 @@ func _physics_process(delta: float) -> void:
|
|||||||
velocity.z = direction.z * WALK_SPEED
|
velocity.z = direction.z * WALK_SPEED
|
||||||
|
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
|
|
||||||
|
net_input = {
|
||||||
|
"move_x": direction.x, "move_z": direction.z,
|
||||||
|
"jump": jumped, "look_yaw": rotation.y,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## Server-authoritative correction: called by scripts/game.gd when the local
|
||||||
|
## predicted position has drifted too far from the server's.
|
||||||
|
func apply_server_correction(pos: Vector3) -> void:
|
||||||
|
global_position = pos
|
||||||
|
velocity.x = 0.0
|
||||||
|
velocity.z = 0.0
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Full networked game flow, two real client processes:
|
||||||
|
# register -> POST /api/worlds/{id}/join -> Session -> game.tscn -> networked.
|
||||||
|
# Client A must see client B rendered as a remote player and its own movement
|
||||||
|
# must round-trip. Requires a backend with GAME_SERVERS_ENABLED and the Godot
|
||||||
|
# binary reachable from it.
|
||||||
|
#
|
||||||
|
# Usage: scripts/run_game_multiplayer_test.sh <backend_base_url> <world_id>
|
||||||
|
set -uo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
BASE_URL="${1:?usage: run_game_multiplayer_test.sh <backend_url> <world_id>}"
|
||||||
|
WORLD_ID="${2:?usage: run_game_multiplayer_test.sh <backend_url> <world_id>}"
|
||||||
|
GODOT_BIN="${GODOT_BIN:-godot}"
|
||||||
|
command -v "$GODOT_BIN" >/dev/null 2>&1 || GODOT_BIN="$HOME/godot/godot"
|
||||||
|
|
||||||
|
export AGP_API_BASE_URL="$BASE_URL"
|
||||||
|
|
||||||
|
cleanup() { kill "${B_PID:-}" 2>/dev/null; wait 2>/dev/null; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "=== client B: join world $WORLD_ID and hold ==="
|
||||||
|
"$GODOT_BIN" --headless --path . --script res://scripts/mp_game_flow_client.gd -- \
|
||||||
|
--role b --world "$WORLD_ID" >/tmp/mpflow_b.log 2>&1 &
|
||||||
|
B_PID=$!
|
||||||
|
for _ in $(seq 1 40); do grep -q 'RESULT b in_world' /tmp/mpflow_b.log && break; sleep 1; done
|
||||||
|
grep -q 'RESULT b in_world' /tmp/mpflow_b.log || { echo "B never entered the world:"; tail -5 /tmp/mpflow_b.log; echo "=== SMOKE RESULT: FAIL ==="; exit 1; }
|
||||||
|
|
||||||
|
echo "=== client A: join world $WORLD_ID, move, look for B ==="
|
||||||
|
"$GODOT_BIN" --headless --path . --script res://scripts/mp_game_flow_client.gd -- \
|
||||||
|
--role a --world "$WORLD_ID" 2>&1 | tee /tmp/mpflow_a.log
|
||||||
|
A_LINE=$(grep '^RESULT a' /tmp/mpflow_a.log || true)
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "A: ${A_LINE:-<none>}"
|
||||||
|
echo "B: $(grep '^RESULT b' /tmp/mpflow_b.log || echo '<none>')"
|
||||||
|
|
||||||
|
if echo "$A_LINE" | grep -q 'moved=true' && echo "$A_LINE" | grep -qE 'remotes=[1-9]'; then
|
||||||
|
echo "=== SMOKE RESULT: PASS ==="
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "=== SMOKE RESULT: FAIL ==="
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
extends Node
|
||||||
|
## Autoload "Session" -- carries the "which world am I joining" handoff from
|
||||||
|
## the world browser to the game scene.
|
||||||
|
##
|
||||||
|
## world_list.gd calls begin() with the address returned by
|
||||||
|
## POST /api/worlds/{id}/join; game.gd reads it in _ready() to decide whether
|
||||||
|
## to run networked or fall back to local single-player (e.g. game.tscn opened
|
||||||
|
## directly, or the backend has no orchestration).
|
||||||
|
|
||||||
|
var world_id: int = 0
|
||||||
|
var host: String = ""
|
||||||
|
var port: int = 0
|
||||||
|
var active: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func begin(p_world_id: int, p_host: String, p_port: int) -> void:
|
||||||
|
world_id = p_world_id
|
||||||
|
host = p_host
|
||||||
|
port = p_port
|
||||||
|
active = true
|
||||||
|
|
||||||
|
|
||||||
|
func clear() -> void:
|
||||||
|
world_id = 0
|
||||||
|
host = ""
|
||||||
|
port = 0
|
||||||
|
active = false
|
||||||
+29
-8
@@ -135,15 +135,16 @@ func _create_world_card(world_dict: Dictionary) -> Control:
|
|||||||
meta_label.add_theme_font_size_override("font_size", 12)
|
meta_label.add_theme_font_size_override("font_size", 12)
|
||||||
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
|
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
|
||||||
|
|
||||||
# PC2: the only thing a world actually loads into right now is the one
|
# "Enter World" asks the backend to host this world (POST /join) and then
|
||||||
# shared playable test level (scenes/game.tscn) -- there's no per-world
|
# loads scenes/game.tscn networked. If the backend has no game-server
|
||||||
# content yet, so "Enter" doesn't need the world's id for anything.
|
# orchestration (a plain dev instance), it falls back to local
|
||||||
# Real per-world level loading is a later concern once worlds actually
|
# single-player. All worlds still load the same test level -- per-world
|
||||||
# have their own content to load.
|
# content is a later milestone.
|
||||||
|
var world_id := int(world_dict.get("id", 0))
|
||||||
var enter_button := Button.new()
|
var enter_button := Button.new()
|
||||||
enter_button.text = "Enter World"
|
enter_button.text = "Enter World"
|
||||||
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||||
enter_button.pressed.connect(_on_enter_world_pressed)
|
enter_button.pressed.connect(_on_enter_world_pressed.bind(world_id, enter_button))
|
||||||
|
|
||||||
card.add_child(name_label)
|
card.add_child(name_label)
|
||||||
card.add_child(desc_label)
|
card.add_child(desc_label)
|
||||||
@@ -153,5 +154,25 @@ func _create_world_card(world_dict: Dictionary) -> Control:
|
|||||||
return panel
|
return panel
|
||||||
|
|
||||||
|
|
||||||
func _on_enter_world_pressed() -> void:
|
func _on_enter_world_pressed(world_id: int, button: Button) -> void:
|
||||||
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
button.disabled = true
|
||||||
|
button.text = "Joining…"
|
||||||
|
|
||||||
|
var result := await ApiClient.join_world(world_id)
|
||||||
|
|
||||||
|
if result.ok and result.data is Dictionary:
|
||||||
|
var d: Dictionary = result.data
|
||||||
|
Session.begin(world_id, str(d.get("host", "")), int(d.get("port", 0)))
|
||||||
|
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
||||||
|
return
|
||||||
|
|
||||||
|
if result.status == 503:
|
||||||
|
# This backend can't host game servers -- go in local single-player.
|
||||||
|
Session.clear()
|
||||||
|
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
||||||
|
return
|
||||||
|
|
||||||
|
button.disabled = false
|
||||||
|
button.text = "Enter World"
|
||||||
|
error_label.text = "Couldn't join: %s" % result.detail
|
||||||
|
error_label.visible = true
|
||||||
|
|||||||
Reference in New Issue
Block a user