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() func _notification(what: int) -> void: # Closing the window mid-run counts as walking out alive - bank the winnings if what == NOTIFICATION_WM_CLOSE_REQUEST and run_active: retire_run() # --- 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 ## Walk out of the arena alive: bank 100% of run gold, keep all gear. ## The reward for retiring instead of dying (death only keeps 10%). func retire_run() -> Dictionary: if not run_active: return last_run_stats run_active = false last_run_stats = { "waves": current_wave, "kills": kills_this_run, "time": get_run_time(), "gold_earned": run_gold, "total_value": get_total_value(), "kept": banked_gold + run_gold, "retired": true, } banked_gold += run_gold run_gold = 0 gold_changed.emit(banked_gold) run_gold_changed.emit(run_gold) 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