Files
code_of_conquest_dnd/client/scripts/ui/creation/character_creation.gd
Phillip Tarrant 8f3aa75fa9 chore(warnings): silence the four pre-existing script warnings the creation screen surfaced
Not errors and not regressions — Godot printed them on every script reload
and they predate M4-b. Cleared so a real warning is not lost in the noise:

- Redundant `const ContentDB`/`const NewGame` preloads that shadowed their
  own `class_name` globals (character_creation.gd, creation_draft.gd) — the
  class is already global, so the const bought nothing.
- Two `log` locals shadowing the built-in `log()` — renamed to `out`
  (canon_log.from_dict) and `canon` (NewGame.construct); the "log" dict KEY
  is the public contract and is unchanged.
- currency.format's copper->denomination division is integer BY DESIGN (the
  remainder is carried, not lost) — annotated `@warning_ignore` to say so.

319 client tests green; headless parse reports none of the four sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:09:29 -05:00

300 lines
11 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)
## 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