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:
@@ -4,111 +4,127 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
This is a 3D multiplayer fighting game built in Godot 4.5 using an inheritance-based architecture. The game uses ENet for client-server networking with the Network singleton (autoload) managing all multiplayer connections.
|
**Survival of the Snippest** — a 3D multiplayer gladiator arena survival roguelike built in Godot 4.5.
|
||||||
|
Outfit a gladiator with banked gold, enter the colosseum, survive escalating enemy waves, earn gold
|
||||||
|
from kills and wave clears, and either retire alive (keep everything) or die (keep 10% of your total
|
||||||
|
value, floored at the 500-gold fresh start). Uses ENet client-server networking.
|
||||||
|
|
||||||
## Key Commands
|
## Key Commands
|
||||||
|
|
||||||
### Running the Game
|
### Running the Game
|
||||||
- Open the project in Godot Editor and press F5 to run
|
- Open the project in Godot Editor and press F5, or: `godot --path . res://level/scenes/level.tscn`
|
||||||
- Or use: `godot --path . res://level/scenes/level.tscn`
|
- Headless script-error check: `godot --headless --path . --quit`
|
||||||
|
|
||||||
### Testing Multiplayer Locally
|
### Testing Multiplayer Locally
|
||||||
1. Run the main scene (level.tscn)
|
1. Run one instance, click ENTER ARENA to host (port 8080)
|
||||||
2. Click "Host" on one instance to create a server (default port 8080)
|
2. Run a second instance, click JOIN FRIEND to connect (IP 127.0.0.1)
|
||||||
3. Run another instance and click "Join" to connect as a client
|
|
||||||
|
### In-Game Debug Keys
|
||||||
|
- `N` — start next enemy wave (server only)
|
||||||
|
- `H` — toggle hitbox/hurtbox debug visualization (off by default)
|
||||||
|
|
||||||
|
## The Core Loop
|
||||||
|
|
||||||
|
1. **Outfitting screen** (main menu): armory shop on the left/middle, Character Sheet in preview
|
||||||
|
mode on the right showing the pending loadout live. Buy weapons with banked gold; hand rules
|
||||||
|
enforced (two-handers and off-hands displace each other).
|
||||||
|
2. **Arena run**: chosen loadout auto-equips on spawn. Waves spawn on a timer; kills pay gold
|
||||||
|
(server-credited to the killer's peer), wave clears pay a bonus to all players.
|
||||||
|
3. **Run end**:
|
||||||
|
- **Death** (permadeath, no respawn): results screen; keep `max(500, 10% of total value)`;
|
||||||
|
gear and loadout liquidated.
|
||||||
|
- **Retire** (escape menu, or quitting alive): bank 100% of run gold, keep all gear.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Inheritance Structure
|
### Autoloads (singletons)
|
||||||
The codebase follows an inheritance-based design pattern:
|
- **Network** (`level/scripts/network.gd`) — ENet setup, host/join, `players` dict keyed by peer_id
|
||||||
- **BaseUnit** (base class) - Common functionality for all game entities that can take damage/have health
|
(`{"nick": String, "skin": Character.SkinColor}`). Server = peer 1, MAX_PLAYERS = 10.
|
||||||
- **Character** extends **CharacterBody3D** - Player character class (class_name: Character)
|
- **GameState** (`level/scripts/game_state.gd`) — the economy. Persists `banked_gold`,
|
||||||
- Currently implements movement, jumping, sprinting, respawning, and skin customization
|
`owned_items`, `selected_loadout` to `user://save.cfg`. Per-run: `run_gold`, `current_wave`,
|
||||||
- Uses multiplayer authority for input handling (only the owner processes input)
|
`kills_this_run`, `run_active`. Key API: `buy_item`, `equip_loadout_item`, `start_run`,
|
||||||
|
`end_run` (death settlement, double-call guarded), `retire_run` (bank everything),
|
||||||
|
`server_credit_kill/gold` (server → owning peer RPCs), `server_sync_wave`.
|
||||||
|
No compile-time dependency on Network — keep it that way (testable standalone via `--script`).
|
||||||
|
- **HUD** (`level/ui/scripts/hud_manager.gd`) — builds all in-run UI for the local player
|
||||||
|
(action bar, unit frame, character sheet, arena status wave/gold readout, escape menu,
|
||||||
|
run results on death). `reset()` tears everything down when returning to the menu —
|
||||||
|
autoload children survive scene reloads, so always reset before `reload_current_scene()`.
|
||||||
|
|
||||||
### Networking (Network.gd)
|
### Class hierarchy
|
||||||
- **Type**: Autoload singleton (accessible via `Network` globally)
|
- **BaseUnit** (`base_unit.gd`) — health, damage, death, respawn, passive regen. Server validates
|
||||||
- **Protocol**: ENet (UDP-based)
|
all damage. NOTE: `died` can fire twice on the host (direct emit + call_local sync) — guard
|
||||||
- **Architecture**: Client-Server (one host, multiple clients up to MAX_PLAYERS=10)
|
anything expensive hooked to it.
|
||||||
- **Player Data**: Stored in `Network.players` dictionary keyed by peer_id
|
- **Character** (`player.gd`, class_name Character) — player: movement, dash, attacks, blocking,
|
||||||
- Contains: `{"nick": String, "skin": Character.SkinColor}`
|
weapon equip/drop/pickup. `can_respawn = false` (death ends the run). Attacks snap the body to
|
||||||
|
camera facing and lock direction for startup+active (`_lock_facing_to_camera`); dash locks
|
||||||
|
direction for its duration.
|
||||||
|
- **BaseEnemy** (`base_enemy.gd`) — targeting, `gold_reward` (basic 10, armed 25). Enemies are
|
||||||
|
server-authoritative (authority = 1).
|
||||||
|
- **BasicEnemy** — melee chaser; `can_respawn = false`, despawns after death, 40% health orb drop.
|
||||||
|
- **ArmedEnemy** — player-model enemy that seeks/equips weapons and drops them on death.
|
||||||
|
`can_respawn = false` (respawning would be a gold farm). Practice dummies DO respawn and
|
||||||
|
deliberately pay no gold.
|
||||||
|
|
||||||
|
### Weapons
|
||||||
|
- **WeaponData** (`weapon_data.gd`) — Resource in `level/resources/*.tres`: combat stats, hand type
|
||||||
|
(MAIN_HAND / OFF_HAND / TWO_HAND), block stats, `cost`/`tier` for the shop, `icon` (optional —
|
||||||
|
weapon slots fall back to name text when missing).
|
||||||
|
- **BaseWeapon** — equipped instance on a hand bone; **WorldWeapon** — pickable RigidBody3D.
|
||||||
|
- No free floor weapons: manually placed WorldWeapons in level.tscn are removed at init on the
|
||||||
|
server (`_remove_manual_weapons_on_server`). Gear enters play via the shop or enemy drops only.
|
||||||
|
- The shop catalog is auto-discovered: any WeaponData `.tres` in `level/resources/` appears in
|
||||||
|
the armory (`outfitting_screen.gd`).
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- `character_sheet.gd` has two modes: live player (Tab in-game) and `preview_mode` (embedded in
|
||||||
|
the outfitting screen, rendering from a loadout dictionary). Keep both paths working.
|
||||||
|
- UI is largely built in code; match that style for new components.
|
||||||
|
|
||||||
### RPC Patterns
|
### RPC Patterns
|
||||||
The project uses Godot 4's RPC system for multiplayer synchronization:
|
- `@rpc("any_peer", "reliable")` — server-validated actions (damage, heal); server checks
|
||||||
- Use `@rpc("any_peer", "reliable")` for critical data (health, damage)
|
`multiplayer.is_server()` inside.
|
||||||
- Use `@rpc("any_peer", "unreliable")` for frequent position updates
|
- `@rpc("any_peer", "call_local", "reliable")` — state sync to all peers (spawns, equips, health).
|
||||||
- The server (multiplayer.is_server()) is authoritative for game state
|
- `@rpc("authority", ...)` — server-initiated (GameState credits, wave sync).
|
||||||
- Use `multiplayer.get_remote_sender_id()` to identify RPC sender
|
- Use `rpc_id(peer_id, ...)` for targeted sync (late-join). When the server targets itself
|
||||||
- Use `rpc_id(peer_id, "method_name")` for targeted RPCs
|
(peer 1), call the method directly instead of `rpc_id(1, ...)`.
|
||||||
|
- Late-join sync lives in `level.gd _on_player_connected` — new networked object types must be
|
||||||
|
added there (players, weapons, enemies, orbs, equipped gear all have precedents).
|
||||||
|
|
||||||
### Scene Structure
|
### Scene Structure (level.tscn)
|
||||||
- **level/scenes/level.tscn** - Main scene with menu UI, chat, and PlayersContainer
|
- Containers the code expects by name: `PlayersContainer`, `WeaponsContainer`, `EnemiesContainer`,
|
||||||
- level.gd spawns players dynamically when they connect
|
`OrbsContainer`, `PlayerSpawnPoints`, `EnemySpawner` (spawn points are its children named
|
||||||
- **level/scenes/player.tscn** - Player character scene
|
`EnemySpawnPoint*`).
|
||||||
- Instantiated by server when players connect
|
- Networked nodes are named deterministically for lookup: players by peer_id, `WorldWeapon_<id>`,
|
||||||
- Named with peer_id for easy lookup
|
`Enemy_<wave>_<n>`, `ArmedEnemy_<n>`, `HealthOrb_<id>`.
|
||||||
|
|
||||||
### Physics Layers
|
### Physics Layers
|
||||||
- Layer 1: "player" - Player collision
|
1 player · 2 world · 3 weapon · 4 hitbox · 5 hurtbox
|
||||||
- Layer 2: "world" - Environment collision
|
|
||||||
|
|
||||||
### Input Actions
|
### Input Actions (project.godot)
|
||||||
Defined in project.godot:
|
`move_left/right/forward/backward` (WASD), `jump` (Space), `shift` (sprint), `dash` (F),
|
||||||
- `move_left`, `move_right`, `move_forward`, `move_backward` (WASD)
|
`attack` (LMB), `block` (RMB), `pickup` (E), `toggle_character_sheet` (Tab),
|
||||||
- `jump` (Space)
|
`toggle_chat` (F12), `quit` (Esc).
|
||||||
- `shift` (Left Shift for sprinting)
|
|
||||||
- `quit` (Escape)
|
|
||||||
- `toggle_chat` (Enter)
|
|
||||||
|
|
||||||
## Development Guidelines
|
## Development Guidelines
|
||||||
|
|
||||||
### Git Commits
|
### Git Commits
|
||||||
When creating git commits, do NOT include "🤖 Generated with [Claude Code]" or "Co-Authored-By: Claude" in commit messages. Keep commit messages clean and professional.
|
When creating git commits, do NOT include "🤖 Generated with [Claude Code]" or
|
||||||
|
"Co-Authored-By: Claude" in commit messages. Keep commit messages clean and professional.
|
||||||
|
|
||||||
### Adding New Components
|
### Verification
|
||||||
1. Create scripts in `level/scripts/`
|
- Always run `godot --headless --path . --quit` after script changes and check for SCRIPT ERROR.
|
||||||
2. For multiplayer-synchronized components:
|
(Pre-existing "invalid UID" warnings on mesh/theme assets are known noise.)
|
||||||
- Always check `is_multiplayer_authority()` before processing input
|
- Economy logic can be tested standalone: a SceneTree script that instantiates
|
||||||
- Use RPC for state changes that need to replicate
|
`game_state.gd` and exercises the API (`--script` mode; autoload `_ready` doesn't fire there,
|
||||||
- Server should validate all important state changes
|
call `load_save()` manually). Restore the default save afterwards.
|
||||||
|
- Test multiplayer with 2 instances (host + client); check late joins and death/retire flows.
|
||||||
|
|
||||||
### Adding New Unit Types (Enemies, NPCs)
|
### Adding Content
|
||||||
1. Create a new class that extends BaseUnit
|
- **New weapon**: create a WeaponData `.tres` in `level/resources/` with `cost`/`tier` — the shop
|
||||||
2. Implement required virtual methods
|
picks it up automatically.
|
||||||
3. Add any unique behavior in _physics_process/_process
|
- **New enemy**: extend BaseEnemy, set `gold_reward`, decide `can_respawn` (respawning enemies
|
||||||
4. Ensure multiplayer authority is set correctly
|
must not pay gold), add scene to the EnemySpawner pool or spawn via level.gd (connect its
|
||||||
|
`died` signal for gold there).
|
||||||
### Testing Multiplayer
|
- **Multiplayer state changes**: server validates, then syncs via RPC; check
|
||||||
- Always test with at least 2 instances (1 host, 1 client)
|
`is_multiplayer_authority()` before processing input.
|
||||||
- Test edge cases: late joins, disconnections, packet loss
|
|
||||||
- Verify state is synchronized correctly across all clients
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Spawning Networked Entities
|
|
||||||
```gdscript
|
|
||||||
# Server-side only
|
|
||||||
if multiplayer.is_server():
|
|
||||||
var entity = entity_scene.instantiate()
|
|
||||||
entity.name = str(unique_id) # Name with ID for easy lookup
|
|
||||||
entity.set_multiplayer_authority(owner_peer_id)
|
|
||||||
container.add_child(entity, true) # true = force readable name
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiplayer Authority Check
|
|
||||||
```gdscript
|
|
||||||
func _physics_process(delta):
|
|
||||||
if not is_multiplayer_authority():
|
|
||||||
return
|
|
||||||
# Only the authority processes input/logic
|
|
||||||
```
|
|
||||||
|
|
||||||
### RPC for State Changes
|
|
||||||
```gdscript
|
|
||||||
@rpc("any_peer", "reliable")
|
|
||||||
func take_damage(amount: int, attacker_id: int):
|
|
||||||
if not multiplayer.is_server():
|
|
||||||
return # Server validates
|
|
||||||
# Apply damage logic
|
|
||||||
rpc("sync_health", current_health) # Broadcast to all
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ signal loadout_changed(loadout: Dictionary)
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
load_save()
|
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 ---------------------------------------------------------
|
# --- Persistence ---------------------------------------------------------
|
||||||
|
|
||||||
func load_save() -> void:
|
func load_save() -> void:
|
||||||
@@ -186,6 +191,31 @@ func end_run() -> Dictionary:
|
|||||||
run_ended.emit(last_run_stats)
|
run_ended.emit(last_run_stats)
|
||||||
return 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) ------------------------
|
# --- Multiplayer crediting (server -> owning peer) ------------------------
|
||||||
# GameState is an autoload, so it exists at the same path on every peer with
|
# 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
|
# 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"))
|
Network.connect("player_connected", Callable(self, "_on_player_connected"))
|
||||||
multiplayer.peer_disconnected.connect(_remove_player)
|
multiplayer.peer_disconnected.connect(_remove_player)
|
||||||
|
|
||||||
# Initialize any manually placed weapons in the scene
|
# Remove any manually placed weapons - free floor loot undermines the shop.
|
||||||
_initialize_manual_weapons()
|
# Weapons only enter the arena via the armory or dropped by slain enemies.
|
||||||
|
_remove_manual_weapons_on_server()
|
||||||
# Spawn initial weapons when server starts
|
|
||||||
_spawn_initial_weapons()
|
|
||||||
|
|
||||||
# Spawn practice dummies
|
# Spawn practice dummies
|
||||||
_spawn_practice_dummies()
|
_spawn_practice_dummies()
|
||||||
@@ -154,81 +152,25 @@ func _cleanup_manual_weapons_on_client():
|
|||||||
weapons_container.remove_child(weapon)
|
weapons_container.remove_child(weapon)
|
||||||
weapon.free()
|
weapon.free()
|
||||||
|
|
||||||
func _initialize_manual_weapons():
|
func _remove_manual_weapons_on_server():
|
||||||
"""Initialize any WorldWeapon nodes manually placed in the level scene"""
|
"""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():
|
if not multiplayer.is_server():
|
||||||
return
|
return
|
||||||
|
|
||||||
if not weapons_container:
|
if not weapons_container:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find all WorldWeapon nodes in the weapons container
|
var removed = 0
|
||||||
var manual_weapons = []
|
|
||||||
for child in weapons_container.get_children():
|
for child in weapons_container.get_children():
|
||||||
if child is WorldWeapon:
|
if child is WorldWeapon and child.weapon_id == -1:
|
||||||
manual_weapons.append(child)
|
weapons_container.remove_child(child)
|
||||||
|
child.free()
|
||||||
|
removed += 1
|
||||||
|
|
||||||
if manual_weapons.is_empty():
|
if removed > 0:
|
||||||
print("[Server] No manually placed weapons found")
|
print("[Server] Removed ", removed, " manually placed floor weapon(s)")
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
func _on_player_connected(peer_id, player_info):
|
func _on_player_connected(peer_id, player_info):
|
||||||
print("[Server] _on_player_connected called for peer ", peer_id, " with info: ", 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)
|
# Player reference (to control mouse mode)
|
||||||
var player: Character = null
|
var player: Character = null
|
||||||
|
|
||||||
|
var _retire_button: Button = null
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
# Start hidden
|
# Start hidden
|
||||||
hide()
|
hide()
|
||||||
is_visible = false
|
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):
|
func _input(event):
|
||||||
# Toggle on Escape press
|
# Toggle on Escape press
|
||||||
if event.is_action_pressed("quit"):
|
if event.is_action_pressed("quit"):
|
||||||
@@ -29,6 +45,7 @@ func toggle_menu():
|
|||||||
is_visible = !is_visible
|
is_visible = !is_visible
|
||||||
if is_visible:
|
if is_visible:
|
||||||
show()
|
show()
|
||||||
|
_update_retire_button()
|
||||||
# Release mouse when opening menu
|
# Release mouse when opening menu
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
else:
|
else:
|
||||||
@@ -37,10 +54,35 @@ func toggle_menu():
|
|||||||
if player and player.is_multiplayer_authority():
|
if player and player.is_multiplayer_authority():
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
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
|
## Called when Resume button is clicked
|
||||||
func _on_resume_pressed():
|
func _on_resume_pressed():
|
||||||
toggle_menu()
|
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
|
## Called when Exit button is clicked
|
||||||
func _on_exit_pressed():
|
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()
|
get_tree().quit()
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ var tab_hint: Control = null
|
|||||||
var keybind_hint: Control = null
|
var keybind_hint: Control = null
|
||||||
var escape_menu: Control = null
|
var escape_menu: Control = null
|
||||||
var run_results: Control = null
|
var run_results: Control = null
|
||||||
|
var arena_status: Control = null
|
||||||
|
var _wave_label: Label = null
|
||||||
|
var _gold_label: Label = null
|
||||||
|
|
||||||
# Player reference
|
# Player reference
|
||||||
var local_player: Character = null
|
var local_player: Character = null
|
||||||
@@ -81,6 +84,7 @@ func _create_ui_components():
|
|||||||
_create_tab_hint()
|
_create_tab_hint()
|
||||||
_create_keybind_hint()
|
_create_keybind_hint()
|
||||||
_create_escape_menu()
|
_create_escape_menu()
|
||||||
|
_create_arena_status()
|
||||||
|
|
||||||
## Create action bar at bottom of screen
|
## Create action bar at bottom of screen
|
||||||
func _create_action_bar():
|
func _create_action_bar():
|
||||||
@@ -150,6 +154,57 @@ func _create_escape_menu():
|
|||||||
else:
|
else:
|
||||||
push_error("[HUD] Failed to load escape_menu.tscn")
|
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
|
## Show all UI components
|
||||||
func show_hud():
|
func show_hud():
|
||||||
show()
|
show()
|
||||||
@@ -165,6 +220,13 @@ func reset():
|
|||||||
_disconnect_player_signals()
|
_disconnect_player_signals()
|
||||||
local_player = null
|
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():
|
for child in get_children():
|
||||||
child.queue_free()
|
child.queue_free()
|
||||||
|
|
||||||
@@ -176,6 +238,9 @@ func reset():
|
|||||||
keybind_hint = null
|
keybind_hint = null
|
||||||
escape_menu = null
|
escape_menu = null
|
||||||
run_results = null
|
run_results = null
|
||||||
|
arena_status = null
|
||||||
|
_wave_label = null
|
||||||
|
_gold_label = null
|
||||||
print("[HUD] Reset - all UI components removed")
|
print("[HUD] Reset - all UI components removed")
|
||||||
|
|
||||||
## Player signal callbacks
|
## Player signal callbacks
|
||||||
|
|||||||
Reference in New Issue
Block a user