diff --git a/project.godot b/project.godot index 96dca88..33185ee 100644 --- a/project.godot +++ b/project.godot @@ -15,6 +15,7 @@ agp/api_base_url="http://127.0.0.1:8000" ApiClient="*res://scripts/api_client.gd" AvatarSystem="*res://scripts/avatar/avatar_system.gd" +Session="*res://scripts/session.gd" [display] diff --git a/scenes/game.tscn b/scenes/game.tscn index 4bdcbc2..3e66735 100644 --- a/scenes/game.tscn +++ b/scenes/game.tscn @@ -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://player_scene_001" path="res://scenes/player.tscn" id="2_player"] [node name="Game" type="Node3D"] +script = ExtResource("0_game") [node name="Level" parent="." instance=ExtResource("1_level")] diff --git a/scripts/api_client.gd b/scripts/api_client.gd index cb337d3..99c8f8b 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -229,6 +229,13 @@ func get_avatar_loadout() -> Dictionary: 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). ## @param loadout A dict in the AvatarLoadout.to_json() shape ## ({"base_body": String, "slots": {...}, "skin_tone"?: {...}}). diff --git a/scripts/game.gd b/scripts/game.gd new file mode 100644 index 0000000..afe1de7 --- /dev/null +++ b/scripts/game.gd @@ -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 diff --git a/scripts/mp_game_flow_client.gd b/scripts/mp_game_flow_client.gd new file mode 100644 index 0000000..68d716f --- /dev/null +++ b/scripts/mp_game_flow_client.gd @@ -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= moved=; +## 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) diff --git a/scripts/network_client.gd b/scripts/network_client.gd index b5f95a2..c036212 100644 --- a/scripts/network_client.gd +++ b/scripts/network_client.gd @@ -56,6 +56,8 @@ var _on_floor: bool = true ## 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. var _remote_states: Dictionary = {} +## Our own latest authoritative state from the most recent snapshot. +var _own_state: Dictionary = {} 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)) if not (mine is Dictionary): return + _own_state = mine var auth_pos: Vector3 = mine.get("position", position) var auth_vel: Vector3 = mine.get("velocity", velocity) 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. func get_remote_states() -> Dictionary: 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) diff --git a/scripts/player.gd b/scripts/player.gd index 828ac3f..996c9c5 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -154,14 +154,23 @@ func _handle_mouse_look(event: InputEventMouseMotion) -> void: # 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: # Apply gravity. if not is_on_floor(): velocity.y -= GRAVITY * delta # Jump. + var jumped := false if is_on_floor() and Input.is_action_just_pressed("ui_accept"): velocity.y = JUMP_VELOCITY + jumped = true # 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 @@ -173,3 +182,16 @@ func _physics_process(delta: float) -> void: velocity.z = direction.z * WALK_SPEED 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 diff --git a/scripts/run_game_multiplayer_test.sh b/scripts/run_game_multiplayer_test.sh new file mode 100644 index 0000000..8e2de5f --- /dev/null +++ b/scripts/run_game_multiplayer_test.sh @@ -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 +set -uo pipefail +cd "$(dirname "$0")/.." + +BASE_URL="${1:?usage: run_game_multiplayer_test.sh }" +WORLD_ID="${2:?usage: run_game_multiplayer_test.sh }" +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:-}" +echo "B: $(grep '^RESULT b' /tmp/mpflow_b.log || echo '')" + +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 diff --git a/scripts/session.gd b/scripts/session.gd new file mode 100644 index 0000000..6e940b9 --- /dev/null +++ b/scripts/session.gd @@ -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 diff --git a/scripts/world_list.gd b/scripts/world_list.gd index 7e7b5b0..0c4302e 100644 --- a/scripts/world_list.gd +++ b/scripts/world_list.gd @@ -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_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 - # shared playable test level (scenes/game.tscn) -- there's no per-world - # content yet, so "Enter" doesn't need the world's id for anything. - # Real per-world level loading is a later concern once worlds actually - # have their own content to load. + # "Enter World" asks the backend to host this world (POST /join) and then + # loads scenes/game.tscn networked. If the backend has no game-server + # orchestration (a plain dev instance), it falls back to local + # single-player. All worlds still load the same test level -- per-world + # content is a later milestone. + var world_id := int(world_dict.get("id", 0)) var enter_button := Button.new() enter_button.text = "Enter World" 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(desc_label) @@ -153,5 +154,25 @@ func _create_world_card(world_dict: Dictionary) -> Control: return panel -func _on_enter_world_pressed() -> void: - get_tree().change_scene_to_file("res://scenes/game.tscn") +func _on_enter_world_pressed(world_id: int, button: Button) -> void: + 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