Rework the UI on Crusenho's pack; credit it in the game, not just the repo
ci / verify (push) Successful in 48s

Crusenho's Complete UI Essential Pack is CC BY 4.0 -- redistributable and
commercial-friendly, confirmed from the License.txt the pack itself ships --
so unlike the two bdragon packs a subset is committed: twelve PNGs, 48 KB,
under assets/sprites/ui/. Only what is used, because each committed PNG costs
a Godot .import sidecar and a directory nothing references is one nobody
prunes.

UiTheme builds a Theme in code from it -- button states, panels, line edits --
and every screen roots itself through UiTheme.themed_root(). The HUD's bars are
the pack's frame with a tinted fill, drawn as three horizontal slices because
Godot's nine-patch lives on nodes and the HUD is drawn rather than built from
controls. Inventory slots use the pack's slot art at exactly twice the source
size; a non-integer scale on a 1px border reads as a wobble along every edge.

The credits screen is the other half of the request and it is a licence
obligation, not a nicety: two packs are now CC BY, which asks for attribution
"in any reasonable manner", and a markdown file in a source repo is not
reasonable for someone who downloaded a build. Settings -> Credits shows every
source with its terms and a link to the licence text. test_credits.gd asserts
CREDITS.md and docs/ASSETS.md name every entry, so the three cannot drift.

Two things found by actually looking at the screen, which is the point:

  - The FIRST version of this styled nothing. A Control inherits its theme from
    Control ANCESTORS only, and the chain breaks at the first plain Node or
    CanvasLayer -- which is every screen here. get_window().theme set the
    property, changed nothing, and read as correct. check.sh, 458 tests and a
    clean smoke run all passed with the entire interface unstyled. The theme
    test now instantiates every screen and asks what its buttons resolve.
  - The settings screen showed Fire bound to the right mouse button, because
    the test suite was writing the player's real user://settings.cfg --
    rebinding calls save() and nothing had redirected the path. Settings.path
    is now redirectable, the fixture points it at a scratch file, and a test
    asserts the default is still the player's own.

tools/screenshot.tscn is what found both. It boots the client windowed and
saves the menus, the HUD, settings and credits. Manual, needs a display, and
the only thing in the project that can tell you the interface rendered.

check.sh clean, 460 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:12:13 +02:00
parent 872922e9e2
commit 132646f6c3
54 changed files with 1319 additions and 50 deletions
+12 -1
View File
@@ -29,6 +29,7 @@ godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up
godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats
godot --headless --path . --script tools/bench.gd # sim cost per tick godot --headless --path . --script tools/bench.gd # sim cost per tick
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas python3 tools/build_local_assets.py # rebuild the local-only bullet atlas
SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # capture the menus and HUD (needs a display)
``` ```
`diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke `diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke
@@ -105,6 +106,8 @@ and `tests/integration/test_replica_parity.gd` pin this down.
| `src/instances/` | Lobby hub and dungeon runs. | | `src/instances/` | Lobby hub and dungeon runs. |
| `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. | | `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. |
| `src/core/settings.gd` | Client-local preferences: key bindings and volumes. Never reaches the server. | | `src/core/settings.gd` | Client-local preferences: key bindings and volumes. Never reaches the server. |
| `src/core/credits.gd` | Every third-party asset and its licence. Two are CC BY, so this is a legal requirement, not a nicety. |
| `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. |
| `src/autoload/net.gd` | The only autoload. RPC surface. | | `src/autoload/net.gd` | The only autoload. RPC surface. |
| `tools/` | Headless tooling. | | `tools/` | Headless tooling. |
@@ -137,7 +140,15 @@ ticks in milliseconds with no SceneTree.
coordinate even on an anchored control; `offset_left`/`offset_top` are the coordinate even on an anchored control; `offset_left`/`offset_top` are the
anchor-relative ones. This combination silently broke the main menu and anchor-relative ones. This combination silently broke the main menu and
three pieces of the HUD. three pieces of the HUD.
6. **`ProjectSettings.save()` drops settings equal to the engine default** and 6. **A Control's theme is inherited from Control ANCESTORS only.** The chain
breaks at the first parent that is a plain `Node` or a `CanvasLayer`, which
here is every screen: they hang off `main.gd` or off a CanvasLayer. Setting
`get_window().theme` therefore compiles, runs, changes the property — and
styles nothing. Apply the theme to each screen's own root Control
(`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
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`.
+14 -2
View File
@@ -1,8 +1,14 @@
# Credits # Credits
Every third-party asset pack used or bundled during development, credited Every third-party asset pack used or bundled during development, credited
regardless of whether its licence requires it. Two of them do not — they are regardless of whether its licence requires it. Some of them do not require it —
here because the work deserves it. they are here because the work deserves it.
**This file is not the only place credit appears.** Two of the packs are CC BY
4.0, which asks for attribution "in any reasonable manner", and a markdown file
in a source repository is not reasonable for someone who downloaded a build. The
game carries the same list on its own credits screen (Settings → Credits), built
from `src/core/credits.gd`; `tests/unit/test_credits.gd` asserts the two agree.
## In the game ## In the game
@@ -11,6 +17,12 @@ Terrain, player, enemies and boss sprites.
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) — public domain. [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) — public domain.
*Credit not required.* *Credit not required.*
**Complete UI Essential Pack** — [Crusenho Agus Hennihuno](https://crusenho.itch.io/complete-ui-essential-pack)
Buttons, panels, bars and inventory slots. Used unmodified; a subset of the
pack's individual sprites is committed under `assets/sprites/ui/`.
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
**Credit required by licence.**
**Pixel Combat SFX** — [Helton Yan](https://heltonyan.itch.io/pixelcombat) **Pixel Combat SFX** — [Helton Yan](https://heltonyan.itch.io/pixelcombat)
All sound effects. Converted to 16-bit / 44.1 kHz mono and trimmed. All sound effects. Converted to 16-bit / 44.1 kHz mono and trimmed.
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
+14 -3
View File
@@ -12,6 +12,17 @@ The split exists so that adding a 3.9 GB pack of source material costs the
repository and the import step nothing, while the handful of files the game repository and the import step nothing, while the handful of files the game
loads stay reviewable. loads stay reviewable.
Nothing is here yet — the game currently draws itself from primitives and one What is here:
runtime-generated dot texture ([src/view/bullet_renderer.gd](../src/view/bullet_renderer.gd)).
Art is Stage 2 of [docs/ROADMAP.md](../docs/ROADMAP.md). | Path | What | Source |
| --- | --- | --- |
| `sprites/dungeon_tileset.png` | terrain, characters, bosses, item icons | 0x72, CC0 |
| `sprites/ui/` | buttons, panels, bars, inventory slots | Crusenho, CC BY 4.0 |
| `audio/sfx/` | every sound the game plays | Helton Yan, CC BY 4.0 |
| `local/` | bullet and impact art — **gitignored**, licence forbids redistribution | bdragon1727 |
Sprite rects and sound paths are declared in
[src/view/art.gd](../src/view/art.gd); UI textures in
[src/view/ui_theme.gd](../src/view/ui_theme.gd). Attribution lives in
[CREDITS.md](../CREDITS.md) and on the game's own credits screen — see
[docs/ASSETS.md](../docs/ASSETS.md) for the licence findings.
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://m2nhvybbia58"
path="res://.godot/imported/Banner01a.png-79d54606f6c5efa37ca262c86fb3c88b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Banner01a.png"
dest_files=["res://.godot/imported/Banner01a.png-79d54606f6c5efa37ca262c86fb3c88b.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: 127 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b4w7eso648lkb"
path="res://.godot/imported/Bar01a.png-4fa78ad3d34ea11ebad35ea23bc8ff70.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Bar01a.png"
dest_files=["res://.godot/imported/Bar01a.png-4fa78ad3d34ea11ebad35ea23bc8ff70.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: 93 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://0txc40f6u7pc"
path="res://.godot/imported/BarFill01f.png-8f813e9f1aea22470c4cd1a20dc14300.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/BarFill01f.png"
dest_files=["res://.godot/imported/BarFill01f.png-8f813e9f1aea22470c4cd1a20dc14300.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: 161 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dk2bbq73k8h27"
path="res://.godot/imported/Button01a_1.png-2cddde3a12e70b9f65fa9b0cba03379d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Button01a_1.png"
dest_files=["res://.godot/imported/Button01a_1.png-2cddde3a12e70b9f65fa9b0cba03379d.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: 161 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b8nsxodlmgomt"
path="res://.godot/imported/Button01a_2.png-35c4875fe9ac1c69211a75ffbcc0fd8c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Button01a_2.png"
dest_files=["res://.godot/imported/Button01a_2.png-35c4875fe9ac1c69211a75ffbcc0fd8c.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: 163 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cvj6idjcu0j1s"
path="res://.godot/imported/Button01a_3.png-3896dc57fbcbb9c973f680df22c8b858.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Button01a_3.png"
dest_files=["res://.godot/imported/Button01a_3.png-3896dc57fbcbb9c973f680df22c8b858.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: 164 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dj0kffckj1iug"
path="res://.godot/imported/Button01a_4.png-9c12b7189c542159f43a7e003d114d95.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Button01a_4.png"
dest_files=["res://.godot/imported/Button01a_4.png-9c12b7189c542159f43a7e003d114d95.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: 389 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bybgrl0ye24w0"
path="res://.godot/imported/Frame01a.png-626104f1220a56d01a83a690493b113d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Frame01a.png"
dest_files=["res://.godot/imported/Frame01a.png-626104f1220a56d01a83a690493b113d.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: 368 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://y42352ti7y2t"
path="res://.godot/imported/Frame02a.png-ac57f8ba2960b573fe171292b8a7c378.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Frame02a.png"
dest_files=["res://.godot/imported/Frame02a.png-ac57f8ba2960b573fe171292b8a7c378.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: 190 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://btuhdrpfr8td3"
path="res://.godot/imported/FrameSlot01a.png-069d1091d125c87d7c14afba6400418d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/FrameSlot01a.png"
dest_files=["res://.godot/imported/FrameSlot01a.png-069d1091d125c87d7c14afba6400418d.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: 189 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://btf5al7f07nl"
path="res://.godot/imported/FrameSlot01b.png-bd6ad2d53ba974202493dbaf3f360ea5.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/FrameSlot01b.png"
dest_files=["res://.godot/imported/FrameSlot01b.png-bd6ad2d53ba974202493dbaf3f360ea5.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: 186 B

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cb41i0vnvjmri"
path="res://.godot/imported/InputField01a.png-d4cf01240254dbae255720ff161f1d6a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/InputField01a.png"
dest_files=["res://.godot/imported/InputField01a.png-d4cf01240254dbae255720ff161f1d6a.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
+29 -9
View File
@@ -12,22 +12,24 @@ can be referenced from a scene or script. Files are copied into `res://assets/`
in game-ready form, and only then imported. The tree is 3.9 GB and in game-ready form, and only then imported. The tree is 3.9 GB and
`tools/check.sh` runs `--import`, which would otherwise walk all of it every run. `tools/check.sh` runs `--import`, which would otherwise walk all of it every run.
What is in the game today totals **548 KB** across seven files: three sprite What is in the game today is three sprite sheets, four sounds and twelve small
sheets and four sounds. Sprite rects and sound paths are all declared in one UI sprites. Sprite rects and sound paths are all declared in one
place, [src/view/art.gd](../src/view/art.gd), and place, [src/view/art.gd](../src/view/art.gd), and
`tests/unit/test_art.gd` asserts every rect lands inside its texture — a wrong `tests/unit/test_art.gd` asserts every rect lands inside its texture — a wrong
atlas coordinate does not error, it silently draws the wrong pixels. atlas coordinate does not error, it silently draws the wrong pixels.
## Licence findings ## Licence findings
Checked against each pack's itch.io page. **Two of the four forbid Checked against each pack's itch.io page, and against the licence file the pack
redistribution**, which is what publishing a repository containing them would itself ships where there is one. **Two of the five forbid redistribution**,
be — so they are not in the repository. which is what publishing a repository containing them would be — so they are not
in the repository.
| Pack | Licence | Commercial | Credit | In this repo? | | Pack | Licence | Commercial | Credit | In this repo? |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| [0x72 DungeonTileset II](https://0x72.itch.io/dungeontileset-ii) | **CC0 1.0** (public domain) | Yes | Not required | **Yes**`assets/sprites/dungeon_tileset.png` | | [0x72 DungeonTileset II](https://0x72.itch.io/dungeontileset-ii) | **CC0 1.0** (public domain) | Yes | Not required | **Yes**`assets/sprites/dungeon_tileset.png` |
| [Helton Yan Pixel Combat](https://heltonyan.itch.io/pixelcombat) | **CC BY 4.0** | Yes | **Required** | **Yes**`assets/audio/sfx/` | | [Helton Yan Pixel Combat](https://heltonyan.itch.io/pixelcombat) | **CC BY 4.0** | Yes | **Required** | **Yes**`assets/audio/sfx/` |
| [Complete UI Essential Pack](https://crusenho.itch.io/complete-ui-essential-pack) | **CC BY 4.0** | Yes | **Required** | **Yes**`assets/sprites/ui/` |
| [Fire Pixel Bullet 16x16](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) | Custom | Free tier non-commercial; **paid tier allows commercial** | Encouraged | **No, at any tier** — local only | | [Fire Pixel Bullet 16x16](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) | Custom | Free tier non-commercial; **paid tier allows commercial** | Encouraged | **No, at any tier** — local only |
| [750+ Effect and FX Pixel All](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) | Custom | Free tier non-commercial; **paid tier allows commercial** | Encouraged | **No, at any tier** — local only | | [750+ Effect and FX Pixel All](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) | Custom | Free tier non-commercial; **paid tier allows commercial** | Encouraged | **No, at any tier** — local only |
@@ -56,12 +58,30 @@ passes 156/156 and the game runs.
This is also why nothing there may be `preload()`ed — `preload` resolves at This is also why nothing there may be `preload()`ed — `preload` resolves at
compile time and would fail the build on every machine that lacks the files. compile time and would fail the build on every machine that lacks the files.
### The UI pack is the easy case
Crusenho's *Complete UI Essential Pack* ships its own `License.txt` naming
CC BY 4.0 outright, with the licensor and store link spelled out. That permits
redistribution and commercial use, so unlike the bullet and FX packs a subset of
it is committed — twelve PNGs, 48 KB, under `assets/sprites/ui/`, listed in
`UiTheme.TEXTURES`.
Only what the game uses was copied rather than all 98 sprites: each committed
PNG costs a Godot `.import` sidecar, and a directory of files nothing references
is a directory nobody prunes. The rest stays in the untracked pack.
The obligation it creates is the same one the audio creates — see below.
### Two obligations this creates ### Two obligations this creates
1. **Helton Yan's CC BY 4.0 requires attribution**, and it is not satisfied by 1. **Two packs are CC BY 4.0 and require attribution** — Helton Yan's audio and
a file in the source tree once the game ships. Players need to be able to Crusenho's UI art. A file in the source tree does not satisfy that once the
see it. [CREDITS.md](../CREDITS.md) records it for now; an in-game credits game ships, because a player who downloaded a build never sees it. **Done:**
screen is a todo. Settings → Credits shows the same list, built from
[src/core/credits.gd](../src/core/credits.gd), including a link to the
licence text as CC BY asks. `tests/unit/test_credits.gd` asserts every source
there also appears in [CREDITS.md](../CREDITS.md) and in this file, so the
three cannot drift apart.
2. **Both bdragon packs are non-commercial in their free tier.** Buying the 2. **Both bdragon packs are non-commercial in their free tier.** Buying the
full tier *does* fix that — confirmed verbatim from the FX pack's paid full tier *does* fix that — confirmed verbatim from the FX pack's paid
licence: "You may use these assets in personal, commercial or licence: "You may use these assets in personal, commercial or
+8 -2
View File
@@ -20,13 +20,17 @@ 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` | 439 GUT tests, no SceneTree | ~4s | | `tools/test.sh` | 460 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 |
| `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s | | `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s |
| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted → **survives every zone change** | ~10s | | `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted → **survives every zone change** | ~10s |
`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/`.
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,
nor walk to the quartermaster), and a listen server cannot drift its own clock nor walk to the quartermaster), and a listen server cannot drift its own clock
@@ -129,8 +133,10 @@ play off server events. Enough to prove the pipeline, not a finished look.
| Impact/death VFX animation | todo | `Art.IMPACT` is loaded and validated but nothing plays it yet | | Impact/death VFX animation | todo | `Art.IMPACT` is loaded and validated but nothing plays it yet |
| 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/` |
| 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** | **todo** | Not cosmetic: the SFX 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. |
| Replace the two non-redistributable packs | todo | Bullet and FX art is local-only and non-commercial. CC0 replacements would let them into the repo and unblock a commercial release. See [ASSETS.md](ASSETS.md). | | Replace the two non-redistributable packs | todo | Bullet and FX art is local-only and non-commercial. CC0 replacements would let them into the repo and unblock a commercial release. See [ASSETS.md](ASSETS.md). |
## Stage 2 — Characters, persistence, levels · *done* ## Stage 2 — Characters, persistence, levels · *done*
+21
View File
@@ -251,3 +251,24 @@ the output, which is worth doing after any change to startup.
`smoke.sh` now asserts no plain engine errors either, excluding by name the one `smoke.sh` now asserts no plain engine errors either, excluding by name the one
line Godot prints on every clean exit — naming it means anything else that turns line Godot prints on every clean exit — naming it means anything else that turns
up is a real finding rather than noise to be squinted past. up is a real finding rather than noise to be squinted past.
### Nothing automated can see the screen
Every gate passed — 458 tests, a clean smoke run, no engine errors — while the
entire interface rendered in Godot's default style and none of the UI pack was
visible. A Control inherits its theme from Control *ancestors only*, and the
chain breaks at the first plain `Node` or `CanvasLayer`; every screen in this
game hangs off one. `get_window().theme = ...` set the property, changed
nothing, and looked correct in the code.
It took `tools/screenshot.tscn` — boot the client, save a PNG, open it — to
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:
- After touching `src/ui/`, look at it. There is no substitute.
- 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.
+76
View File
@@ -0,0 +1,76 @@
class_name Credits
extends RefCounted
## Every third-party asset the game ships, and the terms it ships under.
##
## This is not decoration. Two of these packs are CC BY 4.0, which requires
## attribution "in any reasonable manner" -- a file in the repository is not
## reasonable for a player who downloaded a build, so the game has to say so
## itself. See [CreditsScreen].
##
## `tests/unit/test_credits.gd` asserts every entry here also appears in
## CREDITS.md, so the two cannot drift apart.
## Each entry: what it is, who made it, the licence, a link, and what it is
## used for. `required` marks the ones the licence obliges us to show.
const SOURCES: Array[Dictionary] = [
{
"name": "16x16 DungeonTileset II",
"author": "0x72 (Robert Norenberg)",
"licence": "CC0 1.0 (public domain)",
"url": "https://0x72.itch.io/dungeontileset-ii",
"used_for": "Terrain, characters, bosses, item icons",
"required": false,
},
{
"name": "Complete UI Essential Pack",
"author": "Crusenho Agus Hennihuno",
"licence": "CC BY 4.0",
"url": "https://crusenho.itch.io/complete-ui-essential-pack",
"used_for": "Buttons, panels, bars, inventory slots",
"required": true,
},
{
"name": "Pixel Combat SFX",
"author": "Helton Yan",
"licence": "CC BY 4.0",
"url": "https://heltonyan.itch.io/pixelcombat",
"used_for": "All sound effects",
"required": true,
},
{
"name": "Fire Pixel Bullet 16x16",
"author": "bdragon1727",
"licence": "Custom - not redistributable",
"url": "https://bdragon1727.itch.io/fire-pixel-bullet-16x16",
"used_for": "Bullet sprites (local builds only)",
"required": false,
},
{
"name": "750+ Effect and FX Pixel All",
"author": "bdragon1727",
"licence": "Custom - not redistributable",
"url": "https://bdragon1727.itch.io/750-effect-and-fx-pixel-all",
"used_for": "Impact effects (local builds only)",
"required": false,
},
]
## CC BY asks for a link to the licence itself, not only to the work.
const LICENCE_LINKS := {
"CC BY 4.0": "https://creativecommons.org/licenses/by/4.0/",
"CC0 1.0 (public domain)": "https://creativecommons.org/publicdomain/zero/1.0/",
}
## The packs whose licence actually obliges us to credit them. Everything else
## in [constant SOURCES] is there because the work deserves it.
static func required() -> Array[Dictionary]:
var out: Array[Dictionary] = []
for entry in SOURCES:
if bool(entry["required"]):
out.append(entry)
return out
static func licence_url(licence: String) -> String:
return String(LICENCE_LINKS.get(licence, ""))
+1
View File
@@ -0,0 +1 @@
uid://drpadmmsrvoax
+11 -4
View File
@@ -10,7 +10,14 @@ extends RefCounted
## tool has no main loop and cannot resolve autoload names, and the settings ## tool has no main loop and cannot resolve autoload names, and the settings
## file is something a headless test may well want to read. ## file is something a headless test may well want to read.
const PATH := "user://settings.cfg" const DEFAULT_PATH := "user://settings.cfg"
## Where preferences are read from and written to. A variable rather than a
## constant so tests can point it at a scratch file: rebinding calls save(),
## and a suite run was otherwise rewriting the player's real settings -- which
## it did, silently, until a screenshot showed Fire bound to the right mouse
## button.
static var path: String = DEFAULT_PATH
## Actions the settings screen offers, in the order it lists them. Anything not ## Actions the settings screen offers, in the order it lists them. Anything not
## here keeps whatever `tools/setup_input_map.gd` gave it and cannot be changed ## here keeps whatever `tools/setup_input_map.gd` gave it and cannot be changed
@@ -54,7 +61,7 @@ static var _defaults_captured: bool = false
static func load_and_apply() -> void: static func load_and_apply() -> void:
_capture_defaults() _capture_defaults()
var cfg := ConfigFile.new() var cfg := ConfigFile.new()
if cfg.load(PATH) == OK: if cfg.load(path) == OK:
master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0) master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0)
sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0) sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0)
bindings.clear() bindings.clear()
@@ -81,9 +88,9 @@ static func save() -> void:
cfg.set_value("audio", "sfx", sfx_volume) cfg.set_value("audio", "sfx", sfx_volume)
for action in bindings: for action in bindings:
cfg.set_value("input", action, bindings[action]) cfg.set_value("input", action, bindings[action])
var err := cfg.save(PATH) var err := cfg.save(path)
if err != OK: if err != OK:
GameLog.warn("settings", "could not write %s (error %d)" % [PATH, err]) GameLog.warn("settings", "could not write %s (error %d)" % [path, err])
# --- Audio ------------------------------------------------------------------- # --- Audio -------------------------------------------------------------------
+5 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=10 format=3] [gd_scene load_steps=11 format=3]
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"] [ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"]
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"] [ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
@@ -9,6 +9,7 @@
[ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"] [ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"]
[ext_resource type="Script" path="res://src/ui/upgrade_screen.gd" id="8"] [ext_resource type="Script" path="res://src/ui/upgrade_screen.gd" id="8"]
[ext_resource type="Script" path="res://src/ui/settings_screen.gd" id="9"] [ext_resource type="Script" path="res://src/ui/settings_screen.gd" id="9"]
[ext_resource type="Script" path="res://src/ui/credits_screen.gd" id="10"]
[node name="Game" type="Node2D"] [node name="Game" type="Node2D"]
script = ExtResource("1") script = ExtResource("1")
@@ -37,3 +38,6 @@ script = ExtResource("8")
[node name="SettingsScreen" type="CanvasLayer" parent="."] [node name="SettingsScreen" type="CanvasLayer" parent="."]
script = ExtResource("9") script = ExtResource("9")
[node name="CreditsScreen" type="CanvasLayer" parent="."]
script = ExtResource("10")
+10
View File
@@ -11,6 +11,7 @@ const CONNECT_TIMEOUT_SEC := 8.0
var _menu: Control = null var _menu: Control = null
var _settings: CanvasLayer = null var _settings: CanvasLayer = null
var _credits: CanvasLayer = null
var _game: Node = null var _game: Node = null
var _ticks: int = 0 var _ticks: int = 0
var _connecting: bool = false var _connecting: bool = false
@@ -151,10 +152,19 @@ func _open_settings() -> void:
if _settings == null: if _settings == null:
_settings = preload("res://src/ui/settings_screen.gd").new() _settings = preload("res://src/ui/settings_screen.gd").new()
_settings.closed.connect(func() -> void: _settings.visible = false) _settings.closed.connect(func() -> void: _settings.visible = false)
_settings.credits_requested.connect(_open_credits)
add_child(_settings) add_child(_settings)
_settings.open() _settings.open()
func _open_credits() -> void:
if _credits == null:
_credits = preload("res://src/ui/credits_screen.gd").new()
_credits.closed.connect(func() -> void: _credits.visible = false)
add_child(_credits)
_credits.open()
func _clear_game() -> void: func _clear_game() -> void:
if _game != null: if _game != null:
_game.queue_free() _game.queue_free()
+1 -2
View File
@@ -24,8 +24,7 @@ var _selected: String = ""
func _ready() -> void: func _ready() -> void:
layer = 30 layer = 30
_rng.randomize() _rng.randomize()
var root := Control.new() var root := UiTheme.themed_root()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(root) add_child(root)
var scrim := ColorRect.new() var scrim := ColorRect.new()
+94
View File
@@ -0,0 +1,94 @@
extends CanvasLayer
## Attribution, in the game rather than only in the repository.
##
## CC BY 4.0 requires credit "in any reasonable manner", and a markdown file
## nobody who downloads a build will ever see does not clear that bar. Two of
## the packs the game ships are CC BY, so this screen is a licence obligation
## and not a nicety -- which is why its contents come from [Credits] and a test
## asserts nothing in there is missing from it.
signal closed
var _list: VBoxContainer
func _ready() -> void:
layer = 34
visible = false
var root := UiTheme.themed_root()
add_child(root)
var scrim := ColorRect.new()
scrim.color = Color(0.03, 0.03, 0.06, 0.95)
scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
scrim.mouse_filter = Control.MOUSE_FILTER_STOP
root.add_child(scrim)
var centre := CenterContainer.new()
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(centre)
var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(620.0, 0.0)
panel.add_theme_constant_override("separation", 8)
centre.add_child(panel)
var title := Label.new()
title.text = "CREDITS"
title.add_theme_font_size_override("font_size", 26)
panel.add_child(title)
var intro := Label.new()
intro.text = "Third-party work this game is built from. " \
+ "Credited whether or not the licence asks."
intro.add_theme_font_size_override("font_size", 12)
intro.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78))
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
intro.custom_minimum_size = Vector2(620.0, 0.0)
panel.add_child(intro)
var scroll := ScrollContainer.new()
scroll.custom_minimum_size = Vector2(620.0, 340.0)
panel.add_child(scroll)
_list = VBoxContainer.new()
_list.custom_minimum_size = Vector2(600.0, 0.0)
_list.add_theme_constant_override("separation", 12)
scroll.add_child(_list)
for entry in Credits.SOURCES:
_add_entry(entry)
var close := Button.new()
close.text = "Close"
close.custom_minimum_size = Vector2(160.0, 34.0)
close.pressed.connect(func() -> void: closed.emit())
panel.add_child(close)
func _add_entry(entry: Dictionary) -> void:
var block := VBoxContainer.new()
block.add_theme_constant_override("separation", 1)
_list.add_child(block)
var heading := Label.new()
heading.text = "%s%s" % [entry["name"], entry["author"]]
heading.add_theme_font_size_override("font_size", 16)
block.add_child(heading)
var detail := Label.new()
var licence := String(entry["licence"])
var link := Credits.licence_url(licence)
# The licence LINK, not just its name: CC BY asks for one, and a player
# reading this screen has no other way to reach the terms.
detail.text = "%s · %s%s\n%s" % [
entry["used_for"], licence,
" · " + link if not link.is_empty() else "",
entry["url"]]
detail.add_theme_font_size_override("font_size", 12)
detail.add_theme_color_override("font_color", Color(0.62, 0.67, 0.8))
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
detail.custom_minimum_size = Vector2(600.0, 0.0)
block.add_child(detail)
func open() -> void:
visible = true
+1
View File
@@ -0,0 +1 @@
uid://bo3xikvfahke5
+1 -2
View File
@@ -23,8 +23,7 @@ var _open: bool = false
func _ready() -> void: func _ready() -> void:
layer = 20 layer = 20
var root := Control.new() var root := UiTheme.themed_root()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(root) add_child(root)
# Dim the game behind the menu so it is obvious the world is still running. # Dim the game behind the menu so it is obvious the world is still running.
+73 -20
View File
@@ -6,8 +6,17 @@ const MARGIN := 24.0
const BAR_W := 260.0 const BAR_W := 260.0
const BAR_H := 16.0 const BAR_H := 16.0
## Inventory slot box, and the gap between boxes. ## Inventory slot box, and the gap between boxes.
const SLOT := 46.0 ##
## Exactly twice the 32px source sprite. A non-integer scale on pixel art with
## nearest filtering gives uneven pixel widths, which on a 1px border reads as
## a wobble along the edge of every slot.
const SLOT := 64.0
const SLOT_GAP := 8.0 const SLOT_GAP := 8.0
## Source-pixel width of the caps on the bar sprite. The bar is stretched only
## horizontally, so the ends are drawn at their own size and the middle takes
## whatever is left -- a plain stretched draw would smear a 1px border out to
## eight.
const BAR_CAP := 6.0
## How far above the bottom of the screen the inventory row sits. ## How far above the bottom of the screen the inventory row sits.
const SLOT_BOTTOM := 26.0 const SLOT_BOTTOM := 26.0
@@ -31,6 +40,7 @@ func _ready() -> void:
# and the death-message centering down with it, since all three read # and the death-message centering down with it, since all three read
# _canvas.size. set_anchors_and_offsets_preset() sets both correctly. # _canvas.size. set_anchors_and_offsets_preset() sets both correctly.
_canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
UiTheme.apply_to(_canvas)
_canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE _canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE
_canvas.draw.connect(_draw_hud) _canvas.draw.connect(_draw_hud)
add_child(_canvas) add_child(_canvas)
@@ -44,7 +54,10 @@ func _ready() -> void:
# here means "56px above the bottom anchor", which is what "-56" was meant # here means "56px above the bottom anchor", which is what "-56" was meant
# to say in the first place. # to say in the first place.
_hint.offset_left = MARGIN _hint.offset_left = MARGIN
_hint.offset_top = -56.0 # Above the inventory row, not through it. Derived from the row's own
# geometry rather than written down, because the slots grew from 46px to 64
# and a hardcoded -56 put the control hints straight across them.
_hint.offset_top = -(SLOT_BOTTOM + SLOT + 34.0)
# A real Button rather than something drawn in _draw_hud: this is the one # A real Button rather than something drawn in _draw_hud: this is the one
# HUD element the player has to actually click, and it starts disabled so # HUD element the player has to actually click, and it starts disabled so
@@ -126,13 +139,13 @@ func _draw_hud() -> void:
var origin := Vector2(MARGIN, MARGIN + 28.0) var origin := Vector2(MARGIN, MARGIN + 28.0)
_bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)), _bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)),
Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18)) Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18))
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 3.0)) _draw_xp_bar(origin + Vector2(0.0, BAR_H + 4.0))
if client.my_escaping: if client.my_escaping:
_bar(origin + Vector2(0.0, BAR_H + 8.0), client.my_escape, _bar(origin + Vector2(0.0, (BAR_H + 4.0) * 2.0), client.my_escape,
Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2)) Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2))
_canvas.draw_string(ThemeDB.fallback_font, _canvas.draw_string(ThemeDB.fallback_font,
origin + Vector2(BAR_W + 12.0, BAR_H + 8.0 + BAR_H), origin + Vector2(BAR_W + 12.0, (BAR_H + 4.0) * 2.0 + BAR_H - 3.0),
"ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0)) "ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0))
_draw_boss_bar() _draw_boss_bar()
@@ -195,15 +208,15 @@ func _draw_inventory() -> void:
var at := origin + Vector2(float(i) * (SLOT + SLOT_GAP), 0.0) var at := origin + Vector2(float(i) * (SLOT + SLOT_GAP), 0.0)
var item := Items.by_index(int(client.my_inventory[i])) var item := Items.by_index(int(client.my_inventory[i]))
var def := Items.get_def(item) var def := Items.get_def(item)
var frame_col := Color(0.55, 0.6, 0.72, 0.75) if i == held \ # The held slot uses the pack's pressed-in slot art rather than a
else Color(0.3, 0.33, 0.42, 0.6) # recoloured border, so "this is the one the key is on" reads the same
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), Color(0.07, 0.08, 0.12, 0.72)) # way every other pressed thing in the interface does.
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), frame_col, false, 1.5) var slot_art := UiTheme.texture("slot_active" if i == held else "slot")
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false)
# The slot number, because the key that uses it is the only thing the # The slot number, because the key that uses it is the only thing the
# player actually needs to know about a slot. # player actually needs to know about a slot.
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(4.0, 13.0), _canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 11, str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM)
Color(0.5, 0.55, 0.68))
if def == null: if def == null:
continue continue
var icon := Art.item_icon(item) var icon := Art.item_icon(item)
@@ -248,13 +261,12 @@ func _draw_xp_bar(at: Vector2) -> void:
var capped := level >= Progression.MAX_LEVEL var capped := level >= Progression.MAX_LEVEL
var progress := Progression.level_progress(client.my_total_xp) var progress := Progression.level_progress(client.my_total_xp)
var tint := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.0) var tint := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.0)
_canvas.draw_rect(Rect2(at, Vector2(BAR_W, 5.0)), Color(0.1, 0.12, 0.18)) _bar(at, progress, tint, Color.BLACK)
_canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 5.0)), tint)
# The number as well as the bar: "how far to the next level" is a question # The number as well as the bar: "how far to the next level" is a question
# a bar answers vaguely and a percentage answers exactly. # a bar answers vaguely and a percentage answers exactly.
var text := "MAX" if capped else "%d%% to level %d" % [ var text := "MAX" if capped else "%d%% to level %d" % [
int(floor(progress * 100.0)), level + 1] int(floor(progress * 100.0)), level + 1]
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 6.0), _canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 13.0),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, tint) text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, tint)
@@ -299,8 +311,13 @@ func _draw_boss_bar() -> void:
var w := 560.0 var w := 560.0
var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN) var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN)
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0) var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
_canvas.draw_rect(Rect2(pos, Vector2(w, BAR_H)), Color(0.12, 0.08, 0.1)) var rect := Rect2(pos, Vector2(w, BAR_H))
_canvas.draw_rect(Rect2(pos, Vector2(w * frac, BAR_H)), Color(0.95, 0.35, 0.45)) _hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inner := Rect2(rect.position + Vector2(3.0, 3.0),
Vector2((w - 6.0) * frac, BAR_H - 6.0))
if inner.size.x > 0.5:
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false,
Color(0.95, 0.35, 0.45))
var phase_index := int(b["phase"]) var phase_index := int(b["phase"])
var phase_name := "" var phase_name := ""
if phase_index < client.boss_def.phases.size(): if phase_index < client.boss_def.phases.size():
@@ -310,9 +327,45 @@ func _draw_boss_bar() -> void:
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8)) HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8))
func _bar(pos: Vector2, frac: float, fill: Color, back: Color) -> void: ## A bar in the pack's art: the frame stretched horizontally, the fill tinted.
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W, BAR_H)), back) ##
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W * clampf(frac, 0.0, 1.0), BAR_H)), fill) ## [param back] is no longer a colour -- the frame sprite is the background --
## but the signature keeps it so callers read the same and a future flat-bar
## fallback has somewhere to go.
func _bar(pos: Vector2, frac: float, fill: Color, _back: Color) -> void:
var rect := Rect2(pos, Vector2(BAR_W, BAR_H))
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inset := 3.0
var inner := Rect2(rect.position + Vector2(inset, inset),
Vector2((rect.size.x - inset * 2.0) * clampf(frac, 0.0, 1.0),
rect.size.y - inset * 2.0))
if inner.size.x > 0.5:
# The fill sprite is a flat strip, so tinting it is the whole palette --
# one texture covers health, experience, the escape channel and the
# boss.
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false, fill)
## Draw a horizontally stretchable sprite as three slices: left cap, stretched
## middle, right cap. Godot's nine-patch lives on nodes rather than on the
## immediate-mode API, and the HUD is drawn rather than built out of controls.
func _hslice(tex: Texture2D, rect: Rect2, tint: Color) -> void:
if tex == null:
return
var src := Vector2(tex.get_size())
var cap := minf(BAR_CAP, src.x * 0.5)
var draw_cap := cap * (rect.size.y / src.y)
_canvas.draw_texture_rect_region(tex,
Rect2(rect.position, Vector2(draw_cap, rect.size.y)),
Rect2(0.0, 0.0, cap, src.y), tint)
_canvas.draw_texture_rect_region(tex,
Rect2(rect.position + Vector2(draw_cap, 0.0),
Vector2(maxf(rect.size.x - draw_cap * 2.0, 0.0), rect.size.y)),
Rect2(cap, 0.0, src.x - cap * 2.0, src.y), tint)
_canvas.draw_texture_rect_region(tex,
Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0),
Vector2(draw_cap, rect.size.y)),
Rect2(src.x - cap, 0.0, cap, src.y), tint)
func flash_hit() -> void: func flash_hit() -> void:
+1
View File
@@ -24,6 +24,7 @@ func _ready() -> void:
# what actually caused the "menu stuck in the top-left corner" bug -- # what actually caused the "menu stuck in the top-left corner" bug --
# set_anchors_and_offsets_preset() sets both halves correctly in one call. # set_anchors_and_offsets_preset() sets both halves correctly in one call.
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
UiTheme.apply_to(self)
# A CenterContainer recomputes its child's centered position itself, every # A CenterContainer recomputes its child's centered position itself, every
# time the child's minimum size or the window size changes -- no manual # time the child's minimum size or the window size changes -- no manual
+7 -2
View File
@@ -6,6 +6,7 @@ extends CanvasLayer
## game's state that should change what it can do. ## game's state that should change what it can do.
signal closed signal closed
signal credits_requested
var _rows: Dictionary[String, Button] = {} var _rows: Dictionary[String, Button] = {}
var _status: Label var _status: Label
@@ -20,8 +21,7 @@ var _listening: String = ""
func _ready() -> void: func _ready() -> void:
layer = 32 layer = 32
visible = false visible = false
var root := Control.new() var root := UiTheme.themed_root()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(root) add_child(root)
var scrim := ColorRect.new() var scrim := ColorRect.new()
@@ -81,6 +81,11 @@ func _ready() -> void:
_status.text = "controls reset" _status.text = "controls reset"
refresh()) refresh())
buttons.add_child(reset) buttons.add_child(reset)
var credits := Button.new()
credits.text = "Credits"
credits.custom_minimum_size = Vector2(160.0, 34.0)
credits.pressed.connect(func() -> void: credits_requested.emit())
buttons.add_child(credits)
var close := Button.new() var close := Button.new()
close.text = "Close" close.text = "Close"
close.custom_minimum_size = Vector2(160.0, 34.0) close.custom_minimum_size = Vector2(160.0, 34.0)
+1 -2
View File
@@ -31,8 +31,7 @@ var _status: Label
func _ready() -> void: func _ready() -> void:
layer = 28 layer = 28
visible = false visible = false
var root := Control.new() var root := UiTheme.themed_root()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(root) add_child(root)
var scrim := ColorRect.new() var scrim := ColorRect.new()
+3
View File
@@ -10,6 +10,7 @@ extends Node2D
@onready var characters: CanvasLayer = $CharacterSelect @onready var characters: CanvasLayer = $CharacterSelect
@onready var upgrades: CanvasLayer = $UpgradeScreen @onready var upgrades: CanvasLayer = $UpgradeScreen
@onready var settings: CanvasLayer = $SettingsScreen @onready var settings: CanvasLayer = $SettingsScreen
@onready var credits: CanvasLayer = $CreditsScreen
var _bound: ClientRuntime = null var _bound: ClientRuntime = null
## Opened deliberately from the menu, as opposed to forced open by having no ## Opened deliberately from the menu, as opposed to forced open by having no
@@ -37,6 +38,8 @@ func _ready() -> void:
upgrades.closed.connect(func() -> void: _upgrades_open = false) upgrades.closed.connect(func() -> void: _upgrades_open = false)
menu.settings_requested.connect(func() -> void: settings.open()) menu.settings_requested.connect(func() -> void: settings.open())
settings.closed.connect(func() -> void: settings.visible = false) settings.closed.connect(func() -> void: settings.visible = false)
settings.credits_requested.connect(func() -> void: credits.open())
credits.closed.connect(func() -> void: credits.visible = false)
upgrades.choose_requested.connect(func(i: int) -> void: Net.choose_upgrade(i)) upgrades.choose_requested.connect(func(i: int) -> void: Net.choose_upgrade(i))
+116
View File
@@ -0,0 +1,116 @@
class_name UiTheme
extends RefCounted
## The game's control theme, built in code from Crusenho's UI pack.
##
## Built rather than authored as a .tres for the same reason [Content] is code:
## it is a readable diff, there are no resource UIDs churning in version
## control, and a test can build it without the editor.
##
## Applied to each screen's root Control ([method apply_to]). It cannot be
## applied once to the Window instead: Godot's theme lookup walks a Control's
## ancestors, and the chain BREAKS at the first plain Node -- which for every
## screen here is `main.gd` or a CanvasLayer. Setting `window.theme` looked like
## it worked and changed nothing.
const DIR := "res://assets/sprites/ui/"
## Every texture the theme needs. Named so a test can assert they all load: a
## missing file gives a null texture and a silently unstyled control, which
## looks like the theme never being applied at all.
const TEXTURES := {
"button": DIR + "Button01a_1.png",
"button_hover": DIR + "Button01a_2.png",
"button_pressed": DIR + "Button01a_3.png",
"button_disabled": DIR + "Button01a_4.png",
"panel": DIR + "Frame01a.png",
"panel_accent": DIR + "Frame02a.png",
"slot": DIR + "FrameSlot01a.png",
"slot_active": DIR + "FrameSlot01b.png",
"bar": DIR + "Bar01a.png",
"bar_fill": DIR + "BarFill01f.png",
"field": DIR + "InputField01a.png",
"banner": DIR + "Banner01a.png",
}
## 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.
const INK := Color(0.12, 0.11, 0.13)
const INK_DIM := Color(0.42, 0.40, 0.44)
static var _theme: Theme = null
static func texture(key: String) -> Texture2D:
return load(TEXTURES[key]) as Texture2D
## One shared instance. Themes are pure data and every control only reads them,
## so building a second would be waste rather than isolation.
static func get_theme() -> Theme:
if _theme != null:
return _theme
var t := Theme.new()
t.default_font_size = 14
t.set_stylebox("normal", "Button", _box("button", 8, 12, 7))
t.set_stylebox("hover", "Button", _box("button_hover", 8, 12, 7))
t.set_stylebox("pressed", "Button", _box("button_pressed", 8, 12, 7))
t.set_stylebox("disabled", "Button", _box("button_disabled", 8, 12, 7))
# Focus deliberately reuses the hover art rather than adding an outline:
# the pack has no focus state, and an invented one would be the only thing
# on screen that is not from the set.
t.set_stylebox("focus", "Button", _box("button_hover", 8, 12, 7))
t.set_color("font_color", "Button", INK)
t.set_color("font_hover_color", "Button", INK)
t.set_color("font_pressed_color", "Button", INK)
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))
t.set_stylebox("normal", "LineEdit", _box("field", 8, 10, 6))
t.set_stylebox("focus", "LineEdit", _box("field", 8, 10, 6))
t.set_color("font_color", "LineEdit", INK)
t.set_color("font_placeholder_color", "LineEdit", INK_DIM)
t.set_color("caret_color", "LineEdit", INK)
_theme = t
return _theme
## Give [param root] the theme. Everything under it inherits, so one call per
## screen covers that screen.
##
## Per-screen rather than once on the Window, because theme inheritance only
## travels Control-to-Control: a Control whose parent is a plain Node or a
## CanvasLayer resolves against the default theme however the Window is set.
## Every screen in this game hangs off one or the other.
static func apply_to(root: Control) -> void:
if root != null:
root.theme = get_theme()
## A full-rect Control with the theme already on it -- the root every screen
## built in code starts from. Using this rather than a bare Control is what
## stops a new screen quietly rendering unstyled.
static func themed_root() -> Control:
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
apply_to(root)
return root
## 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:
var sb := StyleBoxTexture.new()
sb.texture = texture(key)
for side in [SIDE_LEFT, SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM]:
sb.set_texture_margin(side, float(margin))
sb.set_content_margin(SIDE_LEFT, float(pad_x))
sb.set_content_margin(SIDE_RIGHT, float(pad_x))
sb.set_content_margin(SIDE_TOP, float(pad_y))
sb.set_content_margin(SIDE_BOTTOM, float(pad_y))
return sb
+1
View File
@@ -0,0 +1 @@
uid://ddaj0c8xfjitx
+76
View File
@@ -0,0 +1,76 @@
extends GutTest
## Attribution. Two of the packs this game ships are CC BY 4.0, which requires
## credit -- so these are licence-compliance tests, not tidiness ones.
func test_every_source_is_completely_described() -> void:
for entry in Credits.SOURCES:
for field in ["name", "author", "licence", "url", "used_for"]:
assert_false(String(entry[field]).strip_edges().is_empty(),
"%s has no %s" % [entry.get("name", "?"), field])
assert_true(String(entry["url"]).begins_with("http"),
"%s has no usable link" % entry["name"])
## The whole point of the flag: it marks the entries a licence obliges us to
## show, as opposed to the ones credited because the work deserves it.
func test_the_cc_by_packs_are_marked_as_required() -> void:
var required := Credits.required()
assert_gt(required.size(), 0)
for entry in Credits.SOURCES:
var is_cc_by := String(entry["licence"]).begins_with("CC BY")
assert_eq(bool(entry["required"]), is_cc_by,
"%s is %s but marked required=%s" % [
entry["name"], entry["licence"], entry["required"]])
## CC BY asks for a link to the licence itself, not only to the work. A player
## reading the in-game screen has no other way to reach the terms.
func test_every_creative_commons_licence_links_to_its_terms() -> void:
for entry in Credits.SOURCES:
var licence := String(entry["licence"])
if not licence.begins_with("CC"):
continue
assert_true(Credits.licence_url(licence).begins_with(
"https://creativecommons.org/"),
"%s has no link to its terms" % licence)
## The repository file and the in-game screen have to say the same thing. They
## are maintained separately, so this is what stops one of them going stale --
## and a stale attribution is a licence problem, not a documentation one.
func test_the_credits_file_names_every_source() -> void:
var f := FileAccess.open("res://CREDITS.md", FileAccess.READ)
assert_not_null(f, "CREDITS.md should be readable")
var text := f.get_as_text()
f.close()
for entry in Credits.SOURCES:
assert_true(text.contains(String(entry["name"])),
"CREDITS.md never mentions %s" % entry["name"])
assert_true(text.contains(String(entry["author"])),
"CREDITS.md never credits %s" % entry["author"])
assert_true(text.contains(String(entry["url"])),
"CREDITS.md has no link for %s" % entry["name"])
func test_the_asset_document_covers_every_source_too() -> void:
var f := FileAccess.open("res://docs/ASSETS.md", FileAccess.READ)
assert_not_null(f)
var text := f.get_as_text()
f.close()
for entry in Credits.SOURCES:
assert_true(text.contains(String(entry["url"])),
"docs/ASSETS.md has no entry for %s" % entry["name"])
## Every pack in the repository's own asset directories must be accounted for.
## Committing art without recording where it came from is how a project ends up
## unable to say whether it may ship.
func test_the_committed_ui_art_is_attributed() -> void:
assert_true(DirAccess.dir_exists_absolute("res://assets/sprites/ui"),
"the UI art should be committed")
var names := ""
for entry in Credits.SOURCES:
names += String(entry["name"])
assert_true(names.contains("Complete UI Essential Pack"),
"the UI pack has to be credited before its art is shipped")
+1
View File
@@ -0,0 +1 @@
uid://tb74xt0r3x5s
+28
View File
@@ -8,6 +8,7 @@ const SCRATCH := "user://test_settings_%d.cfg"
var _saved_bindings: Dictionary[String, Dictionary] = {} var _saved_bindings: Dictionary[String, Dictionary] = {}
var _saved_master: float = 0.0 var _saved_master: float = 0.0
var _saved_sfx: float = 0.0 var _saved_sfx: float = 0.0
var _scratch: String = ""
func before_each() -> void: func before_each() -> void:
@@ -16,6 +17,12 @@ func before_each() -> void:
_saved_bindings = Settings.bindings.duplicate() _saved_bindings = Settings.bindings.duplicate()
_saved_master = Settings.master_volume _saved_master = Settings.master_volume
_saved_sfx = Settings.sfx_volume _saved_sfx = Settings.sfx_volume
# And rebinding SAVES. Without redirecting the file, running the suite
# rewrote the player's own settings -- it really did, and it took a
# screenshot of the settings screen showing Fire on the right mouse button
# to notice.
_scratch = SCRATCH % randi()
Settings.path = _scratch
func after_each() -> void: func after_each() -> void:
@@ -23,6 +30,8 @@ func after_each() -> void:
Settings.master_volume = _saved_master Settings.master_volume = _saved_master
Settings.sfx_volume = _saved_sfx Settings.sfx_volume = _saved_sfx
Settings.apply_input() Settings.apply_input()
Settings.path = Settings.DEFAULT_PATH
DirAccess.remove_absolute(ProjectSettings.globalize_path(_scratch))
func _key_event(code: Key) -> InputEventKey: func _key_event(code: Key) -> InputEventKey:
@@ -226,3 +235,22 @@ func test_a_settings_file_missing_keys_loads_without_complaint() -> void:
# default, which is what made this log an engine error per missing action. # default, which is what made this log an engine error per missing action.
assert_false(loaded.has_section_key("input", "move_up")) assert_false(loaded.has_section_key("input", "move_up"))
DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
## The redirect above is a test fixture. If it were ever left in place -- or the
## default changed to something under a scratch directory -- players would
## silently stop keeping their settings.
func test_the_default_path_is_the_players_own_file() -> void:
assert_eq(Settings.DEFAULT_PATH, "user://settings.cfg")
assert_true(Settings.DEFAULT_PATH.begins_with("user://"))
## Saving has to actually reach the file the path points at, or the redirect
## above would hide a broken save rather than isolate a working one.
func test_saving_writes_to_the_configured_path() -> void:
Settings.master_volume = 0.33
Settings.save()
assert_true(FileAccess.file_exists(_scratch), "nothing was written")
var cfg := ConfigFile.new()
assert_eq(cfg.load(_scratch), OK)
assert_almost_eq(float(cfg.get_value("audio", "master")), 0.33, 0.001)
+140
View File
@@ -0,0 +1,140 @@
extends GutTest
## The control theme. A missing texture gives a null and a silently unstyled
## control, which looks exactly like the theme never having been applied -- so
## every path is checked rather than assumed.
func test_every_declared_texture_loads() -> void:
for key in UiTheme.TEXTURES:
var tex := UiTheme.texture(key)
assert_not_null(tex, "%s (%s) did not load" % [key, UiTheme.TEXTURES[key]])
assert_gt(tex.get_width(), 0)
assert_gt(tex.get_height(), 0)
func test_the_theme_styles_the_controls_the_game_actually_builds() -> void:
var t := UiTheme.get_theme()
for state in ["normal", "hover", "pressed", "disabled", "focus"]:
assert_true(t.has_stylebox(state, "Button"), "Button has no %s style" % state)
assert_true(t.has_stylebox("panel", "Panel"))
assert_true(t.has_stylebox("panel", "PanelContainer"))
assert_true(t.has_stylebox("normal", "LineEdit"))
## The pack's surfaces are cream with a black outline, so text on them has to be
## dark. Everything the game draws over the world stays light; only controls
## sitting on this art flip.
func test_text_on_the_packs_surfaces_is_dark() -> void:
var t := UiTheme.get_theme()
for key in ["font_color", "font_hover_color", "font_pressed_color"]:
var c: Color = t.get_color(key, "Button")
assert_lt(c.get_luminance(), 0.35, "%s is too light for cream art" % key)
assert_lt((t.get_color("font_color", "LineEdit") as Color).get_luminance(), 0.35)
## Nine-patch margins are what stop a 1px border smearing across a stretched
## button. Zero margins would render as a plain stretched texture.
func test_button_styles_are_nine_patched() -> void:
var t := UiTheme.get_theme()
for state in ["normal", "hover", "pressed", "disabled"]:
var sb := t.get_stylebox(state, "Button") as StyleBoxTexture
assert_not_null(sb)
for side in [SIDE_LEFT, SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM]:
assert_gt(sb.get_texture_margin(side), 0.0,
"%s has no stretch margin on side %d" % [state, side])
## The corner size cannot exceed half the sprite, or the two caps overlap and
## the middle has negative width.
func test_stretch_margins_fit_inside_their_sprites() -> void:
var t := UiTheme.get_theme()
for entry in [["normal", "Button"], ["panel", "Panel"], ["normal", "LineEdit"]]:
var sb := t.get_stylebox(entry[0], entry[1]) as StyleBoxTexture
var size := Vector2(sb.texture.get_size())
assert_lte(sb.get_texture_margin(SIDE_LEFT) + sb.get_texture_margin(SIDE_RIGHT),
size.x, "%s margins exceed its width" % entry[1])
assert_lte(sb.get_texture_margin(SIDE_TOP) + sb.get_texture_margin(SIDE_BOTTOM),
size.y, "%s margins exceed its height" % entry[1])
## Built once and shared. Themes are read-only data, so a second instance would
## be waste rather than isolation.
func test_the_theme_is_built_once() -> void:
assert_eq(UiTheme.get_theme(), UiTheme.get_theme())
## The four button states have to be four different pictures, or the interface
## gives no feedback and the pack's state art is going unused.
func test_the_button_states_are_visually_distinct() -> void:
var seen := {}
for key in ["button", "button_hover", "button_pressed", "button_disabled"]:
seen[UiTheme.TEXTURES[key]] = true
assert_eq(seen.size(), 4, "two button states share a texture")
## The inventory draws slots at exactly twice the source size. A non-integer
## scale on pixel art with nearest filtering gives uneven pixel widths, which on
## a 1px border reads as a wobble along every edge.
func test_the_inventory_slot_is_drawn_at_a_whole_multiple() -> void:
var slot := UiTheme.texture("slot")
var scale := HUD_SLOT / float(slot.get_width())
assert_eq(scale, floorf(scale), "slot art is scaled by %f" % scale)
assert_eq(slot.get_width(), slot.get_height(), "the slot should be square")
const HUD_SLOT := 64.0
## A Control resolves its theme by walking up the parent chain, which is why
## one assignment near the root covers screens that share no parent. Checked on
## the mechanism rather than on a Window, so it runs headless.
func test_the_theme_reaches_controls_through_their_ancestors() -> void:
var root: Control = autofree(Control.new())
root.theme = UiTheme.get_theme()
var middle := Control.new()
root.add_child(middle)
var button := Button.new()
middle.add_child(button)
assert_eq(button.get_theme_stylebox("normal", "Button"),
UiTheme.get_theme().get_stylebox("normal", "Button"),
"a button two levels down should still find the theme")
assert_eq(button.get_theme_color("font_color", "Button"), UiTheme.INK)
func test_applying_to_nothing_is_harmless() -> void:
UiTheme.apply_to(null)
assert_true(true, "a null root must not be a crash")
func test_a_themed_root_fills_its_parent_and_carries_the_theme() -> void:
var root: Control = autofree(UiTheme.themed_root())
assert_eq(root.theme, UiTheme.get_theme())
assert_eq(root.anchor_right, 1.0)
assert_eq(root.anchor_bottom, 1.0)
## Every screen in the game is built in code and hangs off either a plain Node
## or a CanvasLayer, and theme inheritance travels Control-to-Control ONLY --
## the chain breaks at the first non-Control parent. Setting the theme once on
## the Window therefore looked like it worked and styled nothing, which is a
## mistake that is invisible until somebody looks at the screen. So: every
## screen is instantiated here and asked what its buttons actually resolve.
func test_every_screen_actually_gets_the_theme() -> void:
var screens := {
"HUD": "res://src/ui/hud.gd",
"GameMenu": "res://src/ui/game_menu.gd",
"CharacterSelect": "res://src/ui/character_select.gd",
"UpgradeScreen": "res://src/ui/upgrade_screen.gd",
"SettingsScreen": "res://src/ui/settings_screen.gd",
"CreditsScreen": "res://src/ui/credits_screen.gd",
"MainMenu": "res://src/ui/main_menu.gd",
}
var wanted := UiTheme.get_theme().get_stylebox("normal", "Button")
for name in screens:
var node: Node = (load(screens[name]) as Script).new()
add_child_autofree(node)
var buttons := node.find_children("*", "Button", true, false)
assert_gt(buttons.size(), 0, "%s has no button to check" % name)
for b: Button in buttons:
assert_eq(b.get_theme_stylebox("normal", "Button"), wanted,
"%s: '%s' is not themed" % [name, b.text])
+1
View File
@@ -0,0 +1 @@
uid://ddwfq3jpwgfep
+86
View File
@@ -0,0 +1,86 @@
extends Node
## Boot the client windowed, capture a few screens, and quit.
##
## SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn
##
## This exists because UI is the one part of the game the rest of the tooling
## cannot check. `check.sh` proves it parses, the suite proves the theme
## resolves, the smoke test proves nothing errors -- and all three passed
## while the entire interface rendered unstyled, because Godot's theme
## inheritance breaks at the first non-Control parent and nothing had ever
## looked at the screen.
##
## Needs a real display, so it is not part of any automated gate. Run it after
## touching anything under src/ui/ and open the files.
const GAME := preload("res://src/game.tscn")
## Frame numbers, not seconds: the point is a deterministic sequence, and a
## windowed client at 60fps gets there in about four seconds.
const AT_MENU := 30
const ENTER_GAME := 34
const AT_GAME := 200
const OPEN_MENU := 210
const AT_INGAME_MENU := 240
const OPEN_SETTINGS := 250
const AT_SETTINGS := 280
const OPEN_CREDITS := 290
const AT_CREDITS := 320
const DONE := 330
var _frame: int = 0
var _game: Node = null
var _menu: Control = null
var _dir: String = "user://"
func _ready() -> void:
GameOpts.parse()
Engine.physics_ticks_per_second = SimConfig.TICK_RATE
Settings.load_and_apply()
var from_env := OS.get_environment("SHOT_DIR")
if not from_env.is_empty():
_dir = from_env
get_window().size = Vector2i(1280, 720)
_menu = preload("res://src/ui/main_menu.gd").new()
add_child(_menu)
func _process(_delta: float) -> void:
_frame += 1
match _frame:
AT_MENU:
_grab("menu")
ENTER_GAME:
_menu.queue_free()
Net.host(27455)
Net.start_local_client()
_game = GAME.instantiate()
add_child(_game)
AT_GAME:
_grab("game")
OPEN_MENU:
_game.get_node("GameMenu").open()
AT_INGAME_MENU:
_grab("menu_ingame")
OPEN_SETTINGS:
_game.get_node("GameMenu").close()
_game.get_node("SettingsScreen").open()
AT_SETTINGS:
_grab("settings")
OPEN_CREDITS:
_game.get_node("SettingsScreen").visible = false
_game.get_node("CreditsScreen").open()
AT_CREDITS:
_grab("credits")
DONE:
Net.shutdown()
get_tree().quit(0)
func _grab(shot_name: String) -> void:
var path := "%s/shot_%s.png" % [_dir, shot_name]
var err := get_viewport().get_texture().get_image().save_png(path)
if err != OK:
printerr("could not write %s (error %d)" % [path, err])
return
print("saved ", path)
+1
View File
@@ -0,0 +1 @@
uid://bnuqy4w5whjce
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/screenshot.gd" id="1"]
[node name="Screenshot" type="Node"]
script = ExtResource("1")