- HUD arena status (top center): live wave number and run gold, driven by GameState signals, torn down cleanly on HUD.reset() - Free floor weapons removed: the initial sword/shield spawn is gone and manually placed level.tscn weapons are deleted server-side at init. Gear now only enters play via the armory or enemy drops. - Retire to Camp: new escape-menu button (shown only while alive in an active run) banks 100% of run gold and keeps all gear - the counterweight to death's 10% settlement. Exit Game and window close also bank winnings before quitting. - CLAUDE.md rewritten for the gladiator arena era: core loop, autoloads, economy API, RPC patterns, verification workflow.
836 lines
31 KiB
GDScript
836 lines
31 KiB
GDScript
extends Node3D
|
|
|
|
@onready var skin_input: LineEdit = $Menu/MainContainer/MainMenu/Option2/SkinInput
|
|
@onready var nick_input: LineEdit = $Menu/MainContainer/MainMenu/Option1/NickInput
|
|
@onready var address_input: LineEdit = $Menu/MainContainer/MainMenu/Option3/AddressInput
|
|
@onready var players_container: Node3D = $PlayersContainer
|
|
@onready var weapons_container: Node3D = $WeaponsContainer
|
|
@onready var enemies_container: Node3D = $EnemiesContainer
|
|
var orbs_container: Node3D = null
|
|
@onready var enemy_spawner: EnemySpawner = $EnemySpawner
|
|
@onready var player_spawn_points: Node3D = $PlayerSpawnPoints
|
|
@onready var menu: Control = $Menu
|
|
@onready var main_menu: VBoxContainer = $Menu/MainContainer/MainMenu
|
|
@export var player_scene: PackedScene
|
|
@export var practice_dummy_scene: PackedScene
|
|
@export var armed_enemy_scene: PackedScene
|
|
|
|
# Weapon spawning counter (server-side only)
|
|
var _weapon_spawn_counter: int = 0
|
|
# Track active weapons for late-join sync (server-side only)
|
|
var _active_weapons: Dictionary = {} # weapon_id -> WorldWeapon reference
|
|
# Track if we've already initialized to prevent double-spawning
|
|
var _multiplayer_initialized: bool = false
|
|
# Track next spawn point for round-robin spawning (server-side only)
|
|
var _next_spawn_index: int = 0
|
|
|
|
# multiplayer chat
|
|
@onready var message: LineEdit = $MultiplayerChat/VBoxContainer/HBoxContainer/Message
|
|
@onready var send: Button = $MultiplayerChat/VBoxContainer/HBoxContainer/Send
|
|
@onready var chat: TextEdit = $MultiplayerChat/VBoxContainer/Chat
|
|
@onready var multiplayer_chat: Control = $MultiplayerChat
|
|
|
|
var chat_visible = false
|
|
|
|
func _ready():
|
|
print("[Level] _ready() called. Peer ID: ", multiplayer.get_unique_id(), " Is server: ", multiplayer.is_server())
|
|
|
|
multiplayer_chat.hide()
|
|
menu.show()
|
|
multiplayer_chat.set_process_input(true)
|
|
|
|
# Add quick-fill preset buttons
|
|
_create_preset_buttons()
|
|
|
|
# Reshape the menu into the gladiator outfitting screen
|
|
_setup_outfitting_menu()
|
|
|
|
# Create or find weapons container
|
|
if has_node("WeaponsContainer"):
|
|
weapons_container = get_node("WeaponsContainer")
|
|
else:
|
|
weapons_container = Node3D.new()
|
|
weapons_container.name = "WeaponsContainer"
|
|
add_child(weapons_container)
|
|
print("Created WeaponsContainer")
|
|
|
|
# Create or find enemies container
|
|
if has_node("EnemiesContainer"):
|
|
enemies_container = get_node("EnemiesContainer")
|
|
else:
|
|
enemies_container = Node3D.new()
|
|
enemies_container.name = "EnemiesContainer"
|
|
add_child(enemies_container)
|
|
print("Created EnemiesContainer")
|
|
|
|
# Create or find health orbs container
|
|
if has_node("OrbsContainer"):
|
|
orbs_container = get_node("OrbsContainer")
|
|
else:
|
|
orbs_container = Node3D.new()
|
|
orbs_container.name = "OrbsContainer"
|
|
add_child(orbs_container)
|
|
print("Created OrbsContainer")
|
|
|
|
# Don't initialize weapons in _ready() - wait for Host/Join to be pressed
|
|
# This is handled in initialize_multiplayer() which is called after the
|
|
# multiplayer peer is properly set up
|
|
print("[Level] _ready() complete - waiting for Host/Join")
|
|
|
|
func _on_connected_to_server():
|
|
print("[Level] Connected to server! Cleaning up manual weapons")
|
|
_cleanup_manual_weapons_on_client()
|
|
|
|
## Called by Network when multiplayer peer is set up
|
|
func initialize_multiplayer():
|
|
print("[Level] initialize_multiplayer called. is_server: ", multiplayer.is_server())
|
|
|
|
# Prevent double initialization
|
|
if _multiplayer_initialized:
|
|
print("[Level] Already initialized, skipping")
|
|
return
|
|
|
|
_multiplayer_initialized = true
|
|
|
|
# Begin this player's arena run (starts the survival clock + gold tracking)
|
|
GameState.start_run()
|
|
|
|
if multiplayer.is_server():
|
|
print("[Level] Running server initialization")
|
|
Network.connect("player_connected", Callable(self, "_on_player_connected"))
|
|
multiplayer.peer_disconnected.connect(_remove_player)
|
|
|
|
# Remove any manually placed weapons - free floor loot undermines the shop.
|
|
# Weapons only enter the arena via the armory or dropped by slain enemies.
|
|
_remove_manual_weapons_on_server()
|
|
|
|
# Spawn practice dummies
|
|
_spawn_practice_dummies()
|
|
|
|
# Spawn armed enemies
|
|
_spawn_armed_enemies()
|
|
|
|
# Scatter a few health orbs so the pickup is visible from the start
|
|
_spawn_initial_health_orbs()
|
|
|
|
# Begin automatic timed enemy waves
|
|
if enemy_spawner:
|
|
enemy_spawner.start_auto_waves()
|
|
|
|
# Spawn the host player (peer ID 1)
|
|
print("[Level] Spawning host player")
|
|
var host_info = Network.players.get(1, {"nick": "Host", "skin": "blue"})
|
|
_add_player(1, host_info)
|
|
else:
|
|
# Client initialization - clean up manual weapons immediately
|
|
print("[Level] Running client initialization - cleaning up manual weapons")
|
|
_cleanup_manual_weapons_on_client()
|
|
|
|
func _cleanup_manual_weapons_on_client():
|
|
"""Remove manually placed weapons on clients (server will sync them via RPC)"""
|
|
print("[Client ", multiplayer.get_unique_id(), "] _cleanup_manual_weapons_on_client called")
|
|
|
|
if not weapons_container:
|
|
print("[Client] No weapons_container found!")
|
|
return
|
|
|
|
print("[Client] WeaponsContainer has ", weapons_container.get_child_count(), " children")
|
|
|
|
var weapons_to_remove = []
|
|
for child in weapons_container.get_children():
|
|
print("[Client] Checking child: ", child.name, " (type: ", child.get_class(), ")")
|
|
if child is WorldWeapon:
|
|
print("[Client] - Is WorldWeapon with weapon_id: ", child.weapon_id)
|
|
if child.weapon_id == -1:
|
|
weapons_to_remove.append(child)
|
|
print("[Client] - Marked for removal")
|
|
|
|
print("[Client] Found ", weapons_to_remove.size(), " weapons to remove")
|
|
for weapon in weapons_to_remove:
|
|
print("[Client] Removing manually placed weapon: ", weapon.name)
|
|
# Use immediate removal to prevent RPC errors
|
|
weapons_container.remove_child(weapon)
|
|
weapon.free()
|
|
|
|
func _remove_manual_weapons_on_server():
|
|
"""Remove WorldWeapon nodes manually placed in level.tscn (server side).
|
|
Clients already remove their local copies in _cleanup_manual_weapons_on_client.
|
|
The arena economy requires gear to be bought or looted from slain enemies."""
|
|
if not multiplayer.is_server():
|
|
return
|
|
|
|
if not weapons_container:
|
|
return
|
|
|
|
var removed = 0
|
|
for child in weapons_container.get_children():
|
|
if child is WorldWeapon and child.weapon_id == -1:
|
|
weapons_container.remove_child(child)
|
|
child.free()
|
|
removed += 1
|
|
|
|
if removed > 0:
|
|
print("[Server] Removed ", removed, " manually placed floor weapon(s)")
|
|
|
|
func _on_player_connected(peer_id, player_info):
|
|
print("[Server] _on_player_connected called for peer ", peer_id, " with info: ", player_info)
|
|
_add_player(peer_id, player_info)
|
|
|
|
# Sync existing players to the newly joined player
|
|
if multiplayer.is_server() and peer_id != 1:
|
|
print("[Server] Syncing existing players to newly connected peer: ", peer_id)
|
|
# Wait a frame to ensure new player is fully initialized
|
|
await get_tree().process_frame
|
|
for existing_player in players_container.get_children():
|
|
var existing_id = int(existing_player.name)
|
|
if existing_id != peer_id: # Don't sync the player to themselves
|
|
print("[Server] Syncing existing player ", existing_id, " to peer ", peer_id)
|
|
rpc_id(peer_id, "_spawn_player_local", existing_id, existing_player.position)
|
|
|
|
# Sync existing weapons to the newly joined player (but not to server itself)
|
|
if multiplayer.is_server() and peer_id != 1:
|
|
print("[Server] Syncing weapons to newly connected peer: ", peer_id)
|
|
print("[Server] Active weapons in _active_weapons: ", _active_weapons.keys())
|
|
print("[Server] Active weapons count: ", _active_weapons.size())
|
|
for weapon_id in _active_weapons.keys():
|
|
var weapon = _active_weapons[weapon_id]
|
|
if is_instance_valid(weapon) and weapon.weapon_data:
|
|
print("[Server] Sending weapon ", weapon_id, " (", weapon.weapon_data.weapon_name, ") at position ", weapon.global_position, " to peer ", peer_id)
|
|
# Send current position and zero velocity for syncing
|
|
rpc_id(peer_id, "_client_spawn_weapon",
|
|
weapon.weapon_data.resource_path,
|
|
weapon.global_position,
|
|
Vector3.ZERO,
|
|
weapon_id
|
|
)
|
|
else:
|
|
print("[Server] Skipping invalid weapon ", weapon_id)
|
|
|
|
# Sync existing enemies to the newly joined player
|
|
print("[Server] Syncing enemies to newly connected peer: ", peer_id)
|
|
if enemies_container:
|
|
for enemy in enemies_container.get_children():
|
|
if enemy is BaseEnemy:
|
|
# Check if it's an ArmedEnemy or PracticeDummy
|
|
if enemy.name.begins_with("ArmedEnemy_"):
|
|
# Sync armed enemy with its weapons
|
|
var main_weapon_path = ""
|
|
var offhand_weapon_path = ""
|
|
if "equipped_weapon" in enemy and enemy.equipped_weapon and enemy.equipped_weapon.weapon_data:
|
|
main_weapon_path = enemy.equipped_weapon.weapon_data.resource_path
|
|
if "equipped_offhand" in enemy and enemy.equipped_offhand and enemy.equipped_offhand.weapon_data:
|
|
offhand_weapon_path = enemy.equipped_offhand.weapon_data.resource_path
|
|
print("[Server] Syncing armed enemy ", enemy.name, " to peer ", peer_id)
|
|
rpc_id(peer_id, "_spawn_armed_enemy_local", enemy.name, enemy.global_position, main_weapon_path, offhand_weapon_path)
|
|
elif enemy.name.begins_with("PracticeDummy_"):
|
|
# Extract ID from name (e.g., "PracticeDummy_1" -> 1)
|
|
var enemy_name_parts = enemy.name.split("_")
|
|
if enemy_name_parts.size() >= 2:
|
|
var enemy_id = enemy_name_parts[-1].to_int()
|
|
print("[Server] Syncing enemy ", enemy.name, " at position ", enemy.global_position, " to peer ", peer_id)
|
|
rpc_id(peer_id, "_spawn_dummy_local", enemy_id, enemy.global_position)
|
|
|
|
# Sync existing health orbs to the newly joined player
|
|
print("[Server] Syncing health orbs to newly connected peer: ", peer_id)
|
|
for orb_id in _active_orbs.keys():
|
|
rpc_id(peer_id, "_spawn_health_orb_local", orb_id, _active_orbs[orb_id])
|
|
|
|
# Sync equipped weapons for all existing players to the newly joined player
|
|
print("[Server] Syncing equipped weapons to newly connected peer: ", peer_id)
|
|
for player_node in players_container.get_children():
|
|
var player = player_node as Character
|
|
if player and is_instance_valid(player):
|
|
# Skip the newly joined player (they don't have weapons yet)
|
|
if int(player.name) == peer_id:
|
|
continue
|
|
|
|
# Sync main hand weapon
|
|
if player.equipped_weapon and player.equipped_weapon.weapon_data:
|
|
print("[Server] Syncing main hand weapon for player ", player.name, " to peer ", peer_id)
|
|
player.rpc_id(peer_id, "equip_weapon_from_world",
|
|
player.equipped_weapon.weapon_data.resource_path)
|
|
|
|
# Sync off-hand weapon
|
|
if player.equipped_offhand and player.equipped_offhand.weapon_data:
|
|
print("[Server] Syncing off-hand weapon for player ", player.name, " to peer ", peer_id)
|
|
player.rpc_id(peer_id, "equip_weapon_from_world",
|
|
player.equipped_offhand.weapon_data.resource_path)
|
|
|
|
func _on_host_pressed():
|
|
print("[Level] Host button pressed")
|
|
menu.hide()
|
|
print("[Level] Calling Network.start_host()")
|
|
Network.start_host(nick_input.text.strip_edges(), skin_input.text.strip_edges().to_lower())
|
|
print("[Level] Waiting one frame...")
|
|
await get_tree().process_frame
|
|
print("[Level] Calling initialize_multiplayer()")
|
|
initialize_multiplayer()
|
|
|
|
func _on_join_pressed():
|
|
print("[Level] Join button pressed")
|
|
menu.hide()
|
|
print("[Level] Calling Network.join_game()")
|
|
Network.join_game(nick_input.text.strip_edges(), skin_input.text.strip_edges().to_lower(), address_input.text.strip_edges())
|
|
print("[Level] Waiting one frame...")
|
|
await get_tree().process_frame
|
|
print("[Level] Calling initialize_multiplayer()")
|
|
initialize_multiplayer()
|
|
|
|
func _add_player(id: int, player_info : Dictionary):
|
|
print("[Level] _add_player called for peer ", id, " with info: ", player_info)
|
|
|
|
# Server spawns player and replicates to all clients via RPC
|
|
if multiplayer.is_server():
|
|
if players_container.has_node(str(id)):
|
|
print("[Level] Player ", id, " already exists, skipping")
|
|
return
|
|
|
|
var spawn_pos = get_spawn_point()
|
|
print("[Level] Server spawning player ", id, " at ", spawn_pos)
|
|
|
|
# Spawn on server and all clients (call_local does both)
|
|
rpc("_spawn_player_local", id, spawn_pos)
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func _spawn_player_local(id: int, spawn_pos: Vector3):
|
|
if players_container.has_node(str(id)):
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Player ", id, " already exists, skipping")
|
|
return
|
|
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Creating player instance for peer ", id)
|
|
var player = player_scene.instantiate()
|
|
player.name = str(id)
|
|
player.position = spawn_pos
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Adding player to PlayersContainer")
|
|
players_container.add_child(player, true)
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Player ", id, " spawned at ", player.position)
|
|
|
|
# Get player info from Network
|
|
var player_info = Network.players.get(id, {"nick": "Player", "skin": "blue"})
|
|
|
|
var nick = player_info["nick"]
|
|
# Access nickname directly via node path since @onready hasn't loaded yet
|
|
var nickname_label = player.get_node_or_null("PlayerNick/Nickname")
|
|
if nickname_label:
|
|
nickname_label.text = nick
|
|
# player.rpc("change_nick", nick)
|
|
|
|
# Set up HUD for local player
|
|
if id == multiplayer.get_unique_id():
|
|
print("[Level] Setting up HUD for local player")
|
|
# Wait a frame to ensure HUD autoload is ready
|
|
await get_tree().process_frame
|
|
if has_node("/root/HUD"):
|
|
get_node("/root/HUD").set_local_player(player)
|
|
else:
|
|
push_warning("[Level] HUD autoload not found! Make sure to restart Godot to register the new autoload.")
|
|
|
|
# Equip the loadout chosen in the outfitting screen (broadcasts to all peers)
|
|
var loadout = GameState.get_selected_loadout()
|
|
if loadout.get("main", "") != "":
|
|
player.rpc("equip_weapon_from_world", loadout["main"])
|
|
if loadout.get("offhand", "") != "":
|
|
player.rpc("equip_weapon_from_world", loadout["offhand"])
|
|
|
|
var skin_enum = player_info["skin"]
|
|
player.set_player_skin(skin_enum)
|
|
# rpc("sync_player_skin", id, skin_enum)
|
|
|
|
# rpc("sync_player_position", id, player.position)
|
|
|
|
func get_spawn_point() -> Vector3:
|
|
# Use PlayerSpawnPoint container if available
|
|
if player_spawn_points and player_spawn_points.get_child_count() > 0:
|
|
var spawn_children = player_spawn_points.get_children()
|
|
|
|
# Use round-robin to distribute players across spawn points
|
|
var spawn_index = _next_spawn_index % spawn_children.size()
|
|
_next_spawn_index = (spawn_index + 1) % spawn_children.size()
|
|
|
|
var spawn_node = spawn_children[spawn_index]
|
|
print("[Level] Using spawn point ", spawn_index, " at ", spawn_node.global_position)
|
|
return spawn_node.global_position
|
|
else:
|
|
# Fallback to random circle spawn if no spawn points defined
|
|
print("[Level] Warning: No PlayerSpawnPoint container found, using fallback random spawn")
|
|
var spawn_point = Vector2.from_angle(randf() * 2 * PI) * 10 # spawn radius
|
|
return Vector3(spawn_point.x, 0, spawn_point.y)
|
|
|
|
func _remove_player(id):
|
|
if not multiplayer.is_server() or not players_container.has_node(str(id)):
|
|
return
|
|
var player_node = players_container.get_node(str(id))
|
|
if player_node:
|
|
player_node.queue_free()
|
|
|
|
# @rpc("any_peer", "call_local")
|
|
# func sync_player_position(id: int, new_position: Vector3):
|
|
# var player = players_container.get_node(str(id))
|
|
# if player:
|
|
# player.position = new_position
|
|
|
|
# @rpc("any_peer", "call_local")
|
|
# func sync_player_skin(id: int, skin_color: Character.SkinColor):
|
|
# var player = players_container.get_node(str(id))
|
|
# if player:
|
|
# player.set_player_skin(skin_color)
|
|
|
|
func _on_quit_pressed() -> void:
|
|
get_tree().quit()
|
|
|
|
# ---------- MULTIPLAYER CHAT ----------
|
|
func toggle_chat():
|
|
if menu.visible:
|
|
return
|
|
|
|
chat_visible = !chat_visible
|
|
if chat_visible:
|
|
multiplayer_chat.show()
|
|
message.grab_focus()
|
|
else:
|
|
multiplayer_chat.hide()
|
|
get_viewport().set_input_as_handled()
|
|
|
|
func is_chat_visible() -> bool:
|
|
return chat_visible
|
|
|
|
func _input(event):
|
|
if event.is_action_pressed("toggle_chat"):
|
|
toggle_chat()
|
|
elif event is InputEventKey and event.keycode == KEY_ENTER:
|
|
_on_send_pressed()
|
|
elif event is InputEventKey and event.keycode == KEY_N and event.pressed:
|
|
# Start next wave (server only, press N)
|
|
if multiplayer.is_server() and enemy_spawner:
|
|
enemy_spawner.start_next_wave()
|
|
print("[Level] Starting wave via N key")
|
|
elif event is InputEventKey and event.keycode == KEY_H and event.pressed:
|
|
# Toggle hitbox/hurtbox debug visualization
|
|
HitBox.debug_visible = not HitBox.debug_visible
|
|
HurtBox.debug_visible = not HurtBox.debug_visible
|
|
print("[Level] Hitbox/Hurtbox debug visibility: ", HitBox.debug_visible)
|
|
|
|
func _on_send_pressed() -> void:
|
|
var trimmed_message = message.text.strip_edges()
|
|
if trimmed_message == "":
|
|
return # do not send empty messages
|
|
|
|
var nick = Network.players[multiplayer.get_unique_id()]["nick"]
|
|
|
|
rpc("msg_rpc", nick, trimmed_message)
|
|
message.text = ""
|
|
message.grab_focus()
|
|
|
|
@rpc("any_peer", "call_local")
|
|
func msg_rpc(nick, msg):
|
|
chat.text += str(nick, " : ", msg, "\n")
|
|
|
|
# ---------- OUTFITTING MENU ----------
|
|
## Turn the plain host/join menu into the gladiator camp:
|
|
## menu options on the left, armory shop + character sheet preview on the right.
|
|
func _setup_outfitting_menu():
|
|
# Retitle and shrink the header so it fits the narrowed left column
|
|
var title = menu.get_node_or_null("MainContainer/Label")
|
|
if title:
|
|
title.text = "SURVIVAL OF\nTHE SNIPPEST"
|
|
title.add_theme_font_size_override("font_size", 42)
|
|
|
|
# Narrow the existing menu column to the left third of the screen
|
|
var main_container = menu.get_node_or_null("MainContainer")
|
|
if main_container:
|
|
main_container.anchor_right = 0.34
|
|
|
|
# Rename buttons to fit the arena fiction
|
|
var host_button = menu.get_node_or_null("MainContainer/MainMenu/Buttons/Host")
|
|
if host_button:
|
|
host_button.text = "ENTER ARENA"
|
|
var join_button = menu.get_node_or_null("MainContainer/MainMenu/Buttons/Join")
|
|
if join_button:
|
|
join_button.text = "JOIN FRIEND"
|
|
|
|
# Armory + preview fill the rest of the screen
|
|
var outfitting_script = load("res://level/ui/scripts/outfitting_screen.gd")
|
|
var outfitting = outfitting_script.new()
|
|
outfitting.name = "OutfittingScreen"
|
|
outfitting.anchor_left = 0.35
|
|
outfitting.anchor_top = 0.04
|
|
outfitting.anchor_right = 0.985
|
|
outfitting.anchor_bottom = 0.96
|
|
menu.add_child(outfitting)
|
|
outfitting.set_nick_input(nick_input)
|
|
|
|
# ---------- PRESET BUTTONS ----------
|
|
func _create_preset_buttons():
|
|
# Create a container for preset buttons
|
|
var preset_container = HBoxContainer.new()
|
|
preset_container.name = "PresetContainer"
|
|
|
|
var preset_label = Label.new()
|
|
preset_label.text = "Quick Fill:"
|
|
preset_container.add_child(preset_label)
|
|
|
|
# Scott button
|
|
var scott_button = Button.new()
|
|
scott_button.text = "Scott"
|
|
scott_button.pressed.connect(_on_scott_preset)
|
|
preset_container.add_child(scott_button)
|
|
|
|
# Jemz button
|
|
var jemz_button = Button.new()
|
|
jemz_button.text = "Jemz"
|
|
jemz_button.pressed.connect(_on_jemz_preset)
|
|
preset_container.add_child(jemz_button)
|
|
|
|
# Add to main menu at the top
|
|
main_menu.add_child(preset_container)
|
|
main_menu.move_child(preset_container, 0)
|
|
|
|
func _on_scott_preset():
|
|
nick_input.text = "Scott"
|
|
skin_input.text = "Blue"
|
|
address_input.text = "127.0.0.1"
|
|
|
|
func _on_jemz_preset():
|
|
nick_input.text = "Jemz"
|
|
skin_input.text = "Red"
|
|
address_input.text = "127.0.0.1"
|
|
|
|
# ---------- ENEMY SPAWNING ----------
|
|
func _spawn_practice_dummies():
|
|
if not multiplayer.is_server():
|
|
return
|
|
|
|
if not practice_dummy_scene:
|
|
push_warning("Practice dummy scene not assigned!")
|
|
return
|
|
|
|
# Wait a frame for everything to be ready
|
|
await get_tree().process_frame
|
|
|
|
print("[Server] Spawning practice dummies")
|
|
|
|
# Spawn dummies at different positions
|
|
var dummy_positions = [
|
|
Vector3(10, 0, 0),
|
|
Vector3(-10, 0, 0),
|
|
Vector3(0, 0, 10),
|
|
Vector3(0, 0, -10),
|
|
]
|
|
|
|
var dummy_counter = 0
|
|
for pos in dummy_positions:
|
|
dummy_counter += 1
|
|
rpc("_spawn_dummy_local", dummy_counter, pos)
|
|
|
|
## Spawn a practice dummy on all clients
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func _spawn_dummy_local(dummy_id: int, spawn_pos: Vector3):
|
|
if not practice_dummy_scene:
|
|
push_error("Practice dummy scene not loaded!")
|
|
return
|
|
|
|
if not enemies_container:
|
|
push_error("EnemiesContainer not found!")
|
|
return
|
|
|
|
var dummy_name = "PracticeDummy_" + str(dummy_id)
|
|
|
|
# Don't spawn duplicates
|
|
if enemies_container.has_node(dummy_name):
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Dummy ", dummy_name, " already exists")
|
|
return
|
|
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Spawning dummy ", dummy_name, " at ", spawn_pos)
|
|
|
|
var dummy = practice_dummy_scene.instantiate()
|
|
dummy.name = dummy_name
|
|
dummy.position = spawn_pos
|
|
|
|
# Set multiplayer authority to server
|
|
dummy.set_multiplayer_authority(1)
|
|
|
|
enemies_container.add_child(dummy, true)
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Dummy ", dummy_name, " spawned successfully")
|
|
|
|
# ---------- WEAPON SPAWNING ----------
|
|
## Spawn a weapon in the world (called from server, syncs to all clients)
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func spawn_world_weapon(weapon_data_path: String, spawn_position: Vector3, initial_velocity: Vector3, weapon_id: int):
|
|
print("[Client ", multiplayer.get_unique_id(), "] spawn_world_weapon called for weapon_id: ", weapon_id)
|
|
|
|
if not weapons_container:
|
|
push_error("WeaponsContainer not found in level!")
|
|
return
|
|
|
|
# Load the weapon data resource
|
|
var weapon_data = load(weapon_data_path) as WeaponData
|
|
if not weapon_data:
|
|
push_error("Failed to load weapon data from: " + weapon_data_path)
|
|
return
|
|
|
|
# Create WorldWeapon instance
|
|
var world_weapon = WorldWeapon.new()
|
|
world_weapon.weapon_data = weapon_data
|
|
print("[DEBUG] About to set weapon_id. Parameter weapon_id = ", weapon_id)
|
|
world_weapon.weapon_id = weapon_id # Store the ID
|
|
print("[DEBUG] After setting weapon_id. world_weapon.weapon_id = ", world_weapon.weapon_id)
|
|
world_weapon.name = "WorldWeapon_" + str(weapon_id) # Deterministic name
|
|
print("[DEBUG] Set weapon name to: ", world_weapon.name, " using weapon_id: ", weapon_id)
|
|
world_weapon.position = spawn_position
|
|
|
|
# Remove existing weapon with same name if it exists (prevents duplicates)
|
|
var weapon_path = NodePath(world_weapon.name)
|
|
if weapons_container.has_node(weapon_path):
|
|
print("[DEBUG] Weapon ", world_weapon.name, " already exists, removing old one first")
|
|
var old_weapon = weapons_container.get_node(weapon_path)
|
|
weapons_container.remove_child(old_weapon)
|
|
old_weapon.queue_free()
|
|
|
|
# Add to weapons container
|
|
weapons_container.add_child(world_weapon)
|
|
print("[DEBUG] After add_child, weapon name in tree: ", world_weapon.name, " weapon_id: ", world_weapon.weapon_id)
|
|
|
|
# Track this weapon on the server (AFTER adding to tree so signals work)
|
|
if multiplayer.is_server():
|
|
_active_weapons[weapon_id] = world_weapon
|
|
print("[Server] Added weapon ", weapon_id, " to _active_weapons. Total: ", _active_weapons.size())
|
|
# Connect to the weapon's removal signal
|
|
world_weapon.tree_exiting.connect(_on_weapon_removed.bind(weapon_id))
|
|
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Spawned weapon: ", world_weapon.name, " at ", spawn_position)
|
|
|
|
# Apply velocity after physics is ready
|
|
if initial_velocity != Vector3.ZERO:
|
|
await get_tree().process_frame
|
|
world_weapon.linear_velocity = initial_velocity
|
|
|
|
## Remove a world weapon from all clients (called by WorldWeapon when picked up)
|
|
func remove_world_weapon(weapon_id: int):
|
|
print("[Server] remove_world_weapon called for weapon_id: ", weapon_id)
|
|
if not multiplayer.is_server():
|
|
print("[ERROR] remove_world_weapon called on client!")
|
|
return
|
|
|
|
# Immediately remove from active weapons to prevent late-join sync issues
|
|
if _active_weapons.has(weapon_id):
|
|
_active_weapons.erase(weapon_id)
|
|
print("[Server] Removed weapon ", weapon_id, " from _active_weapons. Remaining: ", _active_weapons.size())
|
|
print("[Server] Remaining weapon IDs: ", _active_weapons.keys())
|
|
else:
|
|
print("[Server] WARNING: Weapon ", weapon_id, " not found in _active_weapons!")
|
|
|
|
# Broadcast removal to all clients
|
|
print("[Server] Broadcasting removal RPC to all clients")
|
|
rpc("_remove_weapon_on_clients", weapon_id)
|
|
|
|
## RPC to remove weapon on all clients
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func _remove_weapon_on_clients(weapon_id: int):
|
|
print("[Peer ", multiplayer.get_unique_id(), "] _remove_weapon_on_clients called for weapon_id: ", weapon_id)
|
|
var weapon_name = "WorldWeapon_" + str(weapon_id)
|
|
if weapons_container and weapons_container.has_node(weapon_name):
|
|
var weapon = weapons_container.get_node(weapon_name)
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Removing weapon ", weapon_name)
|
|
weapon.queue_free()
|
|
else:
|
|
print("[Peer ", multiplayer.get_unique_id(), "] WARNING: Weapon ", weapon_name, " not found in WeaponsContainer")
|
|
|
|
## Called when a weapon is removed (picked up or destroyed)
|
|
func _on_weapon_removed(weapon_id: int):
|
|
if not multiplayer.is_server():
|
|
return
|
|
|
|
# Remove from tracking
|
|
if _active_weapons.has(weapon_id):
|
|
_active_weapons.erase(weapon_id)
|
|
print("Removed weapon ", weapon_id, " from active tracking")
|
|
|
|
## Client-only spawn (called via rpc_id for late-join sync)
|
|
@rpc("any_peer", "reliable")
|
|
func _client_spawn_weapon(weapon_data_path: String, spawn_position: Vector3, initial_velocity: Vector3, weapon_id: int):
|
|
print("[Client ", multiplayer.get_unique_id(), "] _client_spawn_weapon received for weapon_id: ", weapon_id)
|
|
# This only runs on clients, not server (no call_local)
|
|
if multiplayer.is_server():
|
|
print("[ERROR] _client_spawn_weapon called on server!")
|
|
return
|
|
|
|
# Call the regular spawn function to create the weapon
|
|
print("[Client ", multiplayer.get_unique_id(), "] Calling spawn_world_weapon locally")
|
|
spawn_world_weapon(weapon_data_path, spawn_position, initial_velocity, weapon_id)
|
|
|
|
# ---------- HEALTH ORBS ----------
|
|
var _orb_spawn_counter: int = 0
|
|
var _active_orbs: Dictionary = {} # orb_id -> spawn position (Vector3), for late-join sync
|
|
|
|
## Spawn a health orb at a position (server only, replicates to all clients)
|
|
func spawn_health_orb(spawn_pos: Vector3):
|
|
if not multiplayer.is_server():
|
|
return
|
|
_orb_spawn_counter += 1
|
|
rpc("_spawn_health_orb_local", _orb_spawn_counter, spawn_pos)
|
|
|
|
## Spawn a health orb on all clients (call_local spawns on server too)
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func _spawn_health_orb_local(orb_id: int, spawn_pos: Vector3):
|
|
if not orbs_container:
|
|
push_error("[Level] OrbsContainer not found!")
|
|
return
|
|
|
|
var orb_name = "HealthOrb_" + str(orb_id)
|
|
if orbs_container.has_node(orb_name):
|
|
return # Already spawned (e.g. duplicate sync)
|
|
|
|
var orb_scene = load("res://level/scenes/health_orb.tscn")
|
|
if not orb_scene:
|
|
push_error("[Level] Failed to load health_orb.tscn")
|
|
return
|
|
|
|
var orb = orb_scene.instantiate()
|
|
orb.orb_id = orb_id
|
|
orb.name = orb_name
|
|
orb.position = spawn_pos
|
|
orbs_container.add_child(orb, true)
|
|
|
|
# Server tracks active orbs for late-join sync
|
|
if multiplayer.is_server():
|
|
_active_orbs[orb_id] = spawn_pos
|
|
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Spawned health orb ", orb_name, " at ", spawn_pos)
|
|
|
|
## Remove a health orb from all clients (called by HealthOrb when collected)
|
|
func remove_health_orb(orb_id: int):
|
|
if not multiplayer.is_server():
|
|
return
|
|
if _active_orbs.has(orb_id):
|
|
_active_orbs.erase(orb_id)
|
|
rpc("_remove_orb_on_clients", orb_id)
|
|
|
|
## RPC to remove an orb on all clients
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func _remove_orb_on_clients(orb_id: int):
|
|
var orb_name = "HealthOrb_" + str(orb_id)
|
|
if orbs_container and orbs_container.has_node(orb_name):
|
|
orbs_container.get_node(orb_name).queue_free()
|
|
|
|
## Scatter a few health orbs at server start
|
|
func _spawn_initial_health_orbs():
|
|
if not multiplayer.is_server():
|
|
return
|
|
|
|
# Wait a frame for everything to be ready
|
|
await get_tree().process_frame
|
|
|
|
var orb_positions = [
|
|
Vector3(6, 1, 6),
|
|
Vector3(-6, 1, -6),
|
|
Vector3(-6, 1, 6),
|
|
]
|
|
for pos in orb_positions:
|
|
spawn_health_orb(pos)
|
|
|
|
# ---------- ARMED ENEMY SPAWNING ----------
|
|
var _armed_enemy_counter: int = 0
|
|
|
|
## Spawn an armed enemy (server only, replicates to all clients)
|
|
func spawn_armed_enemy(spawn_pos: Vector3, main_weapon_path: String = "", offhand_weapon_path: String = ""):
|
|
if not multiplayer.is_server():
|
|
return
|
|
|
|
if not armed_enemy_scene:
|
|
push_warning("[Level] Armed enemy scene not assigned!")
|
|
return
|
|
|
|
_armed_enemy_counter += 1
|
|
var enemy_name = "ArmedEnemy_" + str(_armed_enemy_counter)
|
|
|
|
rpc("_spawn_armed_enemy_local", enemy_name, spawn_pos, main_weapon_path, offhand_weapon_path)
|
|
|
|
## Spawn armed enemy on all clients
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func _spawn_armed_enemy_local(enemy_name: String, spawn_pos: Vector3, main_weapon_path: String, offhand_weapon_path: String):
|
|
if not armed_enemy_scene:
|
|
push_error("[Level] Armed enemy scene not loaded!")
|
|
return
|
|
|
|
if not enemies_container:
|
|
push_error("[Level] EnemiesContainer not found!")
|
|
return
|
|
|
|
# Don't spawn duplicates
|
|
if enemies_container.has_node(enemy_name):
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Armed enemy ", enemy_name, " already exists")
|
|
return
|
|
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Spawning armed enemy ", enemy_name, " at ", spawn_pos)
|
|
|
|
var enemy = armed_enemy_scene.instantiate()
|
|
enemy.name = enemy_name
|
|
enemy.position = spawn_pos
|
|
|
|
# Set multiplayer authority to server
|
|
enemy.set_multiplayer_authority(1)
|
|
|
|
# Set starting weapons (for server to trigger RPCs)
|
|
if main_weapon_path != "":
|
|
var weapon_data = load(main_weapon_path) as WeaponData
|
|
if weapon_data:
|
|
enemy.starting_weapon = weapon_data
|
|
|
|
if offhand_weapon_path != "":
|
|
var offhand_data = load(offhand_weapon_path) as WeaponData
|
|
if offhand_data:
|
|
enemy.starting_offhand = offhand_data
|
|
|
|
enemies_container.add_child(enemy, true)
|
|
print("[Peer ", multiplayer.get_unique_id(), "] Armed enemy ", enemy_name, " spawned successfully")
|
|
|
|
# Server credits kill gold for armed enemies spawned outside the wave spawner
|
|
if multiplayer.is_server():
|
|
enemy.died.connect(_on_level_enemy_died.bind(enemy))
|
|
|
|
## Kill-gold payout for enemies spawned directly by the level (not the wave spawner).
|
|
## Practice dummies are deliberately NOT connected - they respawn and would be farmable.
|
|
func _on_level_enemy_died(killer_id: int, enemy: Node):
|
|
if not multiplayer.is_server():
|
|
return
|
|
if killer_id > 0 and Network.players.has(killer_id) and enemy is BaseEnemy:
|
|
GameState.server_credit_kill(killer_id, enemy.gold_reward)
|
|
|
|
## Spawn initial armed enemies when server starts
|
|
func _spawn_armed_enemies():
|
|
if not multiplayer.is_server():
|
|
return
|
|
|
|
if not armed_enemy_scene:
|
|
push_warning("[Level] Armed enemy scene not assigned - skipping armed enemy spawn")
|
|
return
|
|
|
|
# Wait a frame for everything to be ready
|
|
await get_tree().process_frame
|
|
|
|
print("[Server] Spawning armed enemies")
|
|
|
|
# Find enemy spawn points from EnemySpawner
|
|
var enemy_spawner = get_node_or_null("EnemySpawner")
|
|
if not enemy_spawner:
|
|
push_warning("[Level] EnemySpawner not found - skipping armed enemy spawn")
|
|
return
|
|
|
|
# Get all spawn points (children of EnemySpawner that start with "EnemySpawnPoint")
|
|
var spawn_points: Array[Node3D] = []
|
|
for child in enemy_spawner.get_children():
|
|
if child is Node3D and child.name.begins_with("EnemySpawnPoint"):
|
|
spawn_points.append(child)
|
|
|
|
if spawn_points.is_empty():
|
|
push_warning("[Level] No enemy spawn points found in EnemySpawner")
|
|
return
|
|
|
|
print("[Server] Found ", spawn_points.size(), " enemy spawn points")
|
|
|
|
# Spawn an unarmed enemy at each spawn point
|
|
for spawn_point in spawn_points:
|
|
var spawn_pos = spawn_point.global_position
|
|
print("[Server] Spawning armed enemy at ", spawn_pos)
|
|
spawn_armed_enemy(spawn_pos, "", "")
|