Stores only what was rolled or chosen, plus current hp/mp (the only two numbers that genuinely mutate). Everything else is a function, so no derived number can drift from its inputs and M5's level curve is a function change, not a migration. LCK has no row and no accessor here — §7 holds by construction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
95 lines
2.9 KiB
GDScript
95 lines
2.9 KiB
GDScript
class_name CharacterSheet
|
|
extends RefCounted
|
|
## The character, as state (§2). STORES only what was rolled or chosen, plus the two
|
|
## numbers that genuinely mutate in play (current hp/mp). Everything else is DERIVED —
|
|
## so a derived number can never drift from the inputs that produced it, and M5's level
|
|
## curve becomes a function change rather than a stored-field migration.
|
|
##
|
|
## LCK IS NOT HERE, and there is no accessor for it. It lives in GameState.luck: no
|
|
## save, no skill, no row (§7). That holds by construction, not by discipline.
|
|
|
|
const PROF_BONUS_L1 := 2
|
|
|
|
# --- stored: rolled, chosen, or genuinely mutable -------------------------------
|
|
var attributes: Dictionary = {} # {str,dex,con,fth,mag} — final, post-spend
|
|
var race_id: String = ""
|
|
var calling_id: String = ""
|
|
var level: int = 1
|
|
var skills: Array = [] # chosen + race-granted + the human's bonus pick
|
|
var bonus_skill: String = "" # human only; also present in `skills`
|
|
var hp: int = 0 # CURRENT hp — combat writes this
|
|
var mp: int = 0 # CURRENT mp
|
|
|
|
|
|
# --- derived: functions, never stored -------------------------------------------
|
|
func modifier(stat: String) -> int:
|
|
return Attributes.modifier(int(attributes.get(stat, 10)))
|
|
|
|
|
|
func prof_bonus() -> int:
|
|
return PROF_BONUS_L1 # the level curve is M5's; creation is L1
|
|
|
|
|
|
func max_hp() -> int:
|
|
return Callings.hit_die(calling_id) + modifier("con")
|
|
|
|
|
|
func max_mp() -> int:
|
|
# TUNABLE — M5 owns the real MP curve (races-and-classes spec §6 defers it).
|
|
# The SHAPE is what M4-b needs to draw a bar: martials have no pool at all; a
|
|
# caster's scales off the casting stat, with a floor so a bad roll still casts.
|
|
if not Callings.is_caster(calling_id):
|
|
return 0
|
|
return maxi(4, 4 + 4 * modifier(Callings.casting_stat(calling_id)))
|
|
|
|
|
|
func ac() -> int:
|
|
return 10 + modifier("dex") # armor is an item -> M7
|
|
|
|
|
|
func initiative() -> int:
|
|
return modifier("dex")
|
|
|
|
|
|
func is_proficient(skill: String) -> bool:
|
|
return skill in skills
|
|
|
|
|
|
func save(stat: String) -> int:
|
|
var v := modifier(stat)
|
|
if stat in Callings.saves(calling_id):
|
|
v += prof_bonus()
|
|
v += Races.save_bonus(race_id) # human's +1 to all five
|
|
return v
|
|
|
|
|
|
func poison_save_bonus() -> int:
|
|
# Situational (vs poison + the affliction track) — deliberately NOT folded into
|
|
# save("con"), because a conditional inside a flat number is a sheet that lies.
|
|
return Races.poison_save_bonus(race_id)
|
|
|
|
|
|
func skill_bonus(skill: String) -> int:
|
|
var v := modifier(Skills.attribute(skill))
|
|
if is_proficient(skill):
|
|
v += prof_bonus()
|
|
return v
|
|
|
|
|
|
func spell_dc() -> int:
|
|
if not Callings.is_caster(calling_id):
|
|
return 0
|
|
return 8 + prof_bonus() + modifier(Callings.casting_stat(calling_id))
|
|
|
|
|
|
func has_nightsight() -> bool:
|
|
return Races.flag(race_id, "nightsight")
|
|
|
|
|
|
func has_claws() -> bool:
|
|
return Races.flag(race_id, "claws")
|
|
|
|
|
|
func has_keen_scent() -> bool:
|
|
return Races.flag(race_id, "keen_scent")
|