Files
SurvivalOfTheSnippest/level/scripts/game_state.gd
T

287 lines
9.1 KiB
GDScript
Raw Normal View History

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