class_name CharacterCreation extends Control ## The creation screen (mock: Character Creation). Layout is authored in ## CharacterCreation.tscn — open it in the editor to arrange it (ADR 0001). This ## script does on-load work only: @onready refs, signal wiring, and binding the ## draft into AUTHORED nodes. It creates no nodes and it owns no rules. ## ## Every rule lives in CreationDraft; every display string derived from a rules ## table lives in CreationCopy; the prose lives in ContentDB. This file is glue. ## ## §2: the screen never hands an attribute value to anything. It emits a seed and ## four choices, and NewGame.construct re-rolls from that seed itself. ## §7: LCK appears nowhere here. Five ability cards. Never six. signal creation_confirmed(creation: Dictionary) const ContentDB = preload("res://scripts/content/content_db.gd") ## Shown under the CTA when the draft is legal. When it is NOT legal, the first ## validation error takes this slot instead — the player is told what is wrong, in ## the same place, in the same dry voice. §13: even the nag is content. const CTA_HINT := "no second chances out there" ## Injection seams — set BETWEEN instantiate() and add_child(), as the shell does ## for DmService. _ready builds the real ones when nothing was supplied. var world: ContentDB = null var origin: Dictionary = {} var draft: CreationDraft var _race_cards: Array = [] var _calling_cards: Array = [] var _pool_chips: Array = [] var _bonus_chips: Array = [] var _ability_cards: Array = [] @onready var _plate_name: Label = $Split/Bay/Col/Nameplate/Name @onready var _plate_sub: Label = $Split/Bay/Col/Nameplate/Sub @onready var _origin_text: RichTextLabel = $Split/Bay/Col/OriginCard/Box/Text @onready var _race_row: HBoxContainer = $Split/Sheet/Body/RaceSection/Cards @onready var _calling_row: HBoxContainer = $Split/Sheet/Body/CallingSection/Cards @onready var _detail_blurb: RichTextLabel = $Split/Sheet/Body/CallingSection/Detail/Box/Blurb @onready var _detail_mech: Label = $Split/Sheet/Body/CallingSection/Detail/Box/Mechanics @onready var _pool_row: HBoxContainer = $Split/Sheet/Body/SkillSection/PoolRow @onready var _pool_count: Label = $Split/Sheet/Body/SkillSection/PoolRow/Count @onready var _bonus_row: HBoxContainer = $Split/Sheet/Body/SkillSection/BonusRow @onready var _ability_row: HBoxContainer = $Split/Sheet/Body/AbilitySection/Cards @onready var _points: Label = $Split/Sheet/Body/AbilitySection/Head/Points @onready var _reroll: Button = $Split/Sheet/Body/AbilitySection/Head/Reroll @onready var _name_edit: LineEdit = $Split/Sheet/Body/Footer/NameBox/NameEdit @onready var _enter: Button = $Split/Sheet/Body/Footer/CtaBox/Enter @onready var _error: Label = $Split/Sheet/Body/Footer/CtaBox/Error func _ready() -> void: # The .tscn root has a fixed size and NO full-rect anchors — a root Control run # via F6 is not sized by full-rect anchors and collapses to (0,0) (the M3-a bug). _fit_to_viewport() get_viewport().size_changed.connect(_fit_to_viewport) if world == null: world = ContentDB.new() world.load_from(ContentDB.default_content_root()) if origin.is_empty(): origin = ContentDB.load_json(ContentDB.origin_path("deserter")) _collect_authored_nodes() _wire() draft = CreationDraft.fresh() draft.set_race(Races.IDS[0]) var allowed := _allowed_callings() if not allowed.is_empty(): draft.set_calling(allowed[0]) # A degenerate origin (zero allowed callings, or no build_constraints at all) # leaves calling_id unset rather than crashing — §13, never an error thrown # at the player. The CTA simply stays disabled; there is no calling to invent. _bind() func _fit_to_viewport() -> void: size = get_viewport_rect().size func _allowed_callings() -> Array: ## The ORIGIN gates the callings. The screen does not hardcode seven — a later ## origin may allow fewer, and construct would reject a calling it disallows. var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", []) var out: Array = [] for id in Callings.IDS: # Callings.IDS fixes the ORDER; the origin fixes the SET if id in allowed: out.append(id) return out func _collect_authored_nodes() -> void: # Fixed authored nodes, gathered — never created (ADR 0001). Spares hide. for i in range(4): _race_cards.append(_race_row.get_node("Race%d" % i)) for i in range(7): _calling_cards.append(_calling_row.get_node("Calling%d" % i)) for i in range(6): _pool_chips.append(_pool_row.get_node("Pool%d" % i)) for i in range(9): _bonus_chips.append(_bonus_row.get_node("Bonus%d" % i)) for i in range(5): _ability_cards.append(_ability_row.get_node("Ab%d" % i)) func _wire() -> void: for i in range(_race_cards.size()): _race_cards[i].pressed.connect(_on_race_pressed.bind(i)) for i in range(_calling_cards.size()): _calling_cards[i].pressed.connect(_on_calling_pressed.bind(i)) for i in range(_pool_chips.size()): _pool_chips[i].pressed.connect(_on_pool_pressed.bind(i)) for i in range(_bonus_chips.size()): _bonus_chips[i].pressed.connect(_on_bonus_pressed.bind(i)) for i in range(_ability_cards.size()): var card: PanelContainer = _ability_cards[i] card.get_node("Box/Buttons/Minus").pressed.connect(_on_minus_pressed.bind(i)) card.get_node("Box/Buttons/Plus").pressed.connect(_on_plus_pressed.bind(i)) _reroll.pressed.connect(_on_reroll_pressed) _name_edit.text_changed.connect(_on_name_changed) _enter.pressed.connect(_on_enter_pressed) # ---------------------------------------------------------------- input -> draft func _on_race_pressed(i: int) -> void: draft.set_race(Races.IDS[i]) _bind() func _on_calling_pressed(i: int) -> void: var allowed := _allowed_callings() if i >= allowed.size(): return draft.set_calling(allowed[i]) _bind() func _on_pool_pressed(i: int) -> void: var pool := draft.skill_pool() if i < pool.size(): draft.toggle_skill(pool[i]) _bind() func _on_bonus_pressed(i: int) -> void: draft.set_bonus_skill(Skills.IDS[i]) _bind() func _on_plus_pressed(i: int) -> void: draft.increment(Attributes.IDS[i]) _bind() func _on_minus_pressed(i: int) -> void: draft.decrement(Attributes.IDS[i]) _bind() func _on_reroll_pressed() -> void: draft.reroll() _bind() func _on_name_changed(text: String) -> void: draft.name = text _bind() func _on_enter_pressed() -> void: if not draft.is_complete(origin, world): return creation_confirmed.emit(draft.to_creation()) # ---------------------------------------------------------------- draft -> nodes func _bind() -> void: _bind_races() _bind_callings() _bind_skills() _bind_abilities() _bind_bay() _bind_cta() func _bind_races() -> void: for i in range(_race_cards.size()): var id: String = Races.IDS[i] var card: Button = _race_cards[i] card.get_node("Box/Name").text = str(world.race(id).get("name", id.capitalize())) card.get_node("Box/Blurb").text = str(world.race(id).get("blurb", "")) card.get_node("Box/Trait").text = CreationCopy.race_trait(id) card.button_pressed = (id == draft.race_id) card.get_node("Tag").visible = card.button_pressed func _bind_callings() -> void: var allowed := _allowed_callings() for i in range(_calling_cards.size()): var card: Button = _calling_cards[i] if i >= allowed.size(): card.visible = false # a spare authored slot: hide it (ADR 0001) continue var id: String = allowed[i] card.visible = true card.get_node("Box/Name").text = str(world.calling(id).get("name", id.capitalize())) card.get_node("Box/Chip").text = Callings.primary(id).to_upper() card.button_pressed = (id == draft.calling_id) card.get_node("Tag").visible = card.button_pressed _detail_blurb.text = "[i]%s[/i]" % str(world.calling(draft.calling_id).get("blurb", "")) _detail_mech.text = CreationCopy.calling_detail(draft.calling_id) func _bind_skills() -> void: var pool := draft.skill_pool() _pool_count.text = "%d of %d chosen" % [draft.skills.size(), draft.picks_needed()] for i in range(_pool_chips.size()): var chip: Button = _pool_chips[i] if i >= pool.size(): chip.visible = false continue var skill: String = pool[i] chip.visible = true chip.text = CreationCopy.skill_label(skill) chip.button_pressed = draft.is_picked(skill) # Inert when the race already granted it, or when the picks are spent. chip.disabled = draft.is_granted(skill) or (not draft.is_picked(skill) and not draft.can_pick(skill)) _bonus_row.visible = draft.wants_bonus_skill() for i in range(_bonus_chips.size()): var skill: String = Skills.IDS[i] var chip: Button = _bonus_chips[i] chip.text = CreationCopy.skill_label(skill) chip.button_pressed = (draft.bonus_skill == skill) chip.disabled = not draft.can_take_bonus(skill) and draft.bonus_skill != skill func _bind_abilities() -> void: var rolled := draft.rolled() var final := draft.final() var primary := Callings.primary(draft.calling_id) _points.text = "points left: %d" % draft.points_left() for i in range(_ability_cards.size()): var stat: String = Attributes.IDS[i] var card: PanelContainer = _ability_cards[i] var spent := int(draft.spend.get(stat, 0)) card.get_node("Box/Key").text = stat.to_upper() card.get_node("Box/Value").text = str(int(final[stat])) card.get_node("Box/Rolled").text = "rolled %d%s" % [ int(rolled[stat]), (" +%d" % spent) if spent > 0 else ""] card.get_node("Box/Buttons/Plus").disabled = not draft.can_increment(stat) card.get_node("Box/Buttons/Minus").disabled = not draft.can_decrement(stat) card.get_node("Tag").visible = (stat == primary) card.theme_type_variation = ThemeKeys.PRIMARY_CARD if stat == primary else ThemeKeys.PARCHMENT_CARD func _bind_bay() -> void: var shown_name := draft.name.strip_edges() _plate_name.text = shown_name if shown_name != "" else "Nameless" _plate_sub.text = "%s · %s" % [ str(world.race(draft.race_id).get("name", "")), str(world.calling(draft.calling_id).get("name", "")), ] _origin_text.text = "[i]%s[/i]" % _origin_prose() func _origin_prose() -> String: ## The DM sizes you up — authored, not an AI call. The DM's real entrance is the ## shell's opening narration, one screen later, at the first moment where speaking ## means something (§13: authored text is CONTENT, not error handling). var race: Dictionary = world.race(draft.race_id) var calling: Dictionary = world.calling(draft.calling_id) # A missing fragment degrades to the blurb rather than crashing. The content # parity test stops that from ever shipping; this stops it from ever exploding. var race_frag := str(race.get("fragment", race.get("blurb", ""))) var calling_frag := str(calling.get("fragment", calling.get("blurb", ""))) return "%s Now you carry a %s's work — %s" % [ race_frag, str(calling.get("name", "")).to_lower(), calling_frag] func _bind_cta() -> void: ## The errors array is the VALIDATOR's, written for code. What lands on the ## label is CreationCopy's, written for the player — "choose two more ## proficiencies", not "hedge_mage picks 2 skills, got 0" (§13). The mapping is ## in the presentation layer on purpose: NewGame.validate's strings are a ## contract, and this screen is not the only thing that reads them. var errors := draft.errors(origin, world) _enter.disabled = not errors.is_empty() _error.text = CreationCopy.error_line(str(errors[0]), draft) if not errors.is_empty() else CTA_HINT