Arena polish: in-run HUD, no free loot, retire-with-winnings
- 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.
This commit is contained in:
@@ -39,6 +39,11 @@ signal loadout_changed(loadout: Dictionary)
|
||||
func _ready() -> void:
|
||||
load_save()
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
# Closing the window mid-run counts as walking out alive - bank the winnings
|
||||
if what == NOTIFICATION_WM_CLOSE_REQUEST and run_active:
|
||||
retire_run()
|
||||
|
||||
# --- Persistence ---------------------------------------------------------
|
||||
|
||||
func load_save() -> void:
|
||||
@@ -186,6 +191,31 @@ func end_run() -> Dictionary:
|
||||
run_ended.emit(last_run_stats)
|
||||
return last_run_stats
|
||||
|
||||
## Walk out of the arena alive: bank 100% of run gold, keep all gear.
|
||||
## The reward for retiring instead of dying (death only keeps 10%).
|
||||
func retire_run() -> Dictionary:
|
||||
if not run_active:
|
||||
return last_run_stats
|
||||
run_active = false
|
||||
|
||||
last_run_stats = {
|
||||
"waves": current_wave,
|
||||
"kills": kills_this_run,
|
||||
"time": get_run_time(),
|
||||
"gold_earned": run_gold,
|
||||
"total_value": get_total_value(),
|
||||
"kept": banked_gold + run_gold,
|
||||
"retired": true,
|
||||
}
|
||||
|
||||
banked_gold += run_gold
|
||||
run_gold = 0
|
||||
gold_changed.emit(banked_gold)
|
||||
run_gold_changed.emit(run_gold)
|
||||
save_game()
|
||||
run_ended.emit(last_run_stats)
|
||||
return last_run_stats
|
||||
|
||||
# --- Multiplayer crediting (server -> owning peer) ------------------------
|
||||
# GameState is an autoload, so it exists at the same path on every peer with
|
||||
# the server (peer 1) as its multiplayer authority. The server decides who
|
||||
|
||||
+14
-72
@@ -100,11 +100,9 @@ func initialize_multiplayer():
|
||||
Network.connect("player_connected", Callable(self, "_on_player_connected"))
|
||||
multiplayer.peer_disconnected.connect(_remove_player)
|
||||
|
||||
# Initialize any manually placed weapons in the scene
|
||||
_initialize_manual_weapons()
|
||||
|
||||
# Spawn initial weapons when server starts
|
||||
_spawn_initial_weapons()
|
||||
# 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()
|
||||
@@ -154,81 +152,25 @@ func _cleanup_manual_weapons_on_client():
|
||||
weapons_container.remove_child(weapon)
|
||||
weapon.free()
|
||||
|
||||
func _initialize_manual_weapons():
|
||||
"""Initialize any WorldWeapon nodes manually placed in the level scene"""
|
||||
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
|
||||
|
||||
# Find all WorldWeapon nodes in the weapons container
|
||||
var manual_weapons = []
|
||||
var removed = 0
|
||||
for child in weapons_container.get_children():
|
||||
if child is WorldWeapon:
|
||||
manual_weapons.append(child)
|
||||
if child is WorldWeapon and child.weapon_id == -1:
|
||||
weapons_container.remove_child(child)
|
||||
child.free()
|
||||
removed += 1
|
||||
|
||||
if manual_weapons.is_empty():
|
||||
print("[Server] No manually placed weapons found")
|
||||
return
|
||||
|
||||
print("[Server] Found ", manual_weapons.size(), " manually placed weapon(s)")
|
||||
|
||||
# Initialize each manually placed weapon
|
||||
for weapon in manual_weapons:
|
||||
# Skip if already initialized (weapon_id != -1)
|
||||
if weapon.weapon_id != -1:
|
||||
continue
|
||||
|
||||
# Assign unique ID
|
||||
_weapon_spawn_counter += 1
|
||||
weapon.weapon_id = _weapon_spawn_counter
|
||||
|
||||
# Set deterministic name for networking
|
||||
var old_name = weapon.name
|
||||
weapon.name = "WorldWeapon_" + str(weapon.weapon_id)
|
||||
|
||||
# Track in active weapons
|
||||
_active_weapons[weapon.weapon_id] = weapon
|
||||
|
||||
# Connect cleanup signal
|
||||
weapon.tree_exiting.connect(_on_weapon_removed.bind(weapon.weapon_id))
|
||||
|
||||
print("[Server] Initialized manual weapon '", old_name, "' with ID: ", weapon.weapon_id, " at position: ", weapon.global_position)
|
||||
|
||||
# Verify weapon_data is set
|
||||
if not weapon.weapon_data:
|
||||
push_error("Manual weapon '", old_name, "' has no WeaponData assigned!")
|
||||
continue
|
||||
|
||||
func _spawn_initial_weapons():
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
# Wait a frame for everything to be ready
|
||||
await get_tree().process_frame
|
||||
|
||||
print("[Server] _spawn_initial_weapons - Connected peers: ", multiplayer.get_peers())
|
||||
|
||||
# Spawn a sword
|
||||
_weapon_spawn_counter += 1
|
||||
print("[Server] Calling RPC to spawn sword with ID: ", _weapon_spawn_counter)
|
||||
rpc("spawn_world_weapon",
|
||||
"res://level/resources/weapon_sword.tres",
|
||||
Vector3(5, 1, 0),
|
||||
Vector3.ZERO,
|
||||
_weapon_spawn_counter
|
||||
)
|
||||
|
||||
# Spawn a shield
|
||||
_weapon_spawn_counter += 1
|
||||
print("[Server] Calling RPC to spawn shield with ID: ", _weapon_spawn_counter)
|
||||
rpc("spawn_world_weapon",
|
||||
"res://level/resources/weapon_shield.tres",
|
||||
Vector3(-5, 1, 0),
|
||||
Vector3.ZERO,
|
||||
_weapon_spawn_counter
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -9,11 +9,27 @@ var is_visible: bool = false
|
||||
# Player reference (to control mouse mode)
|
||||
var player: Character = null
|
||||
|
||||
var _retire_button: Button = null
|
||||
|
||||
func _ready():
|
||||
# Start hidden
|
||||
hide()
|
||||
is_visible = false
|
||||
|
||||
# Insert "Retire to Camp" between Resume and Exit - leave alive, bank everything
|
||||
var vbox = get_node_or_null("Panel/MarginContainer/VBoxContainer")
|
||||
var exit_button = get_node_or_null("Panel/MarginContainer/VBoxContainer/ExitButton")
|
||||
if vbox and exit_button:
|
||||
_retire_button = Button.new()
|
||||
_retire_button.name = "RetireButton"
|
||||
_retire_button.custom_minimum_size = Vector2(0, 50)
|
||||
_retire_button.add_theme_color_override("font_color", Color.WHITE)
|
||||
_retire_button.add_theme_color_override("font_hover_color", Color(1, 0.8, 0))
|
||||
_retire_button.add_theme_font_size_override("font_size", 20)
|
||||
_retire_button.pressed.connect(_on_retire_pressed)
|
||||
vbox.add_child(_retire_button)
|
||||
vbox.move_child(_retire_button, exit_button.get_index())
|
||||
|
||||
func _input(event):
|
||||
# Toggle on Escape press
|
||||
if event.is_action_pressed("quit"):
|
||||
@@ -29,6 +45,7 @@ func toggle_menu():
|
||||
is_visible = !is_visible
|
||||
if is_visible:
|
||||
show()
|
||||
_update_retire_button()
|
||||
# Release mouse when opening menu
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
else:
|
||||
@@ -37,10 +54,35 @@ func toggle_menu():
|
||||
if player and player.is_multiplayer_authority():
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
|
||||
## Retiring is only possible while alive with an active run
|
||||
func _update_retire_button():
|
||||
if not _retire_button:
|
||||
return
|
||||
var can_retire = GameState.run_active and player and not player.is_dead
|
||||
_retire_button.visible = can_retire
|
||||
if can_retire:
|
||||
_retire_button.text = "Retire to Camp (+%dg)" % GameState.run_gold
|
||||
|
||||
## Called when Resume button is clicked
|
||||
func _on_resume_pressed():
|
||||
toggle_menu()
|
||||
|
||||
## Walk out alive: bank all run gold, keep gear, return to the menu
|
||||
func _on_retire_pressed():
|
||||
GameState.retire_run()
|
||||
|
||||
multiplayer.multiplayer_peer = null
|
||||
Network.players.clear()
|
||||
|
||||
if has_node("/root/HUD"):
|
||||
get_node("/root/HUD").reset()
|
||||
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
get_tree().reload_current_scene()
|
||||
|
||||
## Called when Exit button is clicked
|
||||
func _on_exit_pressed():
|
||||
# Leaving alive still banks your winnings (GameState also catches window close)
|
||||
if GameState.run_active and player and not player.is_dead:
|
||||
GameState.retire_run()
|
||||
get_tree().quit()
|
||||
|
||||
@@ -11,6 +11,9 @@ var tab_hint: Control = null
|
||||
var keybind_hint: Control = null
|
||||
var escape_menu: Control = null
|
||||
var run_results: Control = null
|
||||
var arena_status: Control = null
|
||||
var _wave_label: Label = null
|
||||
var _gold_label: Label = null
|
||||
|
||||
# Player reference
|
||||
var local_player: Character = null
|
||||
@@ -81,6 +84,7 @@ func _create_ui_components():
|
||||
_create_tab_hint()
|
||||
_create_keybind_hint()
|
||||
_create_escape_menu()
|
||||
_create_arena_status()
|
||||
|
||||
## Create action bar at bottom of screen
|
||||
func _create_action_bar():
|
||||
@@ -150,6 +154,57 @@ func _create_escape_menu():
|
||||
else:
|
||||
push_error("[HUD] Failed to load escape_menu.tscn")
|
||||
|
||||
## Create the arena status readout (wave + run gold, top center)
|
||||
func _create_arena_status():
|
||||
arena_status = PanelContainer.new()
|
||||
arena_status.name = "ArenaStatus"
|
||||
arena_status.set_anchors_preset(Control.PRESET_CENTER_TOP)
|
||||
arena_status.offset_top = 10
|
||||
arena_status.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||
add_child(arena_status)
|
||||
|
||||
var margin = MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 18)
|
||||
margin.add_theme_constant_override("margin_right", 18)
|
||||
margin.add_theme_constant_override("margin_top", 6)
|
||||
margin.add_theme_constant_override("margin_bottom", 6)
|
||||
arena_status.add_child(margin)
|
||||
|
||||
var hbox = HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 24)
|
||||
margin.add_child(hbox)
|
||||
|
||||
_wave_label = Label.new()
|
||||
_wave_label.add_theme_font_size_override("font_size", 22)
|
||||
_wave_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.0))
|
||||
_wave_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
_wave_label.add_theme_constant_override("outline_size", 2)
|
||||
hbox.add_child(_wave_label)
|
||||
|
||||
_gold_label = Label.new()
|
||||
_gold_label.add_theme_font_size_override("font_size", 22)
|
||||
_gold_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.2))
|
||||
_gold_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
_gold_label.add_theme_constant_override("outline_size", 2)
|
||||
hbox.add_child(_gold_label)
|
||||
|
||||
# Live updates from the economy
|
||||
GameState.wave_changed.connect(_on_wave_changed)
|
||||
GameState.run_gold_changed.connect(_on_run_gold_changed)
|
||||
|
||||
# Seed with current values (run may already be underway)
|
||||
_on_wave_changed(GameState.current_wave)
|
||||
_on_run_gold_changed(GameState.run_gold)
|
||||
print("[HUD] Arena status created")
|
||||
|
||||
func _on_wave_changed(wave: int):
|
||||
if _wave_label:
|
||||
_wave_label.text = "Wave %d" % wave if wave > 0 else "Prepare..."
|
||||
|
||||
func _on_run_gold_changed(gold: int):
|
||||
if _gold_label:
|
||||
_gold_label.text = "Gold +%d" % gold
|
||||
|
||||
## Show all UI components
|
||||
func show_hud():
|
||||
show()
|
||||
@@ -165,6 +220,13 @@ func reset():
|
||||
_disconnect_player_signals()
|
||||
local_player = null
|
||||
|
||||
# Drop GameState connections so they don't fire on freed labels
|
||||
# (and don't double-connect when the next run creates a new status bar)
|
||||
if GameState.wave_changed.is_connected(_on_wave_changed):
|
||||
GameState.wave_changed.disconnect(_on_wave_changed)
|
||||
if GameState.run_gold_changed.is_connected(_on_run_gold_changed):
|
||||
GameState.run_gold_changed.disconnect(_on_run_gold_changed)
|
||||
|
||||
for child in get_children():
|
||||
child.queue_free()
|
||||
|
||||
@@ -176,6 +238,9 @@ func reset():
|
||||
keybind_hint = null
|
||||
escape_menu = null
|
||||
run_results = null
|
||||
arena_status = null
|
||||
_wave_label = null
|
||||
_gold_label = null
|
||||
print("[HUD] Reset - all UI components removed")
|
||||
|
||||
## Player signal callbacks
|
||||
|
||||
Reference in New Issue
Block a user