feat: integrate ChaffGames FPS template as local player controller

Replaces the overhead box-mesh view with a full FPS character:

- mouse look, WASD movement, jump (Space), crouch (C), sprint (Shift), lean (Q/E)

- 6 weapons (LMB shoot, R reload, scroll switch, F melee, G drop)

- Crosshair HUD, ammo counter, sprint bar

- Client sends position to server at 20Hz for multiplayer visibility

- Server broadcasts positions to all peers

- Remote players shown as boxes (placeholder)

source: https://godotengine.org/asset-library/asset/2652 (MIT license)
This commit is contained in:
2026-07-02 00:26:45 -04:00
parent f0c95dcfd2
commit ad48f38ca5
70 changed files with 5297 additions and 52 deletions
+49 -52
View File
@@ -1,10 +1,13 @@
## Client Main — Client entry point for testing.
##
## Connects to the server. Receives broadcast_spawn_player /
## broadcast_despawn_player RPCs from the server and creates
## visual player nodes locally so each client sees every other player.
## Connects to the server. For the local player, spawns a full FPS
## character (from the ChaffGames FPS template) with mouse look,
## weapons, HUD, and movement. For remote players, creates simple
## box-mesh representations for visibility.
##
## Phase 0: simple position replication via RPC + box mesh player nodes.
## The local FPS character moves independently (single-player style).
## Position is replicated to the server each tick so other clients
## see where we are.
extends Node3D
@@ -13,7 +16,7 @@ extends Node3D
# ---------------------------------------------------------------------------
@export var server_host: String = "68.202.6.107"
@export var server_port: int = 34197
@export var player_scene: PackedScene = preload("res://scenes/player.tscn")
@export var fps_scene: PackedScene = preload("res://client/template/player_character.tscn")
# ---------------------------------------------------------------------------
# State
@@ -22,9 +25,12 @@ extends Node3D
var remote_players: Dictionary = {}
var connected: bool = false
# Our local player node (created when server spawns us)
# Our local FPS character
var _local_player: Node = null
var _camera: Camera3D = null
# Position sync timer
var _sync_timer: float = 0.0
const SYNC_RATE: float = 20.0 # Hz — how often to send position to server
# ---------------------------------------------------------------------------
# Lifecycle
@@ -49,80 +55,73 @@ func _connect_to_server() -> void:
_connect_to_server()
return
# Setup camera (fixed overhead until a player is spawned)
_camera = _make_camera()
add_child(_camera)
# Ambient light so we can see the box meshes
var light := DirectionalLight3D.new()
light.light_energy = 1.0
light.shadow_enabled = true
light.position = Vector3(10, 20, 10)
add_child(light)
light.look_at(Vector3.ZERO)
connected = true
# Connect replication signals from NetworkManager
# These fire when the server broadcasts spawn_player / despawn_player
NetworkManager.remote_player_spawned.connect(_on_remote_player_spawned)
NetworkManager.remote_player_despawned.connect(_on_remote_player_despawned)
print("[ClientMain] Connected to server. Peer ID: %d" % multiplayer.get_unique_id())
func _make_camera() -> Camera3D:
var cam := Camera3D.new()
cam.current = true
cam.position = Vector3(0, 16, 12)
cam.rotation_degrees.x = -55
return cam
# ---------------------------------------------------------------------------
# Local FPS character + position sync
# ---------------------------------------------------------------------------
func _process(delta: float) -> void:
# Sync our position to the server at a fixed rate
if _local_player:
_sync_timer += delta
if _sync_timer >= 1.0 / SYNC_RATE:
_sync_timer = 0.0
var pos: Vector3 = _local_player.global_position
rpc_id(1, "_send_position", pos)
func _process(_delta: float) -> void:
# Make camera follow local player if we have one
if _local_player and _camera:
_camera.position = _local_player.position + Vector3(0, 16, 12)
# Always look down at the player
var look_target := _local_player.position
look_target.y = _local_player.position.y + 2.0
_camera.look_at(look_target)
@rpc("unreliable")
func _send_position(pos: Vector3) -> void:
# Server receives our position and broadcasts to other clients
pass
# Server broadcasts other players' positions to us
@rpc("unreliable", "authority")
func _replicate_position(pos: Vector3, moving_peer_id: int) -> void:
# Update the position of a remote player on our screen
if moving_peer_id in remote_players:
remote_players[moving_peer_id].position = pos
# ---------------------------------------------------------------------------
# Player replication handlers (called when server broadcasts via RPC)
# ---------------------------------------------------------------------------
func _on_remote_player_spawned(peer_id: int, pos: Vector3) -> void:
if peer_id == multiplayer.get_unique_id():
# THIS IS OUR PLAYER — create a local player node and attach camera.
# The player.gd script handles WASD input and sends position to server.
# THIS IS OUR PLAYER — spawn the full FPS character
if _local_player:
push_warning("[ClientMain] Local player already exists, respawning")
push_warning("[ClientMain] Local player already exists, despawning old")
_local_player.queue_free()
_local_player = player_scene.instantiate()
_local_player = fps_scene.instantiate()
_local_player.name = "LocalPlayer"
_local_player.position = pos
# Set authority so player.gd detects is_local = true and enables input
_local_player.set_multiplayer_authority(peer_id)
_local_player.global_position = pos
add_child(_local_player, true)
print("[ClientMain] Spawned LOCAL player at (%.1f, %.1f)" % [pos.x, pos.z])
print("[ClientMain] Spawned LOCAL FPS player at (%.1f, %.1f)" % [pos.x, pos.z])
return
if peer_id in remote_players:
push_warning("[ClientMain] Remote player %d already exists, skipping" % peer_id)
return
# Create a remote player node for visualization
var player: Node3D
if player_scene:
player = player_scene.instantiate()
else:
player = Node3D.new()
player.set_script(preload("res://scripts/network/player.gd"))
# Create a remote player node for visualization (simple box mesh)
var player := Node3D.new()
player.name = "RemotePlayer_%d" % peer_id
player.set_multiplayer_authority(peer_id)
player.position = pos
# Add a simple box mesh so we can see where other players are
var mesh := MeshInstance3D.new()
mesh.mesh = BoxMesh.new()
mesh.mesh.size = Vector3(1, 1, 1)
mesh.position.y = 0.5
player.add_child(mesh)
add_child(player, true)
remote_players[peer_id] = player
@@ -130,7 +129,6 @@ func _on_remote_player_spawned(peer_id: int, pos: Vector3) -> void:
func _on_remote_player_despawned(peer_id: int) -> void:
if peer_id == multiplayer.get_unique_id():
# Our player was despawned
if _local_player:
_local_player.queue_free()
_local_player = null
@@ -146,7 +144,6 @@ func _exit_tree() -> void:
if connected:
NetworkManager.stop()
# Clean up any remaining players
if _local_player:
_local_player.queue_free()
for p in remote_players.values():
+20
View File
@@ -215,3 +215,23 @@ func _physics_process(delta: float) -> void:
# Future: authoritative physics tick, state broadcast, etc.
pass
# ---------------------------------------------------------------------------
# Client position replication (receives from FPS character clients)
# ---------------------------------------------------------------------------
@rpc("unreliable", "any_peer")
func _send_position(pos: Vector3) -> void:
# Called by remote clients to update their position on the server.
# Broadcast to all other peers so they see the player move.
var peer_id: int = multiplayer.get_remote_sender_id()
if peer_id in players:
players[peer_id].position = pos
# Re-broadcast to all other peers
_replicate_position.rpc(pos, peer_id)
@rpc("unreliable", "authority")
func _replicate_position(pos: Vector3, exclude_peer: int) -> void:
# All clients receive this and update the specific player's position.
# We need to find which remote player this belongs to by excluding our own.
# For now, the non-authority clients will need to filter — handled in client_main.gd
pass