feat(creation): the character creation screen
Variant C of the mock: seven nameplate calling cards in one row, with the chosen calling's blurb AND mechanics in a detail panel below — the one card you committed to is the only one that talks. Editor-first (ADR 0001): the tree is in the .tscn, the script binds. No rules here — CreationDraft holds them, CreationCopy derives the display strings, the prose comes from ContentDB. Emits creation_confirmed(Dictionary); M4-c constructs. Five ability cards. Never six (§7).
This commit is contained in:
1207
client/scenes/creation/CharacterCreation.tscn
Normal file
1207
client/scenes/creation/CharacterCreation.tscn
Normal file
File diff suppressed because it is too large
Load Diff
305
client/scripts/ui/creation/character_creation.gd
Normal file
305
client/scripts/ui/creation/character_creation.gd
Normal file
@@ -0,0 +1,305 @@
|
||||
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])
|
||||
draft.set_calling(_allowed_callings()[0])
|
||||
_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:
|
||||
var errors := draft.errors(origin, world)
|
||||
_enter.disabled = not errors.is_empty()
|
||||
_error.text = str(errors[0]) if not errors.is_empty() else CTA_HINT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- test seam
|
||||
|
||||
func _complete_a_valid_draft_for_test() -> void:
|
||||
## Drives the draft to a legal state the way a player would. Test-only, and it
|
||||
## touches nothing the player's own clicks do not.
|
||||
draft.name = "Aldric"
|
||||
draft.set_race("human")
|
||||
draft.set_calling("sellsword")
|
||||
for skill in draft.skill_pool():
|
||||
if draft.can_pick(skill):
|
||||
draft.toggle_skill(skill)
|
||||
for skill in Skills.IDS:
|
||||
if draft.can_take_bonus(skill):
|
||||
draft.set_bonus_skill(skill)
|
||||
break
|
||||
_bind()
|
||||
1
client/scripts/ui/creation/character_creation.gd.uid
Normal file
1
client/scripts/ui/creation/character_creation.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://3cxsq82gjlfk
|
||||
105
client/tests/unit/test_character_creation_screen.gd
Normal file
105
client/tests/unit/test_character_creation_screen.gd
Normal file
@@ -0,0 +1,105 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const SCENE := "res://scenes/creation/CharacterCreation.tscn"
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
|
||||
|
||||
func _world() -> ContentDB:
|
||||
var w := ContentDB.new()
|
||||
w.load_from(ContentDB.default_content_root())
|
||||
return w
|
||||
|
||||
|
||||
func _screen() -> CharacterCreation:
|
||||
# Inject origin + world BEFORE _ready (add_child) — instantiate() does not run
|
||||
# _ready(), add_child does. Same seam the shell uses for DmService.
|
||||
var node = load(SCENE).instantiate()
|
||||
node.world = _world()
|
||||
node.origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
add_child_autofree(node)
|
||||
return node
|
||||
|
||||
|
||||
func test_screen_applies_theme_and_fits_viewport():
|
||||
var packed = load(SCENE)
|
||||
assert_true(packed is PackedScene)
|
||||
var s := _screen()
|
||||
assert_true(s is Control)
|
||||
assert_true(s.theme is Theme, "the screen pulls the shared theme")
|
||||
assert_gt(s.size.x, 0.0, "viewport-fit sized the root (the M3-a collapse guard)")
|
||||
|
||||
|
||||
func test_a_draft_exists_with_a_seed_on_load():
|
||||
var s := _screen()
|
||||
assert_not_null(s.draft)
|
||||
assert_ne(s.draft.character_seed, 0)
|
||||
|
||||
|
||||
func test_five_ability_cards_never_six():
|
||||
# §7 — LCK has no card, no row, no tooltip. The player must never be able to
|
||||
# CALCULATE that he is cursed.
|
||||
var s := _screen()
|
||||
assert_eq(s._ability_cards.size(), 5)
|
||||
for card in s._ability_cards:
|
||||
assert_false(card.name.to_lower().contains("lck"))
|
||||
|
||||
|
||||
func test_no_node_on_the_screen_says_luck():
|
||||
var s := _screen()
|
||||
for node in s.find_children("*", "Label", true, false):
|
||||
assert_false(node.text.to_lower().contains("luck"), "%s leaks Luck (§7)" % node.name)
|
||||
assert_false(node.text.to_lower().contains("lck"), "%s leaks Luck (§7)" % node.name)
|
||||
|
||||
|
||||
func test_only_the_origin_s_allowed_callings_are_shown():
|
||||
var s := _screen()
|
||||
var shown := 0
|
||||
for card in s._calling_cards:
|
||||
if card.visible:
|
||||
shown += 1
|
||||
assert_eq(shown, s.origin["build_constraints"]["allowed_callings"].size())
|
||||
|
||||
|
||||
func test_cta_is_disabled_until_the_draft_is_legal():
|
||||
var s := _screen()
|
||||
assert_true(s._enter.disabled, "an empty draft cannot enter the world")
|
||||
assert_ne(s._error.text, CharacterCreation.CTA_HINT, "the screen says WHY, dryly")
|
||||
assert_ne(s._error.text.strip_edges(), "")
|
||||
|
||||
s._complete_a_valid_draft_for_test()
|
||||
assert_false(s._enter.disabled)
|
||||
assert_eq(s._error.text, CharacterCreation.CTA_HINT,
|
||||
"a legal draft gets the mock's line back, not a blank")
|
||||
|
||||
|
||||
func test_pressing_enter_emits_exactly_the_draft_s_creation_dict():
|
||||
var s := _screen()
|
||||
s._complete_a_valid_draft_for_test()
|
||||
watch_signals(s)
|
||||
s._on_enter_pressed()
|
||||
assert_signal_emitted_with_parameters(s, "creation_confirmed", [s.draft.to_creation()])
|
||||
|
||||
|
||||
func test_the_screen_never_constructs_the_character():
|
||||
# The flow is M4-c's job, and a saga later synthesizes the same Dictionary and
|
||||
# skips this scene entirely. The screen PRODUCES the dict; it does not spend it.
|
||||
var s := _screen()
|
||||
assert_false(s.has_method("construct"))
|
||||
|
||||
|
||||
func test_the_displayed_scores_are_the_draft_s_scores():
|
||||
var s := _screen()
|
||||
s._complete_a_valid_draft_for_test()
|
||||
for i in range(Attributes.IDS.size()):
|
||||
var stat: String = Attributes.IDS[i]
|
||||
var value_label: Label = s._ability_cards[i].get_node("Box/Value")
|
||||
assert_eq(value_label.text, str(int(s.draft.final()[stat])),
|
||||
"%s shows what the draft holds" % stat)
|
||||
|
||||
|
||||
func test_reroll_changes_what_is_on_screen():
|
||||
var s := _screen()
|
||||
var before := s.draft.character_seed
|
||||
s._on_reroll_pressed()
|
||||
assert_ne(s.draft.character_seed, before)
|
||||
assert_eq(s._points.text, "points left: 3", "a re-roll returns the pool")
|
||||
1
client/tests/unit/test_character_creation_screen.gd.uid
Normal file
1
client/tests/unit/test_character_creation_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dmliumgn42tv8
|
||||
Reference in New Issue
Block a user