From 1562b375635375a8fe65b95f5298838eab0a57e2 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 1 Jul 2026 23:37:56 +0100 Subject: [PATCH] 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). --- level/resources/weapon_applecorer.tres | 2 + level/resources/weapon_lobsteraxe.tres | 2 + level/resources/weapon_shield.tres | 2 + level/resources/weapon_sword.tres | 2 + level/resources/weapon_testsword.tres | 2 + level/scripts/3d_godot_robot.gd | 4 + level/scripts/armed_enemy.gd | 7 + level/scripts/base_enemy.gd | 2 + level/scripts/enemy_spawner.gd | 12 + level/scripts/game_state.gd | 256 ++++++++++++++++++++++ level/scripts/game_state.gd.uid | 1 + level/scripts/hit_box.gd | 4 +- level/scripts/hurt_box.gd | 4 +- level/scripts/level.gd | 59 +++++ level/scripts/lilguy_body.gd | 4 + level/scripts/player.gd | 58 ++++- level/scripts/weapon_data.gd | 6 + level/ui/scripts/character_sheet.gd | 180 ++++++++++++--- level/ui/scripts/hud_manager.gd | 38 +++- level/ui/scripts/outfitting_screen.gd | 248 +++++++++++++++++++++ level/ui/scripts/outfitting_screen.gd.uid | 1 + level/ui/scripts/run_results.gd | 134 +++++++++++ level/ui/scripts/run_results.gd.uid | 1 + level/ui/scripts/weapon_slot.gd | 36 +++ project.godot | 1 + 25 files changed, 1023 insertions(+), 43 deletions(-) create mode 100644 level/scripts/game_state.gd create mode 100644 level/scripts/game_state.gd.uid create mode 100644 level/ui/scripts/outfitting_screen.gd create mode 100644 level/ui/scripts/outfitting_screen.gd.uid create mode 100644 level/ui/scripts/run_results.gd create mode 100644 level/ui/scripts/run_results.gd.uid diff --git a/level/resources/weapon_applecorer.tres b/level/resources/weapon_applecorer.tres index 9c2d1fc..1c7623d 100644 --- a/level/resources/weapon_applecorer.tres +++ b/level/resources/weapon_applecorer.tres @@ -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 diff --git a/level/resources/weapon_lobsteraxe.tres b/level/resources/weapon_lobsteraxe.tres index 13c40e4..4755e4a 100644 --- a/level/resources/weapon_lobsteraxe.tres +++ b/level/resources/weapon_lobsteraxe.tres @@ -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 diff --git a/level/resources/weapon_shield.tres b/level/resources/weapon_shield.tres index b8dea5f..ed19ff6 100644 --- a/level/resources/weapon_shield.tres +++ b/level/resources/weapon_shield.tres @@ -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 diff --git a/level/resources/weapon_sword.tres b/level/resources/weapon_sword.tres index 2e73bb8..89d562d 100644 --- a/level/resources/weapon_sword.tres +++ b/level/resources/weapon_sword.tres @@ -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 diff --git a/level/resources/weapon_testsword.tres b/level/resources/weapon_testsword.tres index cc61405..acc9b3c 100644 --- a/level/resources/weapon_testsword.tres +++ b/level/resources/weapon_testsword.tres @@ -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 diff --git a/level/scripts/3d_godot_robot.gd b/level/scripts/3d_godot_robot.gd index 8e18898..66adb3f 100644 --- a/level/scripts/3d_godot_robot.gd +++ b/level/scripts/3d_godot_robot.gd @@ -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": diff --git a/level/scripts/armed_enemy.gd b/level/scripts/armed_enemy.gd index c5adfb5..42418ad 100644 --- a/level/scripts/armed_enemy.gd +++ b/level/scripts/armed_enemy.gd @@ -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"): diff --git a/level/scripts/base_enemy.gd b/level/scripts/base_enemy.gd index 4554754..b922b52 100644 --- a/level/scripts/base_enemy.gd +++ b/level/scripts/base_enemy.gd @@ -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() diff --git a/level/scripts/enemy_spawner.gd b/level/scripts/enemy_spawner.gd index 6dc06bb..03ce491 100644 --- a/level/scripts/enemy_spawner.gd +++ b/level/scripts/enemy_spawner.gd @@ -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() diff --git a/level/scripts/game_state.gd b/level/scripts/game_state.gd new file mode 100644 index 0000000..09131c4 --- /dev/null +++ b/level/scripts/game_state.gd @@ -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 diff --git a/level/scripts/game_state.gd.uid b/level/scripts/game_state.gd.uid new file mode 100644 index 0000000..b2713e4 --- /dev/null +++ b/level/scripts/game_state.gd.uid @@ -0,0 +1 @@ +uid://dlcyyjtlb8s7x diff --git a/level/scripts/hit_box.gd b/level/scripts/hit_box.gd index aba54cd..2b748d5 100644 --- a/level/scripts/hit_box.gd +++ b/level/scripts/hit_box.gd @@ -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 diff --git a/level/scripts/hurt_box.gd b/level/scripts/hurt_box.gd index f09bc3a..e963b7d 100644 --- a/level/scripts/hurt_box.gd +++ b/level/scripts/hurt_box.gd @@ -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 diff --git a/level/scripts/level.gd b/level/scripts/level.gd index 66db664..d30186a 100644 --- a/level/scripts/level.gd +++ b/level/scripts/level.gd @@ -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(): diff --git a/level/scripts/lilguy_body.gd b/level/scripts/lilguy_body.gd index 021c0a6..c5e223a 100644 --- a/level/scripts/lilguy_body.gd +++ b/level/scripts/lilguy_body.gd @@ -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 diff --git a/level/scripts/player.gd b/level/scripts/player.gd index 67d2350..353517a 100644 --- a/level/scripts/player.gd +++ b/level/scripts/player.gd @@ -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: diff --git a/level/scripts/weapon_data.gd b/level/scripts/weapon_data.gd index 8145d38..168ffe9 100644 --- a/level/scripts/weapon_data.gd +++ b/level/scripts/weapon_data.gd @@ -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 diff --git a/level/ui/scripts/character_sheet.gd b/level/ui/scripts/character_sheet.gd index 6b03813..28ae590 100644 --- a/level/ui/scripts/character_sheet.gd +++ b/level/ui/scripts/character_sheet.gd @@ -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)) diff --git a/level/ui/scripts/hud_manager.gd b/level/ui/scripts/hud_manager.gd index a5fd7f0..cff03b1 100644 --- a/level/ui/scripts/hud_manager.gd +++ b/level/ui/scripts/hud_manager.gd @@ -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") diff --git a/level/ui/scripts/outfitting_screen.gd b/level/ui/scripts/outfitting_screen.gd new file mode 100644 index 0000000..d9f3cee --- /dev/null +++ b/level/ui/scripts/outfitting_screen.gd @@ -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, + }) diff --git a/level/ui/scripts/outfitting_screen.gd.uid b/level/ui/scripts/outfitting_screen.gd.uid new file mode 100644 index 0000000..9725356 --- /dev/null +++ b/level/ui/scripts/outfitting_screen.gd.uid @@ -0,0 +1 @@ +uid://ngicph2qsf0m diff --git a/level/ui/scripts/run_results.gd b/level/ui/scripts/run_results.gd new file mode 100644 index 0000000..2dd08a5 --- /dev/null +++ b/level/ui/scripts/run_results.gd @@ -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() diff --git a/level/ui/scripts/run_results.gd.uid b/level/ui/scripts/run_results.gd.uid new file mode 100644 index 0000000..e79ee64 --- /dev/null +++ b/level/ui/scripts/run_results.gd.uid @@ -0,0 +1 @@ +uid://i1s8oc7snqnm diff --git a/level/ui/scripts/weapon_slot.gd b/level/ui/scripts/weapon_slot.gd index 69fc243..9c6e6cb 100644 --- a/level/ui/scripts/weapon_slot.gd +++ b/level/ui/scripts/weapon_slot.gd @@ -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(): diff --git a/project.godot b/project.godot index 6090984..5bf10ca 100644 --- a/project.godot +++ b/project.godot @@ -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]