Gladiator arena: gold economy, outfitting shop, permadeath runs

Turns the sandbox into a survival roguelike loop: outfit your gladiator
with banked gold, enter the arena, earn gold from kills and wave clears,
die, keep 10% of your total value (floored at the 500 fresh-start).

Economy (GameState autoload):
- banked_gold, owned items, and loadout persist to user://save.cfg
- Kill gold (basic 10g, armed 25g) credited server-side to the killer
- Wave-clear bonus (25 + 5/wave) for all players; wave synced to peers
- Death settlement liquidates gear, guarded against double-fire

Character creation / shop:
- Menu reframed: armory shop + live Character Sheet loadout preview
- Character Sheet gains preview mode (renders from loadout, no player)
- Buy/Equip with hand rules; two-handers and off-hands displace each
  other symmetrically (shop and in-game pickups)
- Chosen loadout auto-equips on spawn via existing RPC path

Combat feel:
- Attacks snap to camera facing and lock direction for the swing
- Dash commits to its direction for the full duration
- Weapon slots show name text when no icon is set
- Hitbox/hurtbox debug meshes hidden by default (H toggles)

Death is final: players no longer respawn; a results screen shows waves,
time, kills, and the settlement, then returns to camp. Armed enemies no
longer respawn (were an infinite gold farm and respawned invisible).
This commit is contained in:
2026-07-01 23:38:59 +01:00
parent 97ebbb1618
commit 1562b37563
25 changed files with 1023 additions and 43 deletions
+2
View File
@@ -7,6 +7,8 @@
script = ExtResource("2_hfi3c")
weapon_name = "Apple Sword"
description = "yum"
cost = 300
tier = 2
damage = 20.0
attack_range = 3.5
attack_cooldown = 0.6
+2
View File
@@ -7,6 +7,8 @@
script = ExtResource("1")
weapon_name = "Lobster Axe"
description = "A heavy-hitting axe shaped like a lobster claw. Surprisingly quick for its size."
cost = 350
tier = 2
hand_type = 2
damage = 18.0
attack_cooldown = 0.7
+2
View File
@@ -7,6 +7,8 @@
script = ExtResource("1")
weapon_name = "Wooden Shield"
description = "A sturdy wooden shield. Can be used to bash enemies or block attacks."
cost = 120
tier = 1
hand_type = 1
damage = 8.0
attack_range = 2.5
+2
View File
@@ -7,6 +7,8 @@
script = ExtResource("1")
weapon_name = "Iron Sword"
description = "A simple iron sword. Good for close combat."
cost = 150
tier = 1
damage = 15.0
attack_range = 3.5
attack_cooldown = 0.6
+2
View File
@@ -7,6 +7,8 @@
script = ExtResource("1")
weapon_name = "Iron Sword"
description = "A simple iron sword. Good for close combat."
cost = 250
tier = 2
damage = 20.0
attack_range = 3.5
attack_cooldown = 0.6
+4
View File
@@ -13,6 +13,10 @@ func apply_rotation(_velocity: Vector3) -> void:
# rpc("sync_player_rotation", new_rotation_y)
## Instantly face a direction (no lerp) - used to lock attacks to camera facing
func snap_rotation(_direction: Vector3) -> void:
rotation.y = atan2(-_direction.x, -_direction.z)
func animate(_velocity: Vector3) -> void:
# Don't override attack animation if it's playing
if animation_player.is_playing() and animation_player.current_animation == "Attack1":
+7
View File
@@ -59,6 +59,13 @@ func _enter_tree():
func _ready():
super._ready()
# Armed gladiators are worth more than basic enemies (arena economy)
gold_reward = 25
# Gladiators die for good - respawning would make them a repeatable gold farm
# (they also don't re-show their body on respawn, so this avoids invisible enemies)
can_respawn = false
# Auto-find body if not set
if _body == null:
if has_node("LilguyRigged/Armature"):
+2
View File
@@ -12,6 +12,8 @@ var current_target: Node = null
@export var detection_range: float = 10.0
## Whether this enemy is aggressive (will attack players)
@export var is_aggressive: bool = true
## Gold credited to the killing player (arena economy)
@export var gold_reward: int = 10
func _ready():
super._ready()
+12
View File
@@ -71,6 +71,9 @@ func start_wave():
print("[EnemySpawner] Starting wave ", current_wave)
wave_started.emit(current_wave)
# Broadcast wave number to every peer's GameState (for HUD + run stats)
GameState.server_sync_wave(current_wave)
# Spawn enemies
for i in range(enemies_per_wave):
_spawn_enemy(i, enemies_per_wave)
@@ -152,6 +155,10 @@ func _on_enemy_died(killer_id: int, enemy: Node):
print("[EnemySpawner] Enemy ", enemy.name, " defeated by ", killer_id)
# Credit kill gold to the killing player (server decides, credits their peer)
if killer_id > 0 and Network.players.has(killer_id) and enemy is BaseEnemy:
GameState.server_credit_kill(killer_id, enemy.gold_reward)
# Will be cleaned up in _update_active_enemies
## Update list of active enemies and check if wave complete
@@ -177,6 +184,11 @@ func _on_wave_completed():
print("[EnemySpawner] Wave ", current_wave, " completed!")
wave_completed.emit(current_wave)
# Wave-clear bonus for every living player (scales with wave number)
var wave_bonus: int = 25 + current_wave * 5
for peer_id in Network.players.keys():
GameState.server_credit_gold(peer_id, wave_bonus)
# Clean up dead enemies after a delay
await get_tree().create_timer(2.0).timeout
_cleanup_dead_enemies()
+256
View File
@@ -0,0 +1,256 @@
extends Node
## Autoload singleton holding the gladiator roguelike economy + meta-progression.
## Accessible globally as `GameState`.
##
## - banked_gold persists between sessions (saved to user://save.cfg).
## - owned_items are resource paths of weapons the player has bought.
## - run_gold is gold earned during the current arena run (NOT persisted).
##
## Death settlement: on run end you keep 10% of your total value (banked + run
## gold + owned gear value), floored at the DEFAULT_GOLD starting amount so you
## can never fall below a fresh start. This punishes hoarding and rewards spending
## before you enter the arena.
const SAVE_PATH: String = "user://save.cfg"
const DEFAULT_GOLD: int = 500
## Fraction of total value kept on death.
const SURVIVAL_KEEP_FRACTION: float = 0.10
# Persisted progression
var banked_gold: int = DEFAULT_GOLD
var owned_items: Array[String] = []
# Loadout chosen in the outfitting screen: slot -> WeaponData resource path ("" = empty)
var selected_loadout: Dictionary = {"main": "", "offhand": ""}
# Per-run state (reset each run, not saved)
var run_gold: int = 0
var run_active: bool = false
var current_wave: int = 0
var kills_this_run: int = 0
var last_run_stats: Dictionary = {}
var _run_start_msec: int = 0
signal gold_changed(banked_gold: int)
signal run_gold_changed(run_gold: int)
signal wave_changed(wave: int)
signal run_ended(stats: Dictionary)
signal loadout_changed(loadout: Dictionary)
func _ready() -> void:
load_save()
# --- Persistence ---------------------------------------------------------
func load_save() -> void:
var cfg := ConfigFile.new()
var err := cfg.load(SAVE_PATH)
if err != OK:
# No save yet (or unreadable): start fresh.
banked_gold = DEFAULT_GOLD
owned_items = []
save_game()
return
banked_gold = int(cfg.get_value("progress", "banked_gold", DEFAULT_GOLD))
# ConfigFile returns an untyped Array; copy into the typed array.
owned_items.clear()
for path in cfg.get_value("progress", "owned_items", []):
owned_items.append(str(path))
# Restore loadout, dropping anything no longer owned
var saved_loadout = cfg.get_value("progress", "selected_loadout", {"main": "", "offhand": ""})
for slot in ["main", "offhand"]:
var path := str(saved_loadout.get(slot, ""))
selected_loadout[slot] = path if owns_item(path) else ""
func save_game() -> void:
var cfg := ConfigFile.new()
cfg.set_value("progress", "banked_gold", banked_gold)
cfg.set_value("progress", "owned_items", owned_items)
cfg.set_value("progress", "selected_loadout", selected_loadout)
cfg.save(SAVE_PATH)
# --- Shop economy --------------------------------------------------------
func can_afford(cost: int) -> bool:
return banked_gold >= cost
## Buy an item by its WeaponData resource path. Returns true on success.
func buy_item(resource_path: String, cost: int) -> bool:
if not can_afford(cost):
return false
banked_gold -= cost
if not owned_items.has(resource_path):
owned_items.append(resource_path)
gold_changed.emit(banked_gold)
save_game()
return true
func owns_item(resource_path: String) -> bool:
return owned_items.has(resource_path)
# --- Loadout (what you walk into the arena with) --------------------------
## Equip an owned item into the slot its hand_type dictates.
## Two-handed weapons take the main slot and clear the off-hand.
func equip_loadout_item(resource_path: String) -> bool:
if not owns_item(resource_path):
return false
var data = load(resource_path)
if not data is WeaponData:
return false
match data.hand_type:
WeaponData.Hand.OFF_HAND:
selected_loadout["offhand"] = resource_path
# An off-hand displaces a two-handed main - you only have two hands
if _is_two_hand(selected_loadout.get("main", "")):
selected_loadout["main"] = ""
WeaponData.Hand.TWO_HAND:
selected_loadout["main"] = resource_path
selected_loadout["offhand"] = ""
_: # MAIN_HAND
selected_loadout["main"] = resource_path
# If the previous main was two-handed it's gone now; offhand stays as-is
loadout_changed.emit(selected_loadout)
save_game()
return true
func unequip_loadout_slot(slot: String) -> void:
if selected_loadout.get(slot, "") != "":
selected_loadout[slot] = ""
loadout_changed.emit(selected_loadout)
save_game()
func is_loadout_item_selected(resource_path: String) -> bool:
return selected_loadout.get("main", "") == resource_path \
or selected_loadout.get("offhand", "") == resource_path
func get_selected_loadout() -> Dictionary:
return selected_loadout
# --- Run lifecycle -------------------------------------------------------
func start_run() -> void:
run_gold = 0
current_wave = 0
kills_this_run = 0
run_active = true
_run_start_msec = Time.get_ticks_msec()
run_gold_changed.emit(run_gold)
wave_changed.emit(current_wave)
func add_run_gold(amount: int) -> void:
run_gold += amount
run_gold_changed.emit(run_gold)
## Seconds since the current run started.
func get_run_time() -> float:
if _run_start_msec == 0:
return 0.0
return (Time.get_ticks_msec() - _run_start_msec) / 1000.0
## Total liquid value of the player: banked + earned this run + value of owned gear.
func get_total_value() -> int:
var total := banked_gold + run_gold
for path in owned_items:
total += _item_cost(path)
return total
## Settle a finished run (death). Liquidates gear, keeps 10% of total value
## floored at DEFAULT_GOLD, then persists. Returns a stats Dictionary.
## Guarded against double-calls (died can fire more than once on the host).
func end_run() -> Dictionary:
if not run_active:
return last_run_stats
run_active = false
var total := get_total_value()
var kept: int = max(DEFAULT_GOLD, int(floor(total * SURVIVAL_KEEP_FRACTION)))
last_run_stats = {
"waves": current_wave,
"kills": kills_this_run,
"time": get_run_time(),
"gold_earned": run_gold,
"total_value": total,
"kept": kept,
}
banked_gold = kept
owned_items.clear()
selected_loadout = {"main": "", "offhand": ""}
run_gold = 0
gold_changed.emit(banked_gold)
run_gold_changed.emit(run_gold)
loadout_changed.emit(selected_loadout)
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
# earned gold and credits exactly that peer's local GameState.
## Server-side entry point: credit kill gold (and a kill) to a specific peer.
func server_credit_kill(peer_id: int, amount: int) -> void:
if not multiplayer.is_server():
return
if peer_id == 1:
credit_kill(amount) # Host credits itself directly
elif _is_known_peer(peer_id):
rpc_id(peer_id, "credit_kill", amount)
## Server-side entry point: credit plain gold (e.g. wave bonus) to a peer.
func server_credit_gold(peer_id: int, amount: int) -> void:
if not multiplayer.is_server():
return
if peer_id == 1:
credit_gold(amount)
elif _is_known_peer(peer_id):
rpc_id(peer_id, "credit_gold", amount)
## Runtime lookup so this singleton has no compile-time dependency on Network
## (keeps the economy testable standalone).
func _is_known_peer(peer_id: int) -> bool:
var network = get_node_or_null("/root/Network")
return network != null and network.players.has(peer_id)
## Server-side entry point: broadcast the current wave number to all peers.
func server_sync_wave(wave: int) -> void:
if not multiplayer.is_server():
return
rpc("sync_wave", wave)
@rpc("authority", "reliable")
func credit_gold(amount: int) -> void:
if not run_active:
return
add_run_gold(amount)
@rpc("authority", "reliable")
func credit_kill(amount: int) -> void:
if not run_active:
return
kills_this_run += 1
add_run_gold(amount)
@rpc("authority", "call_local", "reliable")
func sync_wave(wave: int) -> void:
current_wave = wave
wave_changed.emit(current_wave)
# --- Helpers -------------------------------------------------------------
func _is_two_hand(resource_path: String) -> bool:
if resource_path.is_empty() or not ResourceLoader.exists(resource_path):
return false
var res = load(resource_path)
return res is WeaponData and res.hand_type == WeaponData.Hand.TWO_HAND
func _item_cost(resource_path: String) -> int:
if resource_path.is_empty() or not ResourceLoader.exists(resource_path):
return 0
var res := load(resource_path)
if res is WeaponData:
return res.cost
return 0
+1
View File
@@ -0,0 +1 @@
uid://dlcyyjtlb8s7x
+2 -2
View File
@@ -14,8 +14,8 @@ signal hit_landed(target: Node, damage: float, knockback: float, attacker_pos: V
## Owner entity (used to prevent self-damage and identify attacker)
@export var owner_entity: Node = null
## Global debug visibility toggle (static-like via class name access)
static var debug_visible: bool = true
## Global debug visibility toggle (press H in-game to toggle)
static var debug_visible: bool = false
## Whether hitbox is currently active (only deals damage when active)
var is_active: bool = false
+2 -2
View File
@@ -5,8 +5,8 @@ class_name HurtBox
## Attach to any entity that can be damaged (players, enemies, destructibles)
## NOTE: This is a passive detection zone - HitBox handles the collision detection
## Global debug visibility toggle (static-like via class name access)
static var debug_visible: bool = true
## Global debug visibility toggle (press H in-game to toggle)
static var debug_visible: bool = false
## The entity that owns this hurtbox (should be a BaseUnit or similar)
@export var owner_entity: Node = null
+59
View File
@@ -42,6 +42,9 @@ func _ready():
# 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")
@@ -89,6 +92,9 @@ func initialize_multiplayer():
_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"))
@@ -377,6 +383,13 @@ func _spawn_player_local(id: int, spawn_pos: Vector3):
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)
@@ -470,6 +483,40 @@ func _on_send_pressed() -> void:
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
@@ -795,6 +842,18 @@ func _spawn_armed_enemy_local(enemy_name: String, spawn_pos: Vector3, main_weapo
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():
+4
View File
@@ -11,6 +11,10 @@ func apply_rotation(_velocity: Vector3) -> void:
var new_rotation_y = lerp_angle(rotation.y, atan2(_velocity.x, _velocity.z), LERP_VELOCITY)
rotation.y = new_rotation_y
## Instantly face a direction (no lerp) - used to lock attacks to camera facing
func snap_rotation(_direction: Vector3) -> void:
rotation.y = atan2(_direction.x, _direction.z)
func animate(_velocity: Vector3) -> void:
if not animation_player:
return
+53 -5
View File
@@ -59,6 +59,9 @@ var _dash_cooldown_timer: float = 0.0
var _is_dashing: bool = false
var _dash_direction: Vector3 = Vector3.ZERO
# Facing lock: while > 0 the body cannot turn (attacks aim at camera, dashes stay straight)
var _facing_locked_timer: float = 0.0
# UI Signals
signal dash_cooldown_updated(remaining: float, total: float)
signal attack_cooldown_updated(remaining: float, total: float)
@@ -74,6 +77,9 @@ func _ready():
health_regen = 3.0
regen_delay = 5.0
# Gladiator arena: death ends your run - no respawning
can_respawn = false
super._ready()
# Set respawn point to current position (where we spawned) - base_unit._ready already does this
# Don't override with a hardcoded position
@@ -240,6 +246,10 @@ func _process(delta):
_attack_timer -= delta
attack_cooldown_updated.emit(_attack_timer, attack_cooldown)
# Update facing lock (attack/dash direction commitment)
if _facing_locked_timer > 0:
_facing_locked_timer -= delta
# Update dash timers
if _dash_timer > 0:
_dash_timer -= delta
@@ -295,12 +305,10 @@ func freeze():
_body.animate(Vector3.ZERO)
func _move() -> void:
# If dashing, use dash movement
# If dashing, use dash movement (facing already snapped and locked at dash start)
if _is_dashing:
velocity.x = _dash_direction.x * _current_speed * dash_speed_multiplier
velocity.z = _dash_direction.z * _current_speed * dash_speed_multiplier
if _body:
_body.apply_rotation(velocity)
return
var _input_direction: Vector2 = Vector2.ZERO
@@ -319,13 +327,35 @@ func _move() -> void:
if _direction:
velocity.x = _direction.x * _current_speed
velocity.z = _direction.z * _current_speed
if _body:
# Body only turns toward movement while facing isn't locked by an attack/dash
if _body and _facing_locked_timer <= 0:
_body.apply_rotation(velocity)
return
velocity.x = move_toward(velocity.x, 0, _current_speed)
velocity.z = move_toward(velocity.z, 0, _current_speed)
## Horizontal direction the camera is looking (movement-space, matches _move math)
func _get_camera_forward() -> Vector3:
var fwd: Vector3 = transform.basis * Vector3(0, 0, -1)
if _spring_arm_offset:
fwd = fwd.rotated(Vector3.UP, _spring_arm_offset.rotation.y)
fwd.y = 0
return fwd.normalized()
## Snap the body to face a direction and hold it there for `duration` seconds
func _lock_facing(direction: Vector3, duration: float):
_facing_locked_timer = max(_facing_locked_timer, duration)
if _body and direction.length_squared() > 0.001:
if _body.has_method("snap_rotation"):
_body.snap_rotation(direction)
else:
_body.apply_rotation(direction)
## Attacks commit to where the camera looks, not where the character was walking
func _lock_facing_to_camera(duration: float):
_lock_facing(_get_camera_forward(), duration)
func is_running() -> bool:
if Input.is_action_pressed("shift"):
_current_speed = SPRINT_SPEED
@@ -423,11 +453,14 @@ func _perform_attack():
# Use main hand weapon if available
if equipped_weapon and equipped_weapon.can_attack():
# Aim the swing at the camera direction and commit to it
_lock_facing_to_camera(equipped_weapon.weapon_data.startup_time + equipped_weapon.weapon_data.active_time)
equipped_weapon.perform_attack()
return
# Or use off-hand weapon if available
if equipped_offhand and equipped_offhand.can_attack():
_lock_facing_to_camera(equipped_offhand.weapon_data.startup_time + equipped_offhand.weapon_data.active_time)
equipped_offhand.perform_attack()
return
@@ -445,6 +478,9 @@ func _perform_attack():
_attack_timer = cooldown
_is_unarmed_attacking = true
# Aim the punch at the camera direction and commit to it
_lock_facing_to_camera(total_duration)
# Play attack animation once
if _body:
_body.play_attack("Attack_OneHand")
@@ -517,7 +553,7 @@ func _on_died(killer_id: int):
# Show death message on UI
if has_node("HealthUI/HealthText"):
get_node("HealthUI/HealthText").text = "DEAD - Respawning..."
get_node("HealthUI/HealthText").text = "DEAD - Run Over"
func _on_respawned():
print("[Player ", name, "] _on_respawned called. Authority: ", is_multiplayer_authority(), " Position: ", global_position)
@@ -560,6 +596,9 @@ func _perform_dash():
_dash_timer = dash_duration
_dash_cooldown_timer = dash_cooldown
# Commit to the dash direction - no steering or turning mid-dash
_lock_facing(_dash_direction, dash_duration)
# Animation is handled by the Body's animate function (Jump animation plays during dash)
## Sync attack animation to all clients
@@ -715,6 +754,15 @@ func equip_weapon(data: WeaponData):
# Determine which hand based on weapon type
var is_offhand = (data.hand_type == WeaponData.Hand.OFF_HAND)
# Two-handed weapons occupy both hands - clear the off-hand
if data.hand_type == WeaponData.Hand.TWO_HAND and equipped_offhand:
unequip_weapon(true)
# And symmetrically: an off-hand displaces a two-handed main weapon
if is_offhand and equipped_weapon and equipped_weapon.weapon_data \
and equipped_weapon.weapon_data.hand_type == WeaponData.Hand.TWO_HAND:
unequip_weapon(false)
# Unequip current weapon in that hand first
if is_offhand:
if equipped_offhand:
+6
View File
@@ -11,6 +11,12 @@ enum Hand { MAIN_HAND, OFF_HAND, TWO_HAND }
@export_multiline var description: String = ""
@export var hand_type: Hand = Hand.MAIN_HAND
@export_category("Shop / Economy")
## Cost to buy this weapon in the character-creation shop.
@export var cost: int = 100
## Rough power tier (1 = starter, higher = stronger). Used for shop sorting/grouping.
@export var tier: int = 1
@export_category("Combat Stats")
@export var damage: float = 10.0
@export var attack_range: float = 3.0
+148 -32
View File
@@ -11,15 +11,40 @@ class_name CharacterSheet
# Player reference
var player: Character = null
# Preview mode: renders from a loadout dictionary instead of a live player.
# Used by the outfitting screen at character creation.
# preview_data keys: "nick" (String), "main" (WeaponData or null), "offhand" (WeaponData or null)
var preview_mode: bool = false
var preview_data: Dictionary = {}
# Character defaults shown in preview (match player.gd export defaults)
const PREVIEW_MAX_HEALTH := 100.0
const PREVIEW_ATTACK_DAMAGE := 10.0
const PREVIEW_ATTACK_RANGE := 3.0
const PREVIEW_ATTACK_COOLDOWN := 0.5
const PREVIEW_DASH_COOLDOWN := 4.0
const PREVIEW_DASH_DURATION := 0.25
const PREVIEW_DASH_MULTIPLIER := 2.0
# Visibility
var is_visible: bool = false
func _ready():
# Start hidden
hide()
is_visible = false
if preview_mode:
_configure_preview_layout()
show()
is_visible = true
refresh_all()
else:
# Start hidden (in-game Tab toggle behavior)
hide()
is_visible = false
func _input(event):
# Embedded preview never toggles - it's part of the outfitting screen
if preview_mode:
return
# Close on Escape press if open
if event.is_action_pressed("quit") and is_visible:
toggle_sheet()
@@ -37,6 +62,35 @@ func set_player(p: Character):
if player:
refresh_all()
## Feed the sheet a loadout to preview (outfitting screen).
## Safe to call before or after the sheet enters the tree.
func set_preview(data: Dictionary):
preview_mode = true
preview_data = data
if is_inside_tree():
refresh_all()
## Re-shape the fullscreen overlay into an embeddable panel:
## no dark backdrop, panel fills the space its parent gives it.
func _configure_preview_layout():
mouse_filter = Control.MOUSE_FILTER_IGNORE
var backdrop = get_node_or_null("DarkBackground")
if backdrop:
backdrop.hide()
var panel = get_node_or_null("Panel")
if panel:
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.offset_left = 0
panel.offset_top = 0
panel.offset_right = 0
panel.offset_bottom = 0
var title = get_node_or_null("Panel/MarginContainer/VBoxContainer/TitleLabel")
if title:
title.text = "YOUR GLADIATOR"
var close_hint = get_node_or_null("Panel/MarginContainer/VBoxContainer/CloseHint")
if close_hint:
close_hint.hide()
## Toggle character sheet visibility
func toggle_sheet():
is_visible = !is_visible
@@ -53,7 +107,7 @@ func toggle_sheet():
## Refresh all data in the sheet
func refresh_all():
if not player:
if not player and not preview_mode:
return
_refresh_stats()
@@ -81,11 +135,40 @@ func _refresh_stats():
spacer1.custom_minimum_size = Vector2(0, 10)
stats_container.add_child(spacer1)
# Get player name
var player_id = player.name.to_int()
var player_name = "Player"
if Network.players.has(player_id):
player_name = Network.players[player_id]["nick"]
# Resolve stats from either the live player or the preview loadout
var player_name: String
var current_hp: int
var max_hp: int
var atk_damage: float
var atk_range: float
var atk_cooldown: float
if preview_mode:
player_name = preview_data.get("nick", "Gladiator")
if player_name.strip_edges() == "":
player_name = "Gladiator"
current_hp = int(PREVIEW_MAX_HEALTH)
max_hp = int(PREVIEW_MAX_HEALTH)
# Combat stats come from the selected main weapon, or bare fists
var main_weapon = preview_data.get("main")
if main_weapon is WeaponData:
atk_damage = main_weapon.damage
atk_range = main_weapon.attack_range
atk_cooldown = main_weapon.attack_cooldown
else:
atk_damage = PREVIEW_ATTACK_DAMAGE
atk_range = PREVIEW_ATTACK_RANGE
atk_cooldown = PREVIEW_ATTACK_COOLDOWN
else:
var player_id = player.name.to_int()
player_name = "Player"
if Network.players.has(player_id):
player_name = Network.players[player_id]["nick"]
current_hp = int(player.current_health)
max_hp = int(player.max_health)
atk_damage = player.attack_damage
atk_range = player.attack_range
atk_cooldown = player.attack_cooldown
_add_stat_label("Name: " + player_name)
_add_stat_label("Level: 1") # Hardcoded for now
@@ -93,23 +176,35 @@ func _refresh_stats():
# Health stats
_add_stat_label("=== HEALTH ===", Color(0.8, 0.8, 0.8))
_add_stat_label("Current HP: " + str(int(player.current_health)))
_add_stat_label("Max HP: " + str(int(player.max_health)))
_add_stat_label("Health: " + str(int(player.get_health_percent() * 100)) + "%")
_add_stat_label("Current HP: " + str(current_hp))
_add_stat_label("Max HP: " + str(max_hp))
var hp_percent = int(float(current_hp) / max_hp * 100) if max_hp > 0 else 0
_add_stat_label("Health: " + str(hp_percent) + "%")
_add_stat_label("")
# Movement stats
# Movement stats (class constants - same for live and preview)
_add_stat_label("=== MOVEMENT ===", Color(0.8, 0.8, 0.8))
_add_stat_label("Walk Speed: " + str(player.NORMAL_SPEED))
_add_stat_label("Sprint Speed: " + str(player.SPRINT_SPEED))
_add_stat_label("Jump Power: " + str(player.JUMP_VELOCITY))
_add_stat_label("Walk Speed: " + str(Character.NORMAL_SPEED))
_add_stat_label("Sprint Speed: " + str(Character.SPRINT_SPEED))
_add_stat_label("Jump Power: " + str(Character.JUMP_VELOCITY))
_add_stat_label("")
# Combat stats
_add_stat_label("=== COMBAT ===", Color(0.8, 0.8, 0.8))
_add_stat_label("Base Damage: " + str(player.attack_damage))
_add_stat_label("Attack Range: " + str(player.attack_range))
_add_stat_label("Attack Cooldown: " + str(player.attack_cooldown) + "s")
if preview_mode and preview_data.get("main") is WeaponData:
_add_stat_label("Weapon Damage: " + str(atk_damage))
else:
_add_stat_label("Base Damage: " + str(atk_damage))
_add_stat_label("Attack Range: " + str(atk_range))
_add_stat_label("Attack Cooldown: " + str(atk_cooldown) + "s")
# Off-hand block bonus (preview only - handy when choosing a shield)
if preview_mode:
var offhand = preview_data.get("offhand")
if offhand is WeaponData and offhand.can_block:
_add_stat_label("")
_add_stat_label("=== DEFENSE ===", Color(0.8, 0.8, 0.8))
_add_stat_label("Block Reduction: " + str(int(offhand.block_reduction * 100)) + "%")
## Refresh equipped weapons
func _refresh_weapons():
@@ -143,12 +238,26 @@ func _refresh_weapons():
push_error("[CharacterSheet] Failed to load weapon_slot.tscn")
return
# Resolve weapon data from preview loadout or live player
var main_data: WeaponData = null
var offhand_data: WeaponData = null
if preview_mode:
if preview_data.get("main") is WeaponData:
main_data = preview_data["main"]
if preview_data.get("offhand") is WeaponData:
offhand_data = preview_data["offhand"]
else:
if player.equipped_weapon and player.equipped_weapon.weapon_data:
main_data = player.equipped_weapon.weapon_data
if player.equipped_offhand and player.equipped_offhand.weapon_data:
offhand_data = player.equipped_offhand.weapon_data
# Create main hand slot
var main_hand_slot = weapon_slot_scene.instantiate()
slots_container.add_child(main_hand_slot)
if player.equipped_weapon and player.equipped_weapon.weapon_data:
main_hand_slot.set_weapon(player.equipped_weapon.weapon_data, "Main Hand")
if main_data:
main_hand_slot.set_weapon(main_data, "Main Hand")
else:
main_hand_slot.clear_weapon()
@@ -156,8 +265,8 @@ func _refresh_weapons():
var off_hand_slot = weapon_slot_scene.instantiate()
slots_container.add_child(off_hand_slot)
if player.equipped_offhand and player.equipped_offhand.weapon_data:
off_hand_slot.set_weapon(player.equipped_offhand.weapon_data, "Off-Hand")
if offhand_data:
off_hand_slot.set_weapon(offhand_data, "Off-Hand")
else:
off_hand_slot.clear_weapon()
@@ -182,17 +291,24 @@ func _refresh_abilities():
spacer.custom_minimum_size = Vector2(0, 10)
abilities_container.add_child(spacer)
# Dash ability
# Dash ability (defaults in preview, live values in game)
var dash_cd = PREVIEW_DASH_COOLDOWN if preview_mode else player.dash_cooldown
var dash_dur = PREVIEW_DASH_DURATION if preview_mode else player.dash_duration
var dash_mult = PREVIEW_DASH_MULTIPLIER if preview_mode else player.dash_speed_multiplier
_add_stat_label("--- DASH (F) ---", Color(0.8, 0.8, 0.8))
_add_stat_label("Cooldown: " + str(player.dash_cooldown) + "s")
_add_stat_label("Duration: " + str(player.dash_duration) + "s")
_add_stat_label("Speed: " + str(player.dash_speed_multiplier) + "x")
_add_stat_label("Cooldown: " + str(dash_cd) + "s")
_add_stat_label("Duration: " + str(dash_dur) + "s")
_add_stat_label("Speed: " + str(dash_mult) + "x")
_add_stat_label("Description: Dash in movement direction")
var dash_remaining = player._dash_cooldown_timer
if dash_remaining > 0:
_add_stat_label("Ready in: " + str(ceil(dash_remaining)) + "s", Color(1.0, 0.5, 0.5))
else:
if preview_mode:
_add_stat_label("Status: READY", Color(0.0, 1.0, 0.0))
else:
var dash_remaining = player._dash_cooldown_timer
if dash_remaining > 0:
_add_stat_label("Ready in: " + str(ceil(dash_remaining)) + "s", Color(1.0, 0.5, 0.5))
else:
_add_stat_label("Status: READY", Color(0.0, 1.0, 0.0))
# Spacer
var spacer2 = Control.new()
@@ -201,7 +317,7 @@ func _refresh_abilities():
# Jump ability
_add_stat_label("--- JUMP (Space) ---", Color(0.8, 0.8, 0.8))
_add_stat_label("Power: " + str(player.JUMP_VELOCITY))
_add_stat_label("Power: " + str(Character.JUMP_VELOCITY))
_add_stat_label("Description: Jump into the air")
_add_stat_label("Status: ALWAYS READY", Color(0.0, 1.0, 0.0))
+36 -2
View File
@@ -10,6 +10,7 @@ var character_sheet: Control = null
var tab_hint: Control = null
var keybind_hint: Control = null
var escape_menu: Control = null
var run_results: Control = null
# Player reference
var local_player: Character = null
@@ -157,6 +158,26 @@ func show_hud():
func hide_hud():
hide()
## Tear down all UI components (used when returning to the menu after a run).
## The HUD is an autoload, so its children survive scene reloads unless freed.
func reset():
if local_player and is_instance_valid(local_player):
_disconnect_player_signals()
local_player = null
for child in get_children():
child.queue_free()
action_bar = null
unit_frame = null
target_frame = null
character_sheet = null
tab_hint = null
keybind_hint = null
escape_menu = null
run_results = null
print("[HUD] Reset - all UI components removed")
## Player signal callbacks
func _on_player_health_changed(old_health: float, new_health: float):
# Update unit frame
@@ -164,8 +185,21 @@ func _on_player_health_changed(old_health: float, new_health: float):
unit_frame.update_health(new_health, local_player.max_health)
func _on_player_died(killer_id: int):
print("[HUD] Player died")
# Could show death screen or respawn timer here
print("[HUD] Player died - ending run")
_show_run_results()
## End the local player's run and show the results screen.
## GameState.end_run() is guarded against double-calls (died can fire twice on host).
func _show_run_results():
if run_results and is_instance_valid(run_results):
return # Already showing
var stats = GameState.end_run()
var results_script = load("res://level/ui/scripts/run_results.gd")
run_results = results_script.new()
run_results.setup(stats)
add_child(run_results)
func _on_player_respawned():
print("[HUD] Player respawned")
+248
View File
@@ -0,0 +1,248 @@
extends Control
class_name OutfittingScreen
## Gladiator outfitting screen shown on the main menu.
## Left: the armory shop (buy and equip gear with banked gold).
## Right: the character sheet in preview mode, live-updating as you change loadout.
## Reads/writes GameState (banked_gold, owned_items, selected_loadout).
const WEAPONS_DIR := "res://level/resources/"
# Catalog of WeaponData found on disk, sorted by tier then cost
var _catalog: Array[WeaponData] = []
var _catalog_paths: Dictionary = {} # WeaponData -> resource path
var _gold_label: Label = null
var _items_container: VBoxContainer = null
var _sheet: Control = null # CharacterSheet in preview mode
var _nick_input: LineEdit = null # menu's nickname field (for preview name)
func _ready():
_load_catalog()
_build_ui()
GameState.gold_changed.connect(func(_g): _refresh())
GameState.loadout_changed.connect(func(_l): _refresh())
_refresh()
## The menu's nickname LineEdit, so the preview shows the entered name
func set_nick_input(nick_input: LineEdit):
_nick_input = nick_input
if _nick_input:
_nick_input.text_changed.connect(func(_t): _update_preview())
# --- Catalog --------------------------------------------------------------
func _load_catalog():
_catalog.clear()
_catalog_paths.clear()
var dir = DirAccess.open(WEAPONS_DIR)
if not dir:
push_error("[Outfitting] Cannot open " + WEAPONS_DIR)
return
for file in dir.get_files():
# Exported builds list resources as .tres.remap
var file_name = file.trim_suffix(".remap")
if not file_name.ends_with(".tres"):
continue
var path = WEAPONS_DIR + file_name
var res = load(path)
if res is WeaponData:
_catalog.append(res)
_catalog_paths[res] = path
_catalog.sort_custom(func(a, b):
if a.tier != b.tier:
return a.tier < b.tier
return a.cost < b.cost
)
print("[Outfitting] Loaded ", _catalog.size(), " shop items")
# --- UI construction --------------------------------------------------------
func _build_ui():
var hbox = HBoxContainer.new()
hbox.set_anchors_preset(Control.PRESET_FULL_RECT)
hbox.add_theme_constant_override("separation", 16)
add_child(hbox)
# ----- Shop panel (left) -----
var shop_panel = PanelContainer.new()
shop_panel.custom_minimum_size = Vector2(420, 0)
shop_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
shop_panel.size_flags_stretch_ratio = 0.85
hbox.add_child(shop_panel)
var shop_margin = MarginContainer.new()
shop_margin.add_theme_constant_override("margin_left", 16)
shop_margin.add_theme_constant_override("margin_right", 16)
shop_margin.add_theme_constant_override("margin_top", 12)
shop_margin.add_theme_constant_override("margin_bottom", 12)
shop_panel.add_child(shop_margin)
var shop_vbox = VBoxContainer.new()
shop_vbox.add_theme_constant_override("separation", 8)
shop_margin.add_child(shop_vbox)
var shop_title = Label.new()
shop_title.text = "THE ARMORY"
shop_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
shop_title.add_theme_font_size_override("font_size", 26)
shop_title.add_theme_color_override("font_color", Color(1.0, 0.8, 0.0))
shop_vbox.add_child(shop_title)
_gold_label = Label.new()
_gold_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_gold_label.add_theme_font_size_override("font_size", 20)
_gold_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.2))
shop_vbox.add_child(_gold_label)
var hint = Label.new()
hint.text = "Buy gear, equip it, then enter the arena.\nDie and the arena takes 90% of everything you're worth."
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_font_size_override("font_size", 13)
hint.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
shop_vbox.add_child(hint)
shop_vbox.add_child(HSeparator.new())
var scroll = ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
shop_vbox.add_child(scroll)
_items_container = VBoxContainer.new()
_items_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_items_container.add_theme_constant_override("separation", 6)
scroll.add_child(_items_container)
# ----- Character sheet preview (right) -----
var sheet_scene = load("res://level/ui/scenes/character_sheet.tscn")
if sheet_scene:
_sheet = sheet_scene.instantiate()
_sheet.preview_mode = true # Must be set before _ready runs
_sheet.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_sheet.size_flags_stretch_ratio = 1.0
_sheet.custom_minimum_size = Vector2(480, 0)
hbox.add_child(_sheet)
else:
push_error("[Outfitting] Failed to load character_sheet.tscn")
# --- Refresh ---------------------------------------------------------------
func _refresh():
if _gold_label:
_gold_label.text = "Gold: " + str(GameState.banked_gold)
_rebuild_items()
_update_preview()
func _rebuild_items():
if not _items_container:
return
for child in _items_container.get_children():
child.queue_free()
for weapon in _catalog:
_items_container.add_child(_make_item_row(weapon))
func _make_item_row(weapon: WeaponData) -> Control:
var path: String = _catalog_paths[weapon]
var owned := GameState.owns_item(path)
var selected := GameState.is_loadout_item_selected(path)
var row = PanelContainer.new()
var margin = MarginContainer.new()
margin.add_theme_constant_override("margin_left", 8)
margin.add_theme_constant_override("margin_right", 8)
margin.add_theme_constant_override("margin_top", 6)
margin.add_theme_constant_override("margin_bottom", 6)
row.add_child(margin)
var hbox = HBoxContainer.new()
hbox.add_theme_constant_override("separation", 10)
margin.add_child(hbox)
# Name + stat line
var info = VBoxContainer.new()
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(info)
var name_label = Label.new()
name_label.text = weapon.weapon_name + (" [" + _hand_label(weapon) + "]")
name_label.add_theme_font_size_override("font_size", 16)
name_label.add_theme_color_override("font_color",
Color(0.4, 1.0, 0.4) if selected else (Color(0.85, 0.85, 1.0) if owned else Color.WHITE))
info.add_child(name_label)
var stats_label = Label.new()
var stat_bits: Array[String] = ["DMG " + str(weapon.damage), "CD " + str(weapon.attack_cooldown) + "s"]
if weapon.can_block:
stat_bits.append("Block " + str(int(weapon.block_reduction * 100)) + "%")
stats_label.text = " | ".join(stat_bits)
stats_label.add_theme_font_size_override("font_size", 12)
stats_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
info.add_child(stats_label)
# Action button
var button = Button.new()
button.custom_minimum_size = Vector2(120, 36)
if not owned:
button.text = str(weapon.cost) + "g Buy"
button.disabled = not GameState.can_afford(weapon.cost)
button.pressed.connect(_on_buy_pressed.bind(path, weapon.cost))
elif selected:
button.text = "Equipped"
button.pressed.connect(_on_unequip_pressed.bind(weapon))
else:
button.text = "Equip"
button.pressed.connect(_on_equip_pressed.bind(path))
hbox.add_child(button)
return row
func _hand_label(weapon: WeaponData) -> String:
match weapon.hand_type:
WeaponData.Hand.OFF_HAND: return "Off-Hand"
WeaponData.Hand.TWO_HAND: return "Two-Hand"
_: return "Main Hand"
# --- Actions ----------------------------------------------------------------
func _on_buy_pressed(path: String, cost: int):
if GameState.buy_item(path, cost):
# Auto-equip fresh purchases - you bought it to use it
GameState.equip_loadout_item(path)
# _refresh happens via gold_changed/loadout_changed signals
func _on_equip_pressed(path: String):
GameState.equip_loadout_item(path)
func _on_unequip_pressed(weapon: WeaponData):
var slot = "offhand" if weapon.hand_type == WeaponData.Hand.OFF_HAND else "main"
GameState.unequip_loadout_slot(slot)
# --- Preview ----------------------------------------------------------------
func _update_preview():
if not _sheet:
return
var loadout = GameState.get_selected_loadout()
var main_data = null
var offhand_data = null
if loadout.get("main", "") != "":
main_data = load(loadout["main"])
if loadout.get("offhand", "") != "":
offhand_data = load(loadout["offhand"])
var nick = "Gladiator"
if _nick_input and _nick_input.text.strip_edges() != "":
nick = _nick_input.text.strip_edges()
_sheet.set_preview({
"nick": nick,
"main": main_data,
"offhand": offhand_data,
})
@@ -0,0 +1 @@
uid://ngicph2qsf0m
+134
View File
@@ -0,0 +1,134 @@
extends Control
class_name RunResults
## End-of-run results screen for the gladiator arena.
## Shown by the HUD when the local player dies. Displays survival stats and
## the death settlement (keep 10% of total value, floored at the fresh-start
## default), then returns the player to the menu/camp.
var stats: Dictionary = {}
func setup(run_stats: Dictionary):
stats = run_stats
func _ready():
# Full-screen dark backdrop
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
var backdrop = ColorRect.new()
backdrop.color = Color(0.0, 0.0, 0.0, 0.75)
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(backdrop)
# Centered panel
var center = CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(center)
var panel = PanelContainer.new()
panel.custom_minimum_size = Vector2(460, 0)
center.add_child(panel)
var margin = MarginContainer.new()
margin.add_theme_constant_override("margin_left", 30)
margin.add_theme_constant_override("margin_right", 30)
margin.add_theme_constant_override("margin_top", 24)
margin.add_theme_constant_override("margin_bottom", 24)
panel.add_child(margin)
var vbox = VBoxContainer.new()
vbox.add_theme_constant_override("separation", 8)
margin.add_child(vbox)
# Title
var title = Label.new()
title.text = "YOU HAVE FALLEN"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 36)
title.add_theme_color_override("font_color", Color(0.9, 0.2, 0.2))
vbox.add_child(title)
var subtitle = Label.new()
subtitle.text = "The arena claims another gladiator"
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
subtitle.add_theme_font_size_override("font_size", 16)
subtitle.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
vbox.add_child(subtitle)
_add_spacer(vbox, 12)
# Survival stats
_add_header(vbox, "=== SURVIVAL ===")
_add_stat_row(vbox, "Waves Survived", str(int(stats.get("waves", 0))))
_add_stat_row(vbox, "Time in Arena", _format_time(stats.get("time", 0.0)))
_add_stat_row(vbox, "Kills", str(int(stats.get("kills", 0))))
_add_spacer(vbox, 12)
# Settlement
_add_header(vbox, "=== SETTLEMENT ===")
_add_stat_row(vbox, "Gold Earned This Run", str(int(stats.get("gold_earned", 0))))
_add_stat_row(vbox, "Total Value at Death", str(int(stats.get("total_value", 0))))
var kept = int(stats.get("kept", 0))
var kept_row = Label.new()
kept_row.text = "The arena takes its cut... you keep " + str(kept) + " gold"
kept_row.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
kept_row.add_theme_font_size_override("font_size", 18)
kept_row.add_theme_color_override("font_color", Color(1.0, 0.8, 0.0))
vbox.add_child(kept_row)
_add_spacer(vbox, 16)
# Return button
var return_button = Button.new()
return_button.text = "Return to Camp"
return_button.custom_minimum_size = Vector2(0, 44)
return_button.pressed.connect(_on_return_pressed)
vbox.add_child(return_button)
# Make sure the mouse is usable on the overlay
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func _add_header(parent: Control, text: String):
var label = Label.new()
label.text = text
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", 16)
label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8))
parent.add_child(label)
func _add_stat_row(parent: Control, label_text: String, value_text: String):
var row = HBoxContainer.new()
var label = Label.new()
label.text = label_text
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.add_theme_font_size_override("font_size", 16)
row.add_child(label)
var value = Label.new()
value.text = value_text
value.add_theme_font_size_override("font_size", 16)
value.add_theme_color_override("font_color", Color(1.0, 0.8, 0.0))
row.add_child(value)
parent.add_child(row)
func _add_spacer(parent: Control, height: int):
var spacer = Control.new()
spacer.custom_minimum_size = Vector2(0, height)
parent.add_child(spacer)
func _format_time(seconds: float) -> String:
var total = int(seconds)
return "%dm %02ds" % [total / 60, total % 60]
func _on_return_pressed():
# Tear down the multiplayer session and go back to the menu.
# (Host quitting ends the session for connected clients - acceptable for now.)
multiplayer.multiplayer_peer = null
Network.players.clear()
if has_node("/root/HUD"):
get_node("/root/HUD").reset()
get_tree().reload_current_scene()
+1
View File
@@ -0,0 +1 @@
uid://i1s8oc7snqnm
+36
View File
@@ -5,6 +5,8 @@ class_name WeaponSlot
# References
@onready var icon_rect: TextureRect = $SlotPanel/MarginContainer/IconRect
var tooltip: PanelContainer = null
# Fallback name display for weapons without an icon texture
var _name_label: Label = null
# Weapon data
var weapon_data: WeaponData = null
@@ -53,9 +55,43 @@ func _update_display():
if weapon_data and weapon_data.icon:
icon_rect.texture = weapon_data.icon
icon_rect.modulate = Color.WHITE
_set_name_fallback("")
elif weapon_data:
# No icon on this weapon - show its name in the slot instead
icon_rect.texture = null
icon_rect.modulate = Color.WHITE
_set_name_fallback(weapon_data.weapon_name)
else:
icon_rect.texture = null
icon_rect.modulate = Color(0.3, 0.3, 0.3, 0.5) # Dim empty slot
_set_name_fallback("")
## Show/hide the fallback name label ("" hides it)
func _set_name_fallback(text: String):
if text == "":
if _name_label:
_name_label.hide()
return
if not _name_label:
_name_label = Label.new()
_name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_name_label.add_theme_font_size_override("font_size", 12)
_name_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.4))
_name_label.add_theme_color_override("font_outline_color", Color.BLACK)
_name_label.add_theme_constant_override("outline_size", 2)
_name_label.set_anchors_preset(Control.PRESET_FULL_RECT)
_name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
var container = get_node_or_null("SlotPanel/MarginContainer")
if container:
container.add_child(_name_label)
else:
add_child(_name_label)
_name_label.text = text
_name_label.show()
## Show tooltip on mouse enter
func _on_mouse_entered():
+1
View File
@@ -20,6 +20,7 @@ config/icon="res://icon.png"
[autoload]
Network="*res://level/scripts/network.gd"
GameState="*res://level/scripts/game_state.gd"
HUD="*res://level/ui/scripts/hud_manager.gd"
[display]