Gladiator arena: gold economy, outfitting shop, permadeath runs

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

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

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

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

Death is final: players no longer respawn; a results screen shows waves,
time, kills, and the settlement, then returns to camp. Armed enemies no
longer respawn (were an infinite gold farm and respawned invisible).
This commit is contained in:
2026-07-01 23:38:59 +01:00
parent 97ebbb1618
commit 1562b37563
25 changed files with 1023 additions and 43 deletions
+256
View File
@@ -0,0 +1,256 @@
extends Node
## Autoload singleton holding the gladiator roguelike economy + meta-progression.
## Accessible globally as `GameState`.
##
## - banked_gold persists between sessions (saved to user://save.cfg).
## - owned_items are resource paths of weapons the player has bought.
## - run_gold is gold earned during the current arena run (NOT persisted).
##
## Death settlement: on run end you keep 10% of your total value (banked + run
## gold + owned gear value), floored at the DEFAULT_GOLD starting amount so you
## can never fall below a fresh start. This punishes hoarding and rewards spending
## before you enter the arena.
const SAVE_PATH: String = "user://save.cfg"
const DEFAULT_GOLD: int = 500
## Fraction of total value kept on death.
const SURVIVAL_KEEP_FRACTION: float = 0.10
# Persisted progression
var banked_gold: int = DEFAULT_GOLD
var owned_items: Array[String] = []
# Loadout chosen in the outfitting screen: slot -> WeaponData resource path ("" = empty)
var selected_loadout: Dictionary = {"main": "", "offhand": ""}
# Per-run state (reset each run, not saved)
var run_gold: int = 0
var run_active: bool = false
var current_wave: int = 0
var kills_this_run: int = 0
var last_run_stats: Dictionary = {}
var _run_start_msec: int = 0
signal gold_changed(banked_gold: int)
signal run_gold_changed(run_gold: int)
signal wave_changed(wave: int)
signal run_ended(stats: Dictionary)
signal loadout_changed(loadout: Dictionary)
func _ready() -> void:
load_save()
# --- Persistence ---------------------------------------------------------
func load_save() -> void:
var cfg := ConfigFile.new()
var err := cfg.load(SAVE_PATH)
if err != OK:
# No save yet (or unreadable): start fresh.
banked_gold = DEFAULT_GOLD
owned_items = []
save_game()
return
banked_gold = int(cfg.get_value("progress", "banked_gold", DEFAULT_GOLD))
# ConfigFile returns an untyped Array; copy into the typed array.
owned_items.clear()
for path in cfg.get_value("progress", "owned_items", []):
owned_items.append(str(path))
# Restore loadout, dropping anything no longer owned
var saved_loadout = cfg.get_value("progress", "selected_loadout", {"main": "", "offhand": ""})
for slot in ["main", "offhand"]:
var path := str(saved_loadout.get(slot, ""))
selected_loadout[slot] = path if owns_item(path) else ""
func save_game() -> void:
var cfg := ConfigFile.new()
cfg.set_value("progress", "banked_gold", banked_gold)
cfg.set_value("progress", "owned_items", owned_items)
cfg.set_value("progress", "selected_loadout", selected_loadout)
cfg.save(SAVE_PATH)
# --- Shop economy --------------------------------------------------------
func can_afford(cost: int) -> bool:
return banked_gold >= cost
## Buy an item by its WeaponData resource path. Returns true on success.
func buy_item(resource_path: String, cost: int) -> bool:
if not can_afford(cost):
return false
banked_gold -= cost
if not owned_items.has(resource_path):
owned_items.append(resource_path)
gold_changed.emit(banked_gold)
save_game()
return true
func owns_item(resource_path: String) -> bool:
return owned_items.has(resource_path)
# --- Loadout (what you walk into the arena with) --------------------------
## Equip an owned item into the slot its hand_type dictates.
## Two-handed weapons take the main slot and clear the off-hand.
func equip_loadout_item(resource_path: String) -> bool:
if not owns_item(resource_path):
return false
var data = load(resource_path)
if not data is WeaponData:
return false
match data.hand_type:
WeaponData.Hand.OFF_HAND:
selected_loadout["offhand"] = resource_path
# An off-hand displaces a two-handed main - you only have two hands
if _is_two_hand(selected_loadout.get("main", "")):
selected_loadout["main"] = ""
WeaponData.Hand.TWO_HAND:
selected_loadout["main"] = resource_path
selected_loadout["offhand"] = ""
_: # MAIN_HAND
selected_loadout["main"] = resource_path
# If the previous main was two-handed it's gone now; offhand stays as-is
loadout_changed.emit(selected_loadout)
save_game()
return true
func unequip_loadout_slot(slot: String) -> void:
if selected_loadout.get(slot, "") != "":
selected_loadout[slot] = ""
loadout_changed.emit(selected_loadout)
save_game()
func is_loadout_item_selected(resource_path: String) -> bool:
return selected_loadout.get("main", "") == resource_path \
or selected_loadout.get("offhand", "") == resource_path
func get_selected_loadout() -> Dictionary:
return selected_loadout
# --- Run lifecycle -------------------------------------------------------
func start_run() -> void:
run_gold = 0
current_wave = 0
kills_this_run = 0
run_active = true
_run_start_msec = Time.get_ticks_msec()
run_gold_changed.emit(run_gold)
wave_changed.emit(current_wave)
func add_run_gold(amount: int) -> void:
run_gold += amount
run_gold_changed.emit(run_gold)
## Seconds since the current run started.
func get_run_time() -> float:
if _run_start_msec == 0:
return 0.0
return (Time.get_ticks_msec() - _run_start_msec) / 1000.0
## Total liquid value of the player: banked + earned this run + value of owned gear.
func get_total_value() -> int:
var total := banked_gold + run_gold
for path in owned_items:
total += _item_cost(path)
return total
## Settle a finished run (death). Liquidates gear, keeps 10% of total value
## floored at DEFAULT_GOLD, then persists. Returns a stats Dictionary.
## Guarded against double-calls (died can fire more than once on the host).
func end_run() -> Dictionary:
if not run_active:
return last_run_stats
run_active = false
var total := get_total_value()
var kept: int = max(DEFAULT_GOLD, int(floor(total * SURVIVAL_KEEP_FRACTION)))
last_run_stats = {
"waves": current_wave,
"kills": kills_this_run,
"time": get_run_time(),
"gold_earned": run_gold,
"total_value": total,
"kept": kept,
}
banked_gold = kept
owned_items.clear()
selected_loadout = {"main": "", "offhand": ""}
run_gold = 0
gold_changed.emit(banked_gold)
run_gold_changed.emit(run_gold)
loadout_changed.emit(selected_loadout)
save_game()
run_ended.emit(last_run_stats)
return last_run_stats
# --- Multiplayer crediting (server -> owning peer) ------------------------
# GameState is an autoload, so it exists at the same path on every peer with
# the server (peer 1) as its multiplayer authority. The server decides who
# earned gold and credits exactly that peer's local GameState.
## Server-side entry point: credit kill gold (and a kill) to a specific peer.
func server_credit_kill(peer_id: int, amount: int) -> void:
if not multiplayer.is_server():
return
if peer_id == 1:
credit_kill(amount) # Host credits itself directly
elif _is_known_peer(peer_id):
rpc_id(peer_id, "credit_kill", amount)
## Server-side entry point: credit plain gold (e.g. wave bonus) to a peer.
func server_credit_gold(peer_id: int, amount: int) -> void:
if not multiplayer.is_server():
return
if peer_id == 1:
credit_gold(amount)
elif _is_known_peer(peer_id):
rpc_id(peer_id, "credit_gold", amount)
## Runtime lookup so this singleton has no compile-time dependency on Network
## (keeps the economy testable standalone).
func _is_known_peer(peer_id: int) -> bool:
var network = get_node_or_null("/root/Network")
return network != null and network.players.has(peer_id)
## Server-side entry point: broadcast the current wave number to all peers.
func server_sync_wave(wave: int) -> void:
if not multiplayer.is_server():
return
rpc("sync_wave", wave)
@rpc("authority", "reliable")
func credit_gold(amount: int) -> void:
if not run_active:
return
add_run_gold(amount)
@rpc("authority", "reliable")
func credit_kill(amount: int) -> void:
if not run_active:
return
kills_this_run += 1
add_run_gold(amount)
@rpc("authority", "call_local", "reliable")
func sync_wave(wave: int) -> void:
current_wave = wave
wave_changed.emit(current_wave)
# --- Helpers -------------------------------------------------------------
func _is_two_hand(resource_path: String) -> bool:
if resource_path.is_empty() or not ResourceLoader.exists(resource_path):
return false
var res = load(resource_path)
return res is WeaponData and res.hand_type == WeaponData.Hand.TWO_HAND
func _item_cost(resource_path: String) -> int:
if resource_path.is_empty() or not ResourceLoader.exists(resource_path):
return 0
var res := load(resource_path)
if res is WeaponData:
return res.cost
return 0