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>
300 lines
11 KiB
GDScript
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
|