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).
257 lines
8.3 KiB
GDScript
257 lines
8.3 KiB
GDScript
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
|