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
|