class_name Character extends RefCounted ## One playable character belonging to an account. ## ## Characters outlive a session and a server restart, so this is a persistence ## record first and a gameplay object second: everything here has to survive a ## round trip through JSON without losing meaning. ## ## Death does not delete a character. It clears [member active], which retires ## it from the roster while keeping the record for archival and for working out ## what happened after the fact. const MAX_NAME := 20 var id: String = "" var display_name: String = "adventurer" ## Placeholder identity until there is a cosmetic system. Random per character ## so party members are told apart at a glance. var colour := Color.WHITE var level: int = Progression.START_LEVEL var total_xp: int = 0 ## False once the character has died. Never deleted -- see the class note. var active: bool = true var created_unix: int = 0 var died_unix: int = 0 ## Carried items, one id per slot, [constant Items.NONE] where empty. Stored on ## the character rather than on the session so that swapping in the hub swaps ## bags, and so a server restart does not quietly confiscate everyone's potions. var inventory: Array[StringName] = [] ## Upgrades taken, in the order they were taken. Duplicates are meaningful -- ## two Split Shots really is two splits -- so this is a list, not a set. ## ## The derived numbers (damage, fire rate, health multiplier) are NOT stored: ## [PlayerStats] rebuilds them from this list, so a saved stat can never ## disagree with the upgrades that produced it. var upgrades: Array[StringName] = [] ## Level-ups not yet spent at the hub NPC. They queue: reaching two levels in ## one run owes you two choices, because losing one for doing well is a ## punishment nobody would guess at. var pending_choices: int = 0 ## The choices currently on the table, held so that walking away and coming back ## shows the same three. Without this, closing and reopening the screen would be ## a free reroll until a legendary turned up. var offer: Array[StringName] = [] ## Suggested names, offered when creating a character so the field is never ## blank. Deliberately a pair of short word lists rather than a big table: the ## point is a usable default the player can overwrite, not a naming system. const NAME_FIRST: PackedStringArray = [ "Ash", "Bram", "Cass", "Dorn", "Elm", "Fen", "Gale", "Hale", "Iva", "Jory", "Kit", "Lark", "Mox", "Nell", "Orin", "Pike", "Quill", "Ren", "Sable", "Thorn", "Vesper", "Wren", "Yarrow", "Zel", ] const NAME_LAST: PackedStringArray = [ "blade", "briar", "creek", "dusk", "ember", "fell", "grim", "hollow", "iron", "kettle", "moor", "night", "quarry", "rook", "shade", "thistle", "vale", "wick", ] static func random_name(rng: RandomNumberGenerator) -> String: return "%s%s" % [ NAME_FIRST[rng.randi() % NAME_FIRST.size()], NAME_LAST[rng.randi() % NAME_LAST.size()], ] func _init() -> void: inventory.resize(SimConfig.INVENTORY_SLOTS) inventory.fill(Items.NONE) static func create(character_name: String, rng: RandomNumberGenerator) -> Character: var c := Character.new() # Random-but-readable: full saturation and high value, so two characters are # never both muddy browns, and none of them vanish against the floor. c.colour = Color.from_hsv(rng.randf(), 0.55, 1.0) c.id = "%d-%d" % [Time.get_unix_time_from_system(), rng.randi() & 0xFFFFFF] c.display_name = sanitize_name(character_name) c.created_unix = int(Time.get_unix_time_from_system()) return c ## Names come from clients and are shown to other players, so they are clamped ## here rather than trusted anywhere downstream. static func sanitize_name(raw: String) -> String: var clean := raw.strip_edges().substr(0, MAX_NAME) # Control characters would let a name break the HUD's layout. var out := "" for ch in clean: if ch.unicode_at(0) >= 32: out += ch out = out.strip_edges() return out if not out.is_empty() else "adventurer" ## What this character actually walks around with: the level's health, scaled by ## whatever the upgrades do to it. Shown on the roster screen, so it has to be ## the real number and not the pre-upgrade one. func max_hp() -> int: return maxi(1, roundi(float(Progression.max_hp_for_level(level)) * PlayerStats.build(upgrades).max_hp_mult)) func xp_progress() -> float: return Progression.level_progress(total_xp) ## Award experience and return how many levels it produced, so the caller can ## announce them. Level is derived from lifetime xp rather than tracked ## separately: one source of truth means a level can never disagree with the ## experience that earned it. func grant_xp(amount: int) -> int: if amount <= 0 or not active: return 0 var before := level total_xp += amount level = Progression.level_for_xp(total_xp) return level - before func retire(when_unix: int = 0) -> void: active = false died_unix = when_unix if when_unix > 0 else int(Time.get_unix_time_from_system()) func to_dict() -> Dictionary: return { "id": id, "name": display_name, "colour": colour.to_html(false), "level": level, "xp": total_xp, "active": active, "created": created_unix, "died": died_unix, # Written as ids rather than indices: a save file has to survive # Items.ORDER being appended to, and a human editing it should be able # to tell what a character is carrying. "inventory": _inventory_ids(), "upgrades": _name_list(upgrades), "pending_choices": pending_choices, "offer": _name_list(offer), } static func _name_list(ids: Array[StringName]) -> Array: var out := [] for id in ids: out.append(String(id)) return out func _inventory_ids() -> Array: var out := [] for item in inventory: out.append(String(item)) return out ## Tolerant of missing keys so an older save file still loads: a character that ## has lost a field is far better than an account that will not open. static func from_dict(d: Dictionary) -> Character: var c := Character.new() c.id = String(d.get("id", "")) c.display_name = sanitize_name(String(d.get("name", ""))) c.colour = Color.from_string(String(d.get("colour", "ffffff")), Color.WHITE) c.total_xp = maxi(int(d.get("xp", 0)), 0) # Derived, not read: a hand-edited or corrupted level cannot desync from xp. c.level = Progression.level_for_xp(c.total_xp) c.active = bool(d.get("active", true)) c.created_unix = int(d.get("created", 0)) c.died_unix = int(d.get("died", 0)) # Unknown ids decay to empty rather than to a wrong item -- a save written # by a build with an item this one has never heard of must still load. var carried: Array[StringName] = [] for raw in d.get("inventory", []): var carried_id := StringName(String(raw)) carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE) c.set_inventory(carried) # Unknown upgrade ids are dropped rather than kept as dead entries, so a # save from a build with an upgrade this one lacks still produces coherent # stats instead of a phantom that counts toward the +5% and does nothing. c.upgrades = _known_upgrades(d.get("upgrades", [])) c.pending_choices = maxi(int(d.get("pending_choices", 0)), 0) c.offer = _known_upgrades(d.get("offer", [])) return c static func _known_upgrades(raw: Array) -> Array[StringName]: var out: Array[StringName] = [] for entry in raw: var id := StringName(String(entry)) if Upgrades.get_def(id) != null: out.append(id) return out ## Replace the whole inventory, padded or trimmed to the current slot count. func set_inventory(items: Array[StringName]) -> void: inventory.resize(SimConfig.INVENTORY_SLOTS) for i in SimConfig.INVENTORY_SLOTS: inventory[i] = items[i] if i < items.size() else Items.NONE