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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlcyyjtlb8s7x
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://i1s8oc7snqnm
|
||||
@@ -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():
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user