Style the rest of the interface: panels, sliders, scrollbars, cards
ci / verify (push) Successful in 48s

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>
This commit is contained in:
2026-09-07 11:45:46 +02:00
parent 132646f6c3
commit 61680d00d7
24 changed files with 548 additions and 22 deletions
+7 -1
View File
@@ -148,7 +148,13 @@ ticks in milliseconds with no SceneTree.
(`UiTheme.themed_root()`). `tests/unit/test_ui_theme.gd` instantiates every (`UiTheme.themed_root()`). `tests/unit/test_ui_theme.gd` instantiates every
screen and asks what its buttons actually resolve, because this failure is screen and asks what its buttons actually resolve, because this failure is
invisible to every other kind of check. 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 strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code
in `src/main.gd` instead of trusted to `project.godot`. in `src/main.gd` instead of trusted to `project.godot`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

+40
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

+40
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

+40
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

+40
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

+40
View File
@@ -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
+10
View File
@@ -460,6 +460,16 @@ diagnose themselves.
overrides it. Captured later it would restore the last session's choice — that overrides it. Captured later it would restore the last session's choice — that
is, the thing the player was trying to undo. 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 **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 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. being real mixer settings beats multiplying a number into every `play()` call.
+11 -2
View File
@@ -20,7 +20,7 @@ What "everything passes" currently means. Numbers move; the shape does not.
| Gate | Covers | Runtime | | Gate | Covers | Runtime |
| --- | --- | --- | | --- | --- | --- |
| `tools/check.sh` | every script parses and type-checks | ~5s | | `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 | | `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_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s |
| `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~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, `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 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 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, 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 | | | 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) | | 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/` | | 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` | | 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 | | 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. | | **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. |
+10 -1
View File
@@ -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 was the test suite writing the player's real `user://settings.cfg`, because
rebinding calls `save()` and nothing had redirected the path. 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. - 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 - 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 user state**. Redirect the path in the fixture, and assert the default is
still the real one so the redirect cannot escape. still the real one so the redirect cannot escape.
+6 -1
View File
@@ -36,10 +36,15 @@ func _ready() -> void:
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(centre) 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() var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(460.0, 0.0) panel.custom_minimum_size = Vector2(460.0, 0.0)
panel.add_theme_constant_override("separation", 8) panel.add_theme_constant_override("separation", 8)
centre.add_child(panel) frame.add_child(panel)
_title = Label.new() _title = Label.new()
_title.text = "CHARACTERS" _title.text = "CHARACTERS"
+6 -1
View File
@@ -28,10 +28,15 @@ func _ready() -> void:
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(centre) 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() var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(620.0, 0.0) panel.custom_minimum_size = Vector2(620.0, 0.0)
panel.add_theme_constant_override("separation", 8) panel.add_theme_constant_override("separation", 8)
centre.add_child(panel) frame.add_child(panel)
var title := Label.new() var title := Label.new()
title.text = "CREDITS" title.text = "CREDITS"
+6 -1
View File
@@ -37,10 +37,15 @@ func _ready() -> void:
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(center) 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 = VBoxContainer.new()
_panel.custom_minimum_size = Vector2(320.0, 0.0) _panel.custom_minimum_size = Vector2(320.0, 0.0)
_panel.add_theme_constant_override("separation", 10) _panel.add_theme_constant_override("separation", 10)
center.add_child(_panel) frame.add_child(_panel)
var title := Label.new() var title := Label.new()
title.text = "PAUSED" title.text = "PAUSED"
+33 -5
View File
@@ -34,10 +34,15 @@ func _ready() -> void:
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(centre) 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() var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(520.0, 0.0) panel.custom_minimum_size = Vector2(520.0, 0.0)
panel.add_theme_constant_override("separation", 8) panel.add_theme_constant_override("separation", 8)
centre.add_child(panel) frame.add_child(panel)
var title := Label.new() var title := Label.new()
title.text = "SETTINGS" title.text = "SETTINGS"
@@ -93,12 +98,35 @@ func _ready() -> void:
buttons.add_child(close) 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() var l := Label.new()
l.text = text l.text = text
l.add_theme_font_size_override("font_size", 14) l.add_theme_font_size_override("font_size", 13)
l.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78)) l.add_theme_color_override("font_color", UiTheme.INK)
return l 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: func _slider_row(parent: Control, label: String) -> Array:
+16 -7
View File
@@ -43,10 +43,15 @@ func _ready() -> void:
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(centre) 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() var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(760.0, 0.0) panel.custom_minimum_size = Vector2(760.0, 0.0)
panel.add_theme_constant_override("separation", 10) panel.add_theme_constant_override("separation", 10)
centre.add_child(panel) frame.add_child(panel)
_title = Label.new() _title = Label.new()
_title.add_theme_font_size_override("font_size", 26) _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: func _make_card(index: int, id: StringName) -> Control:
var def := Upgrades.get_def(id) 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: if def == null:
return card return Control.new()
var tint: Color = RARITY_COLOURS.get(def.rarity, Color.WHITE) 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() var name_label := Label.new()
name_label.text = def.display_name name_label.text = def.display_name
@@ -138,7 +147,7 @@ func _make_card(index: int, id: StringName) -> Control:
var body := Label.new() var body := Label.new()
body.text = def.description body.text = def.description
body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART 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) body.add_theme_font_size_override("font_size", 13)
card.add_child(body) 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.custom_minimum_size = Vector2(0.0, 34.0)
take.pressed.connect(func() -> void: choose_requested.emit(index)) take.pressed.connect(func() -> void: choose_requested.emit(index))
card.add_child(take) card.add_child(take)
return card return frame
## Counted rather than listed one per line: "Split Shot x3" is the number that ## Counted rather than listed one per line: "Split Shot x3" is the number that
+92 -2
View File
@@ -24,14 +24,30 @@ const TEXTURES := {
"button_disabled": DIR + "Button01a_4.png", "button_disabled": DIR + "Button01a_4.png",
"panel": DIR + "Frame01a.png", "panel": DIR + "Frame01a.png",
"panel_accent": DIR + "Frame02a.png", "panel_accent": DIR + "Frame02a.png",
"panel_warn": DIR + "Frame03a.png",
"card": DIR + "FrameMarker01a.png",
"slot": DIR + "FrameSlot01a.png", "slot": DIR + "FrameSlot01a.png",
"slot_active": DIR + "FrameSlot01b.png", "slot_active": DIR + "FrameSlot01b.png",
"bar": DIR + "Bar01a.png", "bar": DIR + "Bar01a.png",
"bar_fill": DIR + "BarFill01f.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", "field": DIR + "InputField01a.png",
"banner": DIR + "Banner01a.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 ## 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 ## label the game draws over the dark world stays light; only controls that sit
## on this pack's own surfaces flip. ## 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_disabled_color", "Button", INK_DIM)
t.set_color("font_focus_color", "Button", INK) t.set_color("font_focus_color", "Button", INK)
t.set_stylebox("panel", "Panel", _box("panel", 8, 10, 10)) var dialog := _box("panel", 10, 22, 18)
t.set_stylebox("panel", "PanelContainer", _box("panel", 8, 10, 10)) 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("normal", "LineEdit", _box("field", 8, 10, 6))
t.set_stylebox("focus", "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 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; ## A nine-patch stylebox. [param margin] is the corner size in source pixels;
## the middle is what stretches. ## the middle is what stretches.
static func _box(key: String, margin: int, pad_x: int, pad_y: int) -> StyleBoxTexture: static func _box(key: String, margin: int, pad_x: int, pad_y: int) -> StyleBoxTexture:
+74
View File
@@ -138,3 +138,77 @@ func test_every_screen_actually_gets_the_theme() -> void:
for b: Button in buttons: for b: Button in buttons:
assert_eq(b.get_theme_stylebox("normal", "Button"), wanted, assert_eq(b.get_theme_stylebox("normal", "Button"), wanted,
"%s: '%s' is not themed" % [name, b.text]) "%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)
+37 -1
View File
@@ -25,7 +25,9 @@ const OPEN_SETTINGS := 250
const AT_SETTINGS := 280 const AT_SETTINGS := 280
const OPEN_CREDITS := 290 const OPEN_CREDITS := 290
const AT_CREDITS := 320 const AT_CREDITS := 320
const DONE := 330 const OPEN_UPGRADES := 330
const AT_UPGRADES := 360
const DONE := 370
var _frame: int = 0 var _frame: int = 0
var _game: Node = null var _game: Node = null
@@ -45,8 +47,26 @@ func _ready() -> void:
add_child(_menu) 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: func _process(_delta: float) -> void:
_frame += 1 _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: match _frame:
AT_MENU: AT_MENU:
_grab("menu") _grab("menu")
@@ -72,6 +92,22 @@ func _process(_delta: float) -> void:
_game.get_node("CreditsScreen").open() _game.get_node("CreditsScreen").open()
AT_CREDITS: AT_CREDITS:
_grab("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: DONE:
Net.shutdown() Net.shutdown()
get_tree().quit(0) get_tree().quit(0)