Files
transcience/src/ui/character_select.gd
claude 61680d00d7
ci / verify (push) Successful in 48s
Style the rest of the interface: panels, sliders, scrollbars, cards
Buttons were the easy half. This is everything else.

Dialogs sit on panels now rather than being text over a dimmed world -- the
pause menu, character select, upgrades, settings and credits all root through
UiTheme.dialog_panel(). The panel stylebox is the pack's frame MODULATED DARK.
The set is cream throughout, which is right for buttons and wrong for a dialog
over a dark dungeon; tinting keeps the pixel border and the corner shape and
lets every label the game already draws in light colours stay readable, instead
of recolouring every label in five screens to suit the art.

Sliders and scrollbars are the pack's too, section headings sit on its banner
ribbon, and an upgrade card's rarity is now its frame rather than a word on it
-- three choices are compared at a glance and a colour reads faster than a
label.

Two bugs of the same shape, and neither was findable without looking at the
screen. A Slider and a ScrollBar take their THICKNESS from the stylebox's
minimum size, which for a StyleBoxTexture is its content margins. Mine were
zero, so both resolved the correct stylebox, reported the correct texture, and
drew a groove zero pixels tall. A probe confirmed the theme was resolving
perfectly while the track was invisible. Tests now assert every slider and
scrollbar stylebox has a non-zero minimum, and it is gotcha 7 in CLAUDE.md.

The first attempt at the slider groove also used the pack's HOLLOW bar sprite,
whose middle is transparent -- tinting it dark left an outline and nothing
else. It uses the solid one.

tools/screenshot.tscn gained the upgrade screen, which it has to stage: the
game scene owns that screen's visibility and re-asserts it every frame, so
setting `visible` lasted exactly one frame, and standing the player at the NPC
client-side lasted until reconciliation pulled them back. It now moves the
player on both sides and holds it until the shot. That tool has caught four
bugs the automated gates all passed.

check.sh clean, 468 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:45:46 +02:00

162 lines
5.0 KiB
GDScript

extends CanvasLayer
## Character roster: pick one, or make one.
##
## Shown when the player has no character in the world -- at first login, and
## again when their last one dies. Permadeath makes this a screen players will
## see repeatedly, so dead characters stay listed rather than vanishing: seeing
## the run you lost is the point of keeping the record.
signal select_requested(character_id: String)
signal create_requested(character_name: String)
signal closed
var _list: VBoxContainer
var _name_field: LineEdit
var _create_button: Button
var _status: Label
var _title: Label
var _close_button: Button
var _known: Array[Dictionary] = []
var _rng := RandomNumberGenerator.new()
var _selected: String = ""
func _ready() -> void:
layer = 30
_rng.randomize()
var root := UiTheme.themed_root()
add_child(root)
var scrim := ColorRect.new()
scrim.color = Color(0.03, 0.03, 0.06, 0.92)
scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(scrim)
var centre := CenterContainer.new()
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(centre)
# A panel, so a dialog reads as a thing laid over the world rather
# than as text floating on a dimmed one.
var frame := UiTheme.dialog_panel()
centre.add_child(frame)
var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(460.0, 0.0)
panel.add_theme_constant_override("separation", 8)
frame.add_child(panel)
_title = Label.new()
_title.text = "CHARACTERS"
_title.add_theme_font_size_override("font_size", 26)
panel.add_child(_title)
_list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 4)
panel.add_child(_list)
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0.0, 10.0)
panel.add_child(spacer)
var row := HBoxContainer.new()
panel.add_child(row)
_name_field = LineEdit.new()
_name_field.placeholder_text = "new character name"
_name_field.text = Character.random_name(_rng)
_name_field.max_length = Character.MAX_NAME
_name_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(_name_field)
_create_button = Button.new()
_create_button.text = "Create"
_create_button.pressed.connect(_on_create)
row.add_child(_create_button)
_close_button = Button.new()
_close_button.text = "Back to the hub"
_close_button.pressed.connect(func() -> void: closed.emit())
panel.add_child(_close_button)
_status = Label.new()
_status.add_theme_font_size_override("font_size", 12)
_status.add_theme_color_override("font_color", Color(1.0, 0.6, 0.5))
panel.add_child(_status)
visible = false
func _on_create() -> void:
var wanted := _name_field.text.strip_edges()
if wanted.is_empty():
set_status("give the character a name")
return
create_requested.emit(wanted)
# Offer the next suggestion straight away rather than clearing to blank.
_name_field.text = Character.random_name(_rng)
func set_status(text: String) -> void:
_status.text = text
## Rebuild from the server's roster. Called on every change rather than diffed:
## the list is at most a handful of rows and correctness matters more than
## avoiding a few Control allocations.
func refresh(characters: Array[Dictionary], selected: String) -> void:
_known = characters
_selected = selected
for child in _list.get_children():
child.queue_free()
# Everything the server sends is alive: a dead character is gone as far as
# the player is concerned, and retirement is the server's own bookkeeping.
var living := characters.size()
for c in characters:
_list.add_child(_row_for(c))
if characters.is_empty():
var empty := Label.new()
empty.text = "No characters yet. Name one below to begin."
empty.add_theme_color_override("font_color", Color(0.7, 0.75, 0.85))
_list.add_child(empty)
var room := living < CharacterStore.MAX_ACTIVE
_create_button.disabled = not room
_name_field.editable = room
_title.text = "CHARACTERS (%d / %d living)" % [living, CharacterStore.MAX_ACTIVE]
if not room:
set_status("%d living characters is the limit -- one must fall first"
% CharacterStore.MAX_ACTIVE)
func _row_for(c: Dictionary) -> Control:
var row := HBoxContainer.new()
# The character's colour, which is its only identity until cosmetics exist.
var swatch := ColorRect.new()
swatch.color = c["colour"]
swatch.custom_minimum_size = Vector2(14.0, 14.0)
row.add_child(swatch)
var playing := String(c["id"]) == _selected
var label := Label.new()
label.text = " %s level %d %d hp%s" % [
c["name"], int(c["level"]), int(c["max_hp"]),
" <- playing" if playing else ""]
label.add_theme_color_override("font_color", Color(0.85, 0.9, 1.0))
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(label)
var play := Button.new()
play.text = "Play"
play.disabled = playing
play.pressed.connect(func() -> void: select_requested.emit(String(c["id"])))
row.add_child(play)
return row
## Whether the player may dismiss this screen. False when they have nothing to
## play, in which case there is nowhere to dismiss it to.
func set_dismissible(can_close: bool) -> void:
_close_button.visible = can_close