From c5d2eb6bd22d53f4b57b838cddb5227392adfd72 Mon Sep 17 00:00:00 2001 From: agent-bot Date: Mon, 17 Aug 2026 16:59:27 +0000 Subject: [PATCH] WIP checkpoint: Implement Godot dedicated server scene + player spawning --- scenes/player.tscn | 6 ++ scripts/player_node.gd | 220 +++++++++++++++++++++++++++++++++++++++++ scripts/server_main.gd | 156 +++++++++++++++++++++++++++++ 3 files changed, 382 insertions(+) create mode 100644 scenes/player.tscn create mode 100644 scripts/player_node.gd create mode 100644 scripts/server_main.gd diff --git a/scenes/player.tscn b/scenes/player.tscn new file mode 100644 index 0000000..6c6f8be --- /dev/null +++ b/scenes/player.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/player_node.gd" id="1"] + +[node name="Player" type="Node3D"] +script = ExtResource("1") diff --git a/scripts/player_node.gd b/scripts/player_node.gd new file mode 100644 index 0000000..a91c5f2 --- /dev/null +++ b/scripts/player_node.gd @@ -0,0 +1,220 @@ +extends CharacterBody3D +## PlayerNode - Server-side player entity. +## +## Represents a connected player on the server. Handles movement, +## input processing, and state synchronization. + +class_name PlayerNode + + +# --- Signals --- +signal health_changed(new_health: int) +signal player_died(player_node: PlayerNode) + + +# --- Constants --- +const WALK_SPEED := 5.0 +const SPRINT_MULTIPLIER := 1.6 +const JUMP_VELOCITY := 4.5 +const GRAVITY := 9.8 +const MAX_FALL_SPEED := 20.0 +const FRICTION := 8.0 +const AIR_CONTROL := 0.3 + +# --- Configurable --- +@export var walk_speed: float = WALK_SPEED +@export var sprint_multiplier: float = SPRINT_MULTIPLIER +@export var jump_velocity: float = JUMP_VELOCITY +@export var max_fall_speed: float = MAX_FALL_SPEED + +# --- State --- +@export var max_health: int = 100 +var current_health: int = 0 +var is_alive: bool = true +var is_sprinting: bool = false +var peer_id: int = -1 +var player_name: String = "" +var entity_id: int = 0 + +# --- Input buffer --- +var _input_queue: Array[Dictionary] = [] +var _last_processed_seq: int = -1 + +# --- Movement --- +var _velocity: Vector3 = Vector3.ZERO + + +func _ready() -> void: + current_health = max_health + velocity = _velocity + # Start alive + is_alive = true + + +func _physics_process(delta: float) -> void: + if not is_alive: + return + + # Process input from queue + _process_input(delta) + + # Apply gravity + if not is_on_floor(): + velocity.y -= GRAVITY * delta + velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed) + + # Apply friction on ground + if is_on_floor(): + var move_dir = velocity.xz + if move_dir.length() > 0.01: + var friction_factor = clampf(FRICTION * delta, 0.0, 1.0) + velocity.xz = move_dir.normalized() * move_dir.length() * (1.0 - friction_factor) + else: + velocity.xz = Vector2.ZERO + + # Move and slide + velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed) + move_and_slide() + + # Update visual position (for remote clients via RPC) + _update_visual_state() + + +func _process_input(delta: float) -> void: + """Process buffered input from the server's input queue.""" + if _input_queue.is_empty(): + return + + var input_data: Dictionary = _input_queue.pop_front() + var input_state: Dictionary = input_data.get("input_state", {}) + + # Calculate movement direction + var direction := Vector3.ZERO + direction.x = input_state.get("move_x", 0.0) + direction.z = input_state.get("move_z", 0.0) + + # Sprint check + is_sprinting = input_state.get("sprint", false) + + if direction.length() > 0.01: + direction = direction.normalized() + var speed = walk_speed * sprint_multiplier if is_sprinting else walk_speed + velocity.x = direction.x * speed + velocity.z = direction.z * speed + else: + # On ground, apply friction (handled in physics_process) + if not is_on_floor(): + velocity.x *= (1.0 - AIR_CONTROL * delta * 5.0) + velocity.z *= (1.0 - AIR_CONTROL * delta * 5.0) + + # Jump + if input_state.get("jump", false) and is_on_floor(): + velocity.y = jump_velocity + + +func take_damage(amount: int, source_id: int) -> Dictionary: + """Take damage and return result dict.""" + if not is_alive: + return {"success": false, "reason": "already_dead"} + + current_health = max(0, current_health - amount) + health_changed.emit(current_health) + + if current_health <= 0: + die() + return {"success": true, "health": 0, "dead": true} + + return {"success": true, "health": current_health} + + +func die() -> void: + """Handle player death.""" + is_alive = false + velocity = Vector3.ZERO + player_died.emit(self) + + # Respawn after delay + await get_tree().create_timer(3.0).timeout + respawn() + + +func respawn() -> void: + """Respawn the player at spawn point.""" + current_health = max_health + is_alive = true + velocity = Vector3.ZERO + health_changed.emit(current_health) + + # Move to spawn point (default: origin) + position = Vector3.ZERO + + +func set_input_queue(inputs: Array[Dictionary]) -> void: + """Set the input queue from the server.""" + _input_queue = inputs + + +func get_state() -> Dictionary: + """Get current player state for replication.""" + return { + "entity_id": entity_id, + "peer_id": peer_id, + "position": position, + "rotation": rotation, + "health": current_health, + "max_health": max_health, + "is_alive": is_alive, + "is_sprinting": is_sprinting, + } + + +func _update_visual_state() -> void: + """Update visual state (for remote interpolation).""" + # In a full implementation, this would update a remote player's + # visual representation. For now, the node itself is the authority. + pass + + +# --- RPCs --- + +@rpc("any_peer", "call_remote", "reliable") +func rpc_request_input(input_state: Dictionary, seq: int) -> void: + """Receive input from client.""" + if not is_alive: + return + # Store for processing in _physics_process + _input_queue.append({ + "input_state": input_state, + "seq": seq, + "timestamp": Time.get_ticks_msec(), + }) + + +@rpc("any_peer", "call_remote", "reliable") +func rpc_request_interact(target_id: int) -> void: + """Handle interaction request from client.""" + if not is_alive: + return + # TODO: Implement interaction logic + print("Player %d interacted with entity %d" % [peer_id, target_id]) + + +@rpc("any_peer", "call_remote", "reliable") +func rpc_request_attack(target_id: int, aim_dir: Vector3) -> void: + """Handle attack request from client.""" + if not is_alive: + return + # TODO: Implement attack logic + print("Player %d attacked entity %d with aim %s" % [peer_id, target_id, aim_dir]) + + +@rpc("authority", "call_remote", "reliable") +func rpc_set_position(pos: Vector3) -> void: + """Server authoritative position update.""" + position = pos + + +@rpc("authority", "call_remote", "reliable") +func rpc_set_rotation(rot: Vector3) -> void: + """Server authoritative rotation update.""" + rotation = rot diff --git a/scripts/server_main.gd b/scripts/server_main.gd new file mode 100644 index 0000000..b105f98 --- /dev/null +++ b/scripts/server_main.gd @@ -0,0 +1,156 @@ +## ServerMain - Dedicated server root node. +## Accepts ENet connections, spawns Player nodes for each peer, +## and runs the simulation tick loop at 20 Hz. +extends Node + +## Maximum number of concurrent players. +const MAX_PLAYERS := 16 + +## Server tick rate (Hz). +const TICK_RATE := 20 + +## Tick duration in seconds. +const TICK_DURATION := 1.0 / TICK_RATE + +## ENet server port. +const SERVER_PORT := 7777 + +## Network peer used for ENet communication. +var network_peer: ENetMultiplayerPeer + +## Map of peer_id -> Player node. +var players: Dictionary = {} + +## Current tick counter (server-authoritative). +var tick: int = 0 + +## Whether the server is running. +var is_running: bool = false + +## Accumulated real time for fixed timestep simulation. +var accumulator: float = 0.0 + + +func _ready() -> void: + # Set this node as the multiplayer authority. + multiplayer.multiplayer_peer = _create_server_peer() + multiplayer.server = self + + if network_peer: + is_running = true + print("[Server] Started on port %d" % SERVER_PORT) + else: + printerr("[Server] Failed to create ENet peer.") + get_tree().quit(1) + + +func _create_server_peer() -> ENetMultiplayerPeer: + var peer := ENetMultiplayerPeer.new() + var error := peer.create_server(SERVER_PORT, MAX_PLAYERS) + if error != OK: + printerr("[Server] create_server error: %d" % error) + return ENetMultiplayerPeer() + return peer + + +func _process(delta: float) -> void: + if not is_running: + return + + # Handle disconnect events. + if multiplayer.peer_disconnected.is_connected(_on_peer_disconnected): + pass # already connected + + # Fixed timestep accumulator. + accumulator += delta + while accumulator >= TICK_DURATION: + accumulator -= TICK_DURATION + _tick() + + +func _tick() -> void: + """Main server simulation tick (called at TICK_RATE).""" + tick += 1 + + # Process player inputs and update state. + for peer_id in players: + var player: Node = players[peer_id] + if player and player.has_method("process_server_input"): + player.process_server_input(tick) + + # Broadcast snapshot to all clients. + _broadcast_snapshot() + + +func _broadcast_snapshot() -> void: + """Broadcast the current authoritative state to all connected clients.""" + var snapshot_data := { + "tick": tick, + "players": {} + } + + for peer_id in players: + var player: Node = players[peer_id] + if player and player.has_method("get_server_state"): + snapshot_data.players[peer_id] = player.get_server_state() + + # Send snapshot to all peers except the server itself. + var peer_ids: Array = multiplayer.get_peers() + for pid in peer_ids: + rpc_id(pid, "_receive_snapshot", snapshot_data) + + +# Remote: clients receive snapshots. +@rpc("any_peer", "call_remote", "reliable") +func _receive_snapshot(snapshot_data: Dictionary) -> void: + pass # Handled by client scene. + + +# --- Connection lifecycle --- + +func _on_peer_connected(peer_id: int) -> void: + print("[Server] Peer %d connected." % peer_id) + + if players.size() >= MAX_PLAYERS: + print("[Server] Max players reached. Disconnecting peer %d." % peer_id) + rpc_id(peer_id, "_kick", "Server is full.") + return + + # Spawn a Player node for this peer. + var player := preload("res://scripts/player.tscn").instantiate() + player.name = "Player_%d" % peer_id + player.peer_id = peer_id + add_child(player) + players[peer_id] = player + + print("[Server] Spawned player node for peer %d." % peer_id) + + +func _on_peer_disconnected(peer_id: int) -> void: + print("[Server] Peer %d disconnected." % peer_id) + if players.has(peer_id): + var player: Node = players[peer_id] + if player.is_inside_tree(): + player.queue_free() + players.erase(peer_id) + + +# --- RPCs --- + +@rpc("any_peer", "call_remote", "reliable") +func _kick(peer_id: int, reason: String) -> void: + print("[Server] Kicking peer %d: %s" % [peer_id, reason]) + rpc_id(peer_id, "_disconnect", reason) + + +@rpc("call_remote", "reliable") +func _disconnect(reason: String) -> void: + print("[Server] Disconnecting: %s" % reason) + get_tree().quit(0) + + +# --- Signal connections --- + +func _enter_tree() -> void: + multiplayer.peer_connected.connect(_on_peer_connected) + multiplayer.peer_disconnected.connect(_on_peer_disconnected)