From 61680d00d78e6b1a22175cc6c4f2bb4db318c13b Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 7 Sep 2026 11:45:46 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 8 +- assets/sprites/ui/Bar05a.png | Bin 0 -> 146 bytes assets/sprites/ui/Bar05a.png.import | 40 +++++++++ assets/sprites/ui/Bar07a.png | Bin 0 -> 138 bytes assets/sprites/ui/Bar07a.png.import | 40 +++++++++ assets/sprites/ui/Frame03a.png | Bin 0 -> 306 bytes assets/sprites/ui/Frame03a.png.import | 40 +++++++++ assets/sprites/ui/FrameMarker01a.png | Bin 0 -> 255 bytes assets/sprites/ui/FrameMarker01a.png.import | 40 +++++++++ assets/sprites/ui/Handle02a.png | Bin 0 -> 124 bytes assets/sprites/ui/Handle02a.png.import | 40 +++++++++ assets/sprites/ui/Handle03a.png | Bin 0 -> 124 bytes assets/sprites/ui/Handle03a.png.import | 40 +++++++++ docs/DECISIONS.md | 10 +++ docs/ROADMAP.md | 13 ++- docs/WORKFLOW.md | 11 ++- src/ui/character_select.gd | 7 +- src/ui/credits_screen.gd | 7 +- src/ui/game_menu.gd | 7 +- src/ui/settings_screen.gd | 38 ++++++-- src/ui/upgrade_screen.gd | 23 +++-- src/view/ui_theme.gd | 94 +++++++++++++++++++- tests/unit/test_ui_theme.gd | 74 +++++++++++++++ tools/screenshot.gd | 38 +++++++- 24 files changed, 548 insertions(+), 22 deletions(-) create mode 100644 assets/sprites/ui/Bar05a.png create mode 100644 assets/sprites/ui/Bar05a.png.import create mode 100644 assets/sprites/ui/Bar07a.png create mode 100644 assets/sprites/ui/Bar07a.png.import create mode 100644 assets/sprites/ui/Frame03a.png create mode 100644 assets/sprites/ui/Frame03a.png.import create mode 100644 assets/sprites/ui/FrameMarker01a.png create mode 100644 assets/sprites/ui/FrameMarker01a.png.import create mode 100644 assets/sprites/ui/Handle02a.png create mode 100644 assets/sprites/ui/Handle02a.png.import create mode 100644 assets/sprites/ui/Handle03a.png create mode 100644 assets/sprites/ui/Handle03a.png.import diff --git a/CLAUDE.md b/CLAUDE.md index a417da0..1a2bb47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,7 +148,13 @@ ticks in milliseconds with no SceneTree. (`UiTheme.themed_root()`). `tests/unit/test_ui_theme.gd` instantiates every screen and asks what its buttons actually resolve, because this failure is invisible to every other kind of check. -7. **`ProjectSettings.save()` drops settings equal to the engine default** and +7. **A themed `Slider` or `ScrollBar` takes its THICKNESS from the stylebox's + minimum size**, which for a `StyleBoxTexture` is its content margins. Style + one with margins of zero and it resolves correctly, reports the right + texture, and draws a groove zero pixels tall — indistinguishable from having + no theme at all. Both happened. `tests/unit/test_ui_theme.gd` asserts every + slider and scrollbar stylebox has a non-zero minimum. +8. **`ProjectSettings.save()` drops settings equal to the engine default** and strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code in `src/main.gd` instead of trusted to `project.godot`. diff --git a/assets/sprites/ui/Bar05a.png b/assets/sprites/ui/Bar05a.png new file mode 100644 index 0000000000000000000000000000000000000000..753ceec0facd17f155931f8b0caf6d2bc9efb40f GIT binary patch literal 146 zcmeAS@N?(olHy`uVBq!ia0y~yU{GLSVBq3lV_;yo>Kn6!fq{XsILO_JVcj{ImkbOH zKAtX)Ar`%7r)=b9P~>4g&o7((zd*;O^OGzW_mTS8Rhx4(nV6Ji6~1jcah0wA=$scn z1Eg0?I2M;)R?Ym4;X2FCSz2|M4%Fl_w)_;Vof5|v+P=PE7Xt$WgQu&X%Q~loCIG9} BFsuLo literal 0 HcmV?d00001 diff --git a/assets/sprites/ui/Bar05a.png.import b/assets/sprites/ui/Bar05a.png.import new file mode 100644 index 0000000..b57a12e --- /dev/null +++ b/assets/sprites/ui/Bar05a.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://p6h7i0qxhoao" +path="res://.godot/imported/Bar05a.png-6dc27709fd6ce83b5c13f1aeece67a02.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/sprites/ui/Bar05a.png" +dest_files=["res://.godot/imported/Bar05a.png-6dc27709fd6ce83b5c13f1aeece67a02.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/sprites/ui/Bar07a.png b/assets/sprites/ui/Bar07a.png new file mode 100644 index 0000000000000000000000000000000000000000..4e5502fc6a4f92de0487ea89fd138b1f70206d67 GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0y~yU{GLSVBq0kV_;xl7TLd(fq{XsILO_JVcj{ImkbOH zE}kxqAr`%FuPhX0P~c&{sJ;I1!x^IOX-qQK4=ySsw9mN5^izvXrp3}BQ_A`5H-^pn soD2#lr!fmOF#M58=vQQxc%;2gc?OrUUq-PK0|Nttr>mdKI;Vst0E#~+#Q*>R literal 0 HcmV?d00001 diff --git a/assets/sprites/ui/Bar07a.png.import b/assets/sprites/ui/Bar07a.png.import new file mode 100644 index 0000000..85ca51a --- /dev/null +++ b/assets/sprites/ui/Bar07a.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c32skror5ojq5" +path="res://.godot/imported/Bar07a.png-4832809288d6d60de539798b274f9dea.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/sprites/ui/Bar07a.png" +dest_files=["res://.godot/imported/Bar07a.png-4832809288d6d60de539798b274f9dea.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/sprites/ui/Frame03a.png b/assets/sprites/ui/Frame03a.png new file mode 100644 index 0000000000000000000000000000000000000000..c754988a323b2fdd40d9dc91731868491a9f6ae4 GIT binary patch literal 306 zcmeAS@N?(olHy`uVBq!ia0y~yU`SwKU~u4IV_;x-YEs0&h@=MuV1gUpY=|V*Fo~S RC<6lngQu&X%Q~loCIF)aYXblP literal 0 HcmV?d00001 diff --git a/assets/sprites/ui/Frame03a.png.import b/assets/sprites/ui/Frame03a.png.import new file mode 100644 index 0000000..9339b47 --- /dev/null +++ b/assets/sprites/ui/Frame03a.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cqehg1mvy1tcn" +path="res://.godot/imported/Frame03a.png-5c1b611e7b9fe641c92bf2e2fd3b816b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/sprites/ui/Frame03a.png" +dest_files=["res://.godot/imported/Frame03a.png-5c1b611e7b9fe641c92bf2e2fd3b816b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/sprites/ui/FrameMarker01a.png b/assets/sprites/ui/FrameMarker01a.png new file mode 100644 index 0000000000000000000000000000000000000000..bd5f63d34c45611b7b9d90eedeb308eb7c7114fe GIT binary patch literal 255 zcmeAS@N?(olHy`uVBq!ia0y~yU~phyU{K&-V_;xdG`FsWfq{XsILO_JVcj{ImkbOH zJ3U<-LoyoQPT0tM$Uwlge$AT+N}(cwu8R~{WqG43nDrkhMl|qBv^Py_>T1&TdCHOT zqC%+Le^ZIo&Wi6Qew&j{O$yQ3J7d$a%&^^e-O zR=oK0(^HFnYR>+eHPcykjg0=@`%-q|VJpnr_5c5q<9Fb`P{9z@aE$o^lZjk|*n*D? wTNrxT7qDj7Fz7lwX3Sue!cbx;|A6!JmPi@@`}ZF+FfcH9y85}Sb4q9e001svX#fBK literal 0 HcmV?d00001 diff --git a/assets/sprites/ui/FrameMarker01a.png.import b/assets/sprites/ui/FrameMarker01a.png.import new file mode 100644 index 0000000..5b918ad --- /dev/null +++ b/assets/sprites/ui/FrameMarker01a.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqe250bmbxli0" +path="res://.godot/imported/FrameMarker01a.png-7ed0bebde8d986061a9a76a04179edf2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/sprites/ui/FrameMarker01a.png" +dest_files=["res://.godot/imported/FrameMarker01a.png-7ed0bebde8d986061a9a76a04179edf2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/sprites/ui/Handle02a.png b/assets/sprites/ui/Handle02a.png new file mode 100644 index 0000000000000000000000000000000000000000..e51475222d21c466cfe24620cc7d3216de60fb2a GIT binary patch literal 124 zcmeAS@N?(olHy`uVBq!ia0y~yVBi2@4mJh`h9ms@x)~T47>k44ofy`glX=O&z+mR- z;us<^H8~|A;m7#~_6-IG1_por{}x@XqHJu=ENpj|hleL5;J~2+2M!!4Skzi0EFsBJ bA~#un@Jd#)t|0|SGntDnm{r-UW|o|q&t literal 0 HcmV?d00001 diff --git a/assets/sprites/ui/Handle02a.png.import b/assets/sprites/ui/Handle02a.png.import new file mode 100644 index 0000000..6326adc --- /dev/null +++ b/assets/sprites/ui/Handle02a.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://e2u2b24f7dr4" +path="res://.godot/imported/Handle02a.png-f2f44a66c106ed5473f5b69eb2a37bd8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/sprites/ui/Handle02a.png" +dest_files=["res://.godot/imported/Handle02a.png-f2f44a66c106ed5473f5b69eb2a37bd8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/sprites/ui/Handle03a.png b/assets/sprites/ui/Handle03a.png new file mode 100644 index 0000000000000000000000000000000000000000..f286cc82ed010671b983b1c381cff588a6a8dd88 GIT binary patch literal 124 zcmeAS@N?(olHy`uVBq!ia0y~yVBlb2VBq3lV_;zDJTBkBz`(#*9OUlAuL void: 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) - centre.add_child(panel) + frame.add_child(panel) _title = Label.new() _title.text = "CHARACTERS" diff --git a/src/ui/credits_screen.gd b/src/ui/credits_screen.gd index 988e15d..2731466 100644 --- a/src/ui/credits_screen.gd +++ b/src/ui/credits_screen.gd @@ -28,10 +28,15 @@ func _ready() -> void: 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(620.0, 0.0) panel.add_theme_constant_override("separation", 8) - centre.add_child(panel) + frame.add_child(panel) var title := Label.new() title.text = "CREDITS" diff --git a/src/ui/game_menu.gd b/src/ui/game_menu.gd index 5a25ba5..2035eeb 100644 --- a/src/ui/game_menu.gd +++ b/src/ui/game_menu.gd @@ -37,10 +37,15 @@ func _ready() -> void: center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) root.add_child(center) + # 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() + center.add_child(frame) + _panel = VBoxContainer.new() _panel.custom_minimum_size = Vector2(320.0, 0.0) _panel.add_theme_constant_override("separation", 10) - center.add_child(_panel) + frame.add_child(_panel) var title := Label.new() title.text = "PAUSED" diff --git a/src/ui/settings_screen.gd b/src/ui/settings_screen.gd index 31abb08..64dcc1f 100644 --- a/src/ui/settings_screen.gd +++ b/src/ui/settings_screen.gd @@ -34,10 +34,15 @@ func _ready() -> void: 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(520.0, 0.0) panel.add_theme_constant_override("separation", 8) - centre.add_child(panel) + frame.add_child(panel) var title := Label.new() title.text = "SETTINGS" @@ -93,12 +98,35 @@ func _ready() -> void: buttons.add_child(close) -func _heading(text: String) -> Label: +## A section heading on the pack's banner ribbon, so the two halves of this +## screen are visibly two sections rather than one long list. +## A section heading on the pack's banner ribbon. +## +## Deliberately narrow and centred rather than stretched across the dialog: at +## full width the ribbon stops reading as a label and becomes a bright divider +## competing with everything under it. +func _heading(text: String) -> Control: + var row := CenterContainer.new() + var holder := MarginContainer.new() + holder.custom_minimum_size = Vector2(210.0, 34.0) + row.add_child(holder) + var banner := NinePatchRect.new() + banner.texture = UiTheme.texture("banner") + # The ribbon's tails hang off both ends; stretching through them would + # smear the shape, so they are the patch margins and only the body grows. + banner.patch_margin_left = 14 + banner.patch_margin_right = 14 + banner.patch_margin_top = 8 + banner.patch_margin_bottom = 8 + holder.add_child(banner) var l := Label.new() l.text = text - l.add_theme_font_size_override("font_size", 14) - l.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78)) - return l + l.add_theme_font_size_override("font_size", 13) + l.add_theme_color_override("font_color", UiTheme.INK) + l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + holder.add_child(l) + return row func _slider_row(parent: Control, label: String) -> Array: diff --git a/src/ui/upgrade_screen.gd b/src/ui/upgrade_screen.gd index c2a1947..ac85405 100644 --- a/src/ui/upgrade_screen.gd +++ b/src/ui/upgrade_screen.gd @@ -43,10 +43,15 @@ func _ready() -> void: 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(760.0, 0.0) panel.add_theme_constant_override("separation", 10) - centre.add_child(panel) + frame.add_child(panel) _title = Label.new() _title.add_theme_font_size_override("font_size", 26) @@ -116,12 +121,16 @@ func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) - func _make_card(index: int, id: StringName) -> Control: var def := Upgrades.get_def(id) - var card := VBoxContainer.new() - card.custom_minimum_size = Vector2(240.0, 0.0) - card.add_theme_constant_override("separation", 6) if def == null: - return card + return Control.new() var tint: Color = RARITY_COLOURS.get(def.rarity, Color.WHITE) + # The rarity is the card's frame, not just a word on it. Three cards side by + # side are compared at a glance, and a colour reads faster than a label. + var frame := UiTheme.card_panel(tint.lerp(Color(0.10, 0.10, 0.14), 0.72)) + frame.custom_minimum_size = Vector2(244.0, 0.0) + var card := VBoxContainer.new() + card.add_theme_constant_override("separation", 6) + frame.add_child(card) var name_label := Label.new() name_label.text = def.display_name @@ -138,7 +147,7 @@ func _make_card(index: int, id: StringName) -> Control: var body := Label.new() body.text = def.description body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - body.custom_minimum_size = Vector2(230.0, 96.0) + body.custom_minimum_size = Vector2(214.0, 104.0) body.add_theme_font_size_override("font_size", 13) card.add_child(body) @@ -156,7 +165,7 @@ func _make_card(index: int, id: StringName) -> Control: take.custom_minimum_size = Vector2(0.0, 34.0) take.pressed.connect(func() -> void: choose_requested.emit(index)) card.add_child(take) - return card + return frame ## Counted rather than listed one per line: "Split Shot x3" is the number that diff --git a/src/view/ui_theme.gd b/src/view/ui_theme.gd index 8383795..fb8b7a6 100644 --- a/src/view/ui_theme.gd +++ b/src/view/ui_theme.gd @@ -24,14 +24,30 @@ const TEXTURES := { "button_disabled": DIR + "Button01a_4.png", "panel": DIR + "Frame01a.png", "panel_accent": DIR + "Frame02a.png", + "panel_warn": DIR + "Frame03a.png", + "card": DIR + "FrameMarker01a.png", "slot": DIR + "FrameSlot01a.png", "slot_active": DIR + "FrameSlot01b.png", "bar": DIR + "Bar01a.png", "bar_fill": DIR + "BarFill01f.png", + "track": DIR + "Bar05a.png", + "trough": DIR + "Bar07a.png", + "grabber": DIR + "Handle03a.png", + "grabber_small": DIR + "Handle02a.png", "field": DIR + "InputField01a.png", "banner": DIR + "Banner01a.png", } +## Panels are the pack's shape in this game's colours. +## +## The set is cream throughout, which is right for its buttons -- they are meant +## to be the bright thing you press -- and wrong for a full-screen dialog laid +## over a dark dungeon. Modulating the panel art keeps the pixel border and the +## corner shape and lets every label the game already draws in light colours +## stay readable, instead of a rewrite of every colour override in five screens. +const PANEL_TINT := Color(0.17, 0.18, 0.24) +const TRACK_TINT := Color(0.30, 0.32, 0.40) + ## The art is cream with a black outline, so text on it has to be dark. Every ## label the game draws over the dark world stays light; only controls that sit ## on this pack's own surfaces flip. @@ -67,8 +83,50 @@ static func get_theme() -> Theme: t.set_color("font_disabled_color", "Button", INK_DIM) t.set_color("font_focus_color", "Button", INK) - t.set_stylebox("panel", "Panel", _box("panel", 8, 10, 10)) - t.set_stylebox("panel", "PanelContainer", _box("panel", 8, 10, 10)) + var dialog := _box("panel", 10, 22, 18) + dialog.modulate_color = PANEL_TINT + t.set_stylebox("panel", "Panel", dialog) + t.set_stylebox("panel", "PanelContainer", dialog) + + # Sliders: a groove, a filled portion, and a grabber big enough to aim at. + # + # The groove uses the SOLID bar rather than the pack's hollow one. Tinting a + # sprite whose middle is transparent leaves an outline and nothing else, so + # the first version of this had an invisible track and a grabber apparently + # floating in space. + # The content margins are load-bearing here, not padding: a Slider draws its + # groove in a band whose thickness IS the stylebox's minimum size, and a + # stylebox with no content margins has a minimum size of zero. With them at + # 0 the track resolved correctly, reported the right texture, and drew + # nothing at all. + var groove := _box("trough", 4, 0, 5) + groove.modulate_color = TRACK_TINT + t.set_stylebox("slider", "HSlider", groove) + var filled := _box("bar_fill", 1, 0, 4) + filled.modulate_color = Color(0.55, 0.78, 1.0) + t.set_stylebox("grabber_area", "HSlider", filled) + t.set_stylebox("grabber_area_highlight", "HSlider", filled) + # Twice size, not three times: the pack's handle is 8x10 and at 3x it was + # taller than the row it sits in. + var grab := scaled("grabber", 2) + t.set_icon("grabber", "HSlider", grab) + t.set_icon("grabber_highlight", "HSlider", grab) + t.set_icon("grabber_disabled", "HSlider", grab) + t.set_constant("center_grabber", "HSlider", 1) + + # Scrollbars. Two screens scroll, and Godot's default bar is the one + # remaining piece of grey chrome once everything else is the pack's. + # Content margins again, and for the same reason: a ScrollBar takes its + # THICKNESS from the minimum size of its styleboxes. At zero the bar is + # zero pixels wide, which is indistinguishable from having no scrollbar -- + # and that is precisely how it looked. + for bar_class in ["VScrollBar", "HScrollBar"]: + var trough := _box("trough", 4, 5, 5) + trough.modulate_color = TRACK_TINT + t.set_stylebox("scroll", bar_class, trough) + t.set_stylebox("scroll_focus", bar_class, trough) + for state in ["grabber", "grabber_highlight", "grabber_pressed"]: + t.set_stylebox(state, bar_class, _box("grabber_small", 3, 5, 5)) t.set_stylebox("normal", "LineEdit", _box("field", 8, 10, 6)) t.set_stylebox("focus", "LineEdit", _box("field", 8, 10, 6)) @@ -102,6 +160,38 @@ static func themed_root() -> Control: return root +## A copy of a texture at a whole multiple of its size, nearest-filtered. +## +## For theme items Godot draws at the texture's own size -- a slider grabber, +## for one -- where the pack's 8x10 handle would otherwise be a speck. Nearest +## rather than smooth: this is pixel art, and a bilinear upscale of an 8px +## sprite is mush. +static func scaled(key: String, factor: int) -> ImageTexture: + var img := texture(key).get_image() + img.resize(img.get_width() * factor, img.get_height() * factor, + Image.INTERPOLATE_NEAREST) + return ImageTexture.create_from_image(img) + + +## A panel to put a dialog's contents in, so a screen is a thing on the world +## rather than text floating over it. +static func dialog_panel() -> PanelContainer: + var frame := PanelContainer.new() + apply_to(frame) + return frame + + +## A card: the pack's marker frame, tinted. Used for the upgrade choices, where +## the tint carries the rarity. +static func card_panel(tint: Color) -> PanelContainer: + var card := PanelContainer.new() + apply_to(card) + var style := _box("card", 10, 12, 10) + style.modulate_color = tint + card.add_theme_stylebox_override("panel", style) + return card + + ## A nine-patch stylebox. [param margin] is the corner size in source pixels; ## the middle is what stretches. static func _box(key: String, margin: int, pad_x: int, pad_y: int) -> StyleBoxTexture: diff --git a/tests/unit/test_ui_theme.gd b/tests/unit/test_ui_theme.gd index 6f45715..d9a26a4 100644 --- a/tests/unit/test_ui_theme.gd +++ b/tests/unit/test_ui_theme.gd @@ -138,3 +138,77 @@ func test_every_screen_actually_gets_the_theme() -> void: for b: Button in buttons: assert_eq(b.get_theme_stylebox("normal", "Button"), wanted, "%s: '%s' is not themed" % [name, b.text]) + + +## A Slider draws its groove in a band whose thickness is the stylebox's own +## minimum size -- which for a StyleBoxTexture is its content margins. With +## those at zero the track resolves, reports the right texture, and draws +## nothing, which is exactly what it did. +func test_slider_styleboxes_have_a_thickness() -> void: + var t := UiTheme.get_theme() + for key in ["slider", "grabber_area", "grabber_area_highlight"]: + var sb := t.get_stylebox(key, "HSlider") + assert_gt(sb.get_minimum_size().y, 0.0, + "HSlider/%s would draw with no height" % key) + + +## The filled portion has to sit inside the groove, not overhang it. +func test_the_filled_portion_is_no_thicker_than_the_groove() -> void: + var t := UiTheme.get_theme() + assert_lte(t.get_stylebox("grabber_area", "HSlider").get_minimum_size().y, + t.get_stylebox("slider", "HSlider").get_minimum_size().y) + + +func test_the_slider_grabber_is_big_enough_to_aim_at() -> void: + var grab := UiTheme.get_theme().get_icon("grabber", "HSlider") + assert_not_null(grab) + assert_gte(grab.get_width(), 12, "an 8px grabber is a speck at this scale") + assert_lte(grab.get_height(), 26, "and one taller than its row overflows it") + + +## Nearest-neighbour and a whole multiple, or the pixel art turns to mush. +func test_scaling_a_sprite_keeps_it_a_whole_multiple() -> void: + var src := UiTheme.texture("grabber") + var out := UiTheme.scaled("grabber", 2) + assert_eq(out.get_width(), src.get_width() * 2) + assert_eq(out.get_height(), src.get_height() * 2) + + +## Every scrollbar piece has to be styled or the default grey chrome shows +## through on the two screens that scroll. +func test_scrollbars_are_styled() -> void: + var t := UiTheme.get_theme() + for bar_class in ["VScrollBar", "HScrollBar"]: + for key in ["scroll", "grabber", "grabber_highlight", "grabber_pressed"]: + assert_true(t.has_stylebox(key, bar_class), + "%s/%s is unstyled" % [bar_class, key]) + + +## And they need a thickness, for the same reason sliders do: a ScrollBar sizes +## itself from its styleboxes' minimum size, so styling one with no content +## margins produces a bar zero pixels wide -- which looks exactly like having +## no scrollbar, and did. +func test_scrollbars_have_a_thickness_in_both_directions() -> void: + var t := UiTheme.get_theme() + for bar_class in ["VScrollBar", "HScrollBar"]: + for key in ["scroll", "grabber"]: + var size := t.get_stylebox(key, bar_class).get_minimum_size() + assert_gt(size.x, 0.0, "%s/%s has no width" % [bar_class, key]) + assert_gt(size.y, 0.0, "%s/%s has no height" % [bar_class, key]) + + +## Panels are the pack's shape in this game's colours: the art is cream, and a +## cream dialog over a dark dungeon would need every label in five screens +## recoloured. Tinting the stylebox keeps the border and the existing text. +func test_dialog_panels_are_tinted_dark() -> void: + var sb := UiTheme.get_theme().get_stylebox("panel", "PanelContainer") as StyleBoxTexture + assert_lt(sb.modulate_color.get_luminance(), 0.35, + "a bright panel would make every light label on it unreadable") + assert_gt(sb.get_minimum_size().x, 0.0, "a dialog needs padding") + + +func test_a_card_panel_carries_its_tint() -> void: + var card: PanelContainer = autofree(UiTheme.card_panel(Color(0.9, 0.2, 0.2))) + var sb := card.get_theme_stylebox("panel") as StyleBoxTexture + assert_not_null(sb) + assert_almost_eq(sb.modulate_color.r, 0.9, 0.001) diff --git a/tools/screenshot.gd b/tools/screenshot.gd index b94b327..fbb3a40 100644 --- a/tools/screenshot.gd +++ b/tools/screenshot.gd @@ -25,7 +25,9 @@ const OPEN_SETTINGS := 250 const AT_SETTINGS := 280 const OPEN_CREDITS := 290 const AT_CREDITS := 320 -const DONE := 330 +const OPEN_UPGRADES := 330 +const AT_UPGRADES := 360 +const DONE := 370 var _frame: int = 0 var _game: Node = null @@ -45,8 +47,26 @@ func _ready() -> void: add_child(_menu) +## Put the player at the quartermaster on BOTH sides. Setting only the client's +## predicted position does not hold: reconciliation pulls it back to whatever +## the server says within a tick or two, the game scene notices it is no longer +## at the NPC, and closes the screen again. +func _stand_at_the_npc() -> void: + var npc: Vector2 = Net.client.upgrade_npc + Net.client.predicted_pos = npc + var inst := Net.server.instance_of(Net.LOCAL_PEER) + if inst != null: + var p: SimPlayer = inst.world.players.get(Net.LOCAL_PEER) + if p != null: + p.pos = npc + _game.set("_upgrades_open", true) + + func _process(_delta: float) -> void: _frame += 1 + # Held until the shot is taken; one frame of it is not enough. + if _frame > OPEN_UPGRADES and _frame <= AT_UPGRADES: + _stand_at_the_npc() match _frame: AT_MENU: _grab("menu") @@ -72,6 +92,22 @@ func _process(_delta: float) -> void: _game.get_node("CreditsScreen").open() AT_CREDITS: _grab("credits") + OPEN_UPGRADES: + # Driven with a made-up offer rather than by earning one: the point + # here is the card layout, and levelling a character up first would + # make the shot depend on a dungeon run. + _game.get_node("CreditsScreen").visible = false + # The game scene owns this screen's visibility and re-asserts it + # every frame, closing it unless the player is at the NPC -- so + # setting `visible` directly lasted exactly one frame. Stand the + # player at the quartermaster and open it the way the game does. + _stand_at_the_npc() + var offer: Array[StringName] = [ + Upgrades.SPLIT_SHOT, Upgrades.POISON, Upgrades.ERASER] + var taken: Array[StringName] = [Upgrades.SNIPER, Upgrades.SPREAD] + _game.get_node("UpgradeScreen").refresh(2, offer, taken) + AT_UPGRADES: + _grab("upgrades") DONE: Net.shutdown() get_tree().quit(0)