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 0000000..753ceec Binary files /dev/null and b/assets/sprites/ui/Bar05a.png differ 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 0000000..4e5502f Binary files /dev/null and b/assets/sprites/ui/Bar07a.png differ 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 0000000..c754988 Binary files /dev/null and b/assets/sprites/ui/Frame03a.png differ 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 0000000..bd5f63d Binary files /dev/null and b/assets/sprites/ui/FrameMarker01a.png differ 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 0000000..e514752 Binary files /dev/null and b/assets/sprites/ui/Handle02a.png differ 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 0000000..f286cc8 Binary files /dev/null and b/assets/sprites/ui/Handle03a.png differ diff --git a/assets/sprites/ui/Handle03a.png.import b/assets/sprites/ui/Handle03a.png.import new file mode 100644 index 0000000..d45c588 --- /dev/null +++ b/assets/sprites/ui/Handle03a.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dxd0tojksos8m" +path="res://.godot/imported/Handle03a.png-22d133e9d9604a2db6d3a7746a093159.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/sprites/ui/Handle03a.png" +dest_files=["res://.godot/imported/Handle03a.png-22d133e9d9604a2db6d3a7746a093159.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/docs/DECISIONS.md b/docs/DECISIONS.md index 5c02780..2968f05 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -460,6 +460,16 @@ diagnose themselves. overrides it. Captured later it would restore the last session's choice — that is, the thing the player was trying to undo. +**Panels are the pack's shape in this game's colours.** The set is cream +throughout, which is right for buttons — they are meant to be the bright thing +you press — and wrong for a dialog laid over a dark dungeon. The panel stylebox +is modulated dark, which keeps the pixel border and the corner shape and lets +every label the game already draws in light colours stay readable. The +alternative was recolouring every label in five screens to suit the art. + +**A card's rarity is its frame, not a word on it.** Three upgrade choices are +compared at a glance, and a colour reads faster than a label. + **Effects play on an SFX bus created at runtime.** A bus layout resource would be one more file to keep in step with the code that reads it, and the sliders being real mixer settings beats multiplying a number into every `play()` call. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2271e7b..d26e367 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,7 +20,7 @@ What "everything passes" currently means. Numbers move; the shape does not. | Gate | Covers | Runtime | | --- | --- | --- | | `tools/check.sh` | every script parses and type-checks | ~5s | -| `tools/test.sh` | 460 GUT tests, no SceneTree | ~4s | +| `tools/test.sh` | 468 GUT tests, no SceneTree | ~4s | | `tools/smoke.sh` | 23 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s | | `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s | | `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s | @@ -29,7 +29,14 @@ What "everything passes" currently means. Numbers move; the shape does not. `tools/screenshot.tscn` is a **manual** check, not a gate: it needs a display, and it is the only thing in the project that can tell you the interface -actually rendered. Run it after touching `src/ui/`. +actually rendered. Run it after touching `src/ui/`. It captures the main menu, +the HUD, the pause menu, settings, credits and the upgrade choice — the last of +those by staging an offer, because earning one would make the shot depend on a +dungeon run. + +It has now caught four bugs that every other gate passed: the theme applying to +nothing, the test suite rewriting the player's settings, an invisible slider +track and an invisible scrollbar. The four diagnostics exist because the smoke test structurally cannot reach what they cover: bots are poor shots (so they neither level up, produce drops, @@ -134,6 +141,8 @@ play off server events. Enough to prove the pipeline, not a finished look. | Directional sprites, hit flashes, screen shake | todo | | | Rebindable controls | done | [src/ui/settings_screen.gd](../src/ui/settings_screen.gd), [src/core/settings.gd](../src/core/settings.gd) | | Themed controls from a UI pack | done | [src/view/ui_theme.gd](../src/view/ui_theme.gd), art in `assets/sprites/ui/` | +| Dialogs on panels, sliders, scrollbars | done | `UiTheme.dialog_panel`, `HSlider` / `VScrollBar` theme entries | +| Upgrade cards framed by rarity | done | `UiTheme.card_panel`, `UpgradeScreen._make_card` | | Textured HUD bars and inventory slots | done | `HUD._bar`, `HUD._draw_inventory` | | Audio buses and a volume setting | done | `Settings`, an SFX bus created at runtime, sliders in the settings screen | | **In-game credits screen** | **done** | Settings → Credits, from [src/core/credits.gd](../src/core/credits.gd). Two packs are CC BY 4.0 and attribution is a licence *requirement* — [CREDITS.md](../CREDITS.md) is not reachable by a player. | diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 8c1cad3..c331728 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -266,9 +266,18 @@ find. The same run then showed **Fire bound to the right mouse button**, which was the test suite writing the player's real `user://settings.cfg`, because rebinding calls `save()` and nothing had redirected the path. -Two lessons, and the second is the general one: +Styling the rest of the interface then produced two more of exactly the same +shape: an invisible slider track and an invisible scrollbar. Both resolved the +right stylebox, reported the right texture, and drew nothing, because a +`Slider` and a `ScrollBar` take their thickness from the stylebox's *minimum +size* — its content margins — and mine were zero. Neither was findable without +looking. + +Three lessons, and the last is the general one: - After touching `src/ui/`, look at it. There is no substitute. +- When a control resolves the right theme item and still looks wrong, check + what Godot derives from that item's *size* rather than its picture. - A test that exercises a code path which writes to user state **will write to user state**. Redirect the path in the fixture, and assert the default is still the real one so the redirect cannot escape. diff --git a/src/ui/character_select.gd b/src/ui/character_select.gd index d7c828f..9e87f35 100644 --- a/src/ui/character_select.gd +++ b/src/ui/character_select.gd @@ -36,10 +36,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(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)