The human's first F6 run found four defects headless GUT could not see, all now fixed and break-proven. 1. Fifteen Labels shipped invisible. _fonts() sets a default font and size but never a default font_color for the base Label type, so a Label with no type-variation inherited Godot's built-in default — WHITE — and rendered white-on-parchment: the four race names, four race blurbs, and seven calling names, every unreadable string on the sheet. New CardTitle / CardBody roles (mock #3a2f1c / #6a5a3a) fix it. NOT a global default Label colour: the Title screen has 12 bare Labels on a dark background that rely on white. 2. The PRIMARY badge covered the rolled value. The ability card is a PanelContainer, and a Container force-fits its children, so the badge's authored anchors were dead letters — it stretched to the card's full width and centred over the value. Nesting it under a plain Control (not a Container) restores absolute positioning. The badges also carried _flat()'s 12/6 card padding; a new tight pill stylebox matches the mock's 2px 9px. 3. Race cards overflowed their own border (108px card, 114px of content). Bumped to 140. The CHOSEN badge on the calling card moved from centre to the mock's top-right, and the ability card got its own AbilityCard variation with 16px top headroom for the badge (ParchmentCard is shared with the shell). 4. The default window (1600x900) was larger than a 1600x900 laptop's usable area, so the WM clamped it to 1589x752 and the run came up pillarboxed. Default is now 1280x720, resizable; the 1920x1080 design canvas is unchanged (canvas_items + keep scales it, so no layout number moved). Also closes M4-b's two open copy items: the cutpurse origin fragment (it contradicted its own blurb) and the Hint label (it omitted the roll's floor of 8, now pinned to NewGame.roll_attributes). Guards for 1-4 are new and each was re-broken and watched go red. The overflow guard in 3 was FIRST WRITTEN AS A TAUTOLOGY — Godot clamps Control.size up to its combined minimum, so `min <= size` is `x <= x` — and passed against the bug, 29/29, until it was rewritten to measure against the card that clips. That is traps.md #17 and the thirteenth cannot-fail assertion this branch has caught. 319 client tests green, content build green, theme drift guard satisfied. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
302 lines
12 KiB
GDScript
302 lines
12 KiB
GDScript
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)
|
|
# Overlay/Tag, not Tag: the ability card is a PanelContainer, and a Container
|
|
# force-fits every child it has — which stretched the badge across the card and
|
|
# parked it on top of the rolled value. The Control overlay is not a Container,
|
|
# so the badge's anchors survive and it rides the card's top edge (ADR 0001).
|
|
card.get_node("Overlay/Tag").visible = (stat == primary)
|
|
card.theme_type_variation = ThemeKeys.PRIMARY_CARD if stat == primary else ThemeKeys.ABILITY_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
|