diff --git a/CLAUDE.md b/CLAUDE.md index 4909bf8..a417da0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 . --script tools/bench.gd # sim cost per tick 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 @@ -105,6 +106,8 @@ and `tests/integration/test_replica_parity.gd` pin this down. | `src/instances/` | Lobby hub and dungeon runs. | | `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/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. | | `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 anchor-relative ones. This combination silently broke the main menu and 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 in `src/main.gd` instead of trusted to `project.godot`. diff --git a/CREDITS.md b/CREDITS.md index 1bbd0b0..41e7786 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,8 +1,14 @@ # Credits 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 -here because the work deserves it. +regardless of whether its licence requires it. Some of them do not require 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 @@ -11,6 +17,12 @@ Terrain, player, enemies and boss sprites. [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) — public domain. *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) All sound effects. Converted to 16-bit / 44.1 kHz mono and trimmed. [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). diff --git a/assets/README.md b/assets/README.md index ec19961..18fa410 100644 --- a/assets/README.md +++ b/assets/README.md @@ -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 loads stay reviewable. -Nothing is here yet — the game currently draws itself from primitives and one -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). +What is here: + +| 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. diff --git a/assets/sprites/ui/Banner01a.png b/assets/sprites/ui/Banner01a.png new file mode 100644 index 0000000..e168334 Binary files /dev/null and b/assets/sprites/ui/Banner01a.png differ diff --git a/assets/sprites/ui/Banner01a.png.import b/assets/sprites/ui/Banner01a.png.import new file mode 100644 index 0000000..2027173 --- /dev/null +++ b/assets/sprites/ui/Banner01a.png.import @@ -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 diff --git a/assets/sprites/ui/Bar01a.png b/assets/sprites/ui/Bar01a.png new file mode 100644 index 0000000..544bc84 Binary files /dev/null and b/assets/sprites/ui/Bar01a.png differ diff --git a/assets/sprites/ui/Bar01a.png.import b/assets/sprites/ui/Bar01a.png.import new file mode 100644 index 0000000..ce84653 --- /dev/null +++ b/assets/sprites/ui/Bar01a.png.import @@ -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 diff --git a/assets/sprites/ui/BarFill01f.png b/assets/sprites/ui/BarFill01f.png new file mode 100644 index 0000000..c2c08ae Binary files /dev/null and b/assets/sprites/ui/BarFill01f.png differ diff --git a/assets/sprites/ui/BarFill01f.png.import b/assets/sprites/ui/BarFill01f.png.import new file mode 100644 index 0000000..5b4a42b --- /dev/null +++ b/assets/sprites/ui/BarFill01f.png.import @@ -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 diff --git a/assets/sprites/ui/Button01a_1.png b/assets/sprites/ui/Button01a_1.png new file mode 100644 index 0000000..1bb31b8 Binary files /dev/null and b/assets/sprites/ui/Button01a_1.png differ diff --git a/assets/sprites/ui/Button01a_1.png.import b/assets/sprites/ui/Button01a_1.png.import new file mode 100644 index 0000000..f7b32d8 --- /dev/null +++ b/assets/sprites/ui/Button01a_1.png.import @@ -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 diff --git a/assets/sprites/ui/Button01a_2.png b/assets/sprites/ui/Button01a_2.png new file mode 100644 index 0000000..fd62ab2 Binary files /dev/null and b/assets/sprites/ui/Button01a_2.png differ diff --git a/assets/sprites/ui/Button01a_2.png.import b/assets/sprites/ui/Button01a_2.png.import new file mode 100644 index 0000000..e726125 --- /dev/null +++ b/assets/sprites/ui/Button01a_2.png.import @@ -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 diff --git a/assets/sprites/ui/Button01a_3.png b/assets/sprites/ui/Button01a_3.png new file mode 100644 index 0000000..14d6589 Binary files /dev/null and b/assets/sprites/ui/Button01a_3.png differ diff --git a/assets/sprites/ui/Button01a_3.png.import b/assets/sprites/ui/Button01a_3.png.import new file mode 100644 index 0000000..1ad8639 --- /dev/null +++ b/assets/sprites/ui/Button01a_3.png.import @@ -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 diff --git a/assets/sprites/ui/Button01a_4.png b/assets/sprites/ui/Button01a_4.png new file mode 100644 index 0000000..bb9ca2c Binary files /dev/null and b/assets/sprites/ui/Button01a_4.png differ diff --git a/assets/sprites/ui/Button01a_4.png.import b/assets/sprites/ui/Button01a_4.png.import new file mode 100644 index 0000000..2cfc35b --- /dev/null +++ b/assets/sprites/ui/Button01a_4.png.import @@ -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 diff --git a/assets/sprites/ui/Frame01a.png b/assets/sprites/ui/Frame01a.png new file mode 100644 index 0000000..b8cc060 Binary files /dev/null and b/assets/sprites/ui/Frame01a.png differ diff --git a/assets/sprites/ui/Frame01a.png.import b/assets/sprites/ui/Frame01a.png.import new file mode 100644 index 0000000..5309101 --- /dev/null +++ b/assets/sprites/ui/Frame01a.png.import @@ -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 diff --git a/assets/sprites/ui/Frame02a.png b/assets/sprites/ui/Frame02a.png new file mode 100644 index 0000000..e273c79 Binary files /dev/null and b/assets/sprites/ui/Frame02a.png differ diff --git a/assets/sprites/ui/Frame02a.png.import b/assets/sprites/ui/Frame02a.png.import new file mode 100644 index 0000000..3820cb2 --- /dev/null +++ b/assets/sprites/ui/Frame02a.png.import @@ -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 diff --git a/assets/sprites/ui/FrameSlot01a.png b/assets/sprites/ui/FrameSlot01a.png new file mode 100644 index 0000000..3774d38 Binary files /dev/null and b/assets/sprites/ui/FrameSlot01a.png differ diff --git a/assets/sprites/ui/FrameSlot01a.png.import b/assets/sprites/ui/FrameSlot01a.png.import new file mode 100644 index 0000000..3a1a440 --- /dev/null +++ b/assets/sprites/ui/FrameSlot01a.png.import @@ -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 diff --git a/assets/sprites/ui/FrameSlot01b.png b/assets/sprites/ui/FrameSlot01b.png new file mode 100644 index 0000000..dde41de Binary files /dev/null and b/assets/sprites/ui/FrameSlot01b.png differ diff --git a/assets/sprites/ui/FrameSlot01b.png.import b/assets/sprites/ui/FrameSlot01b.png.import new file mode 100644 index 0000000..a37573d --- /dev/null +++ b/assets/sprites/ui/FrameSlot01b.png.import @@ -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 diff --git a/assets/sprites/ui/InputField01a.png b/assets/sprites/ui/InputField01a.png new file mode 100644 index 0000000..c3327e1 Binary files /dev/null and b/assets/sprites/ui/InputField01a.png differ diff --git a/assets/sprites/ui/InputField01a.png.import b/assets/sprites/ui/InputField01a.png.import new file mode 100644 index 0000000..7737d59 --- /dev/null +++ b/assets/sprites/ui/InputField01a.png.import @@ -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 diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 8722de1..314315d 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -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 `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 -sheets and four sounds. Sprite rects and sound paths are all declared in one +What is in the game today is three sprite sheets, four sounds and twelve small +UI sprites. Sprite rects and sound paths are all declared in one place, [src/view/art.gd](../src/view/art.gd), and `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. ## Licence findings -Checked against each pack's itch.io page. **Two of the four forbid -redistribution**, which is what publishing a repository containing them would -be — so they are not in the repository. +Checked against each pack's itch.io page, and against the licence file the pack +itself ships where there is one. **Two of the five forbid redistribution**, +which is what publishing a repository containing them would be — so they are not +in the repository. | 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` | | [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 | | [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 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 -1. **Helton Yan's CC BY 4.0 requires attribution**, and it is not satisfied by - a file in the source tree once the game ships. Players need to be able to - see it. [CREDITS.md](../CREDITS.md) records it for now; an in-game credits - screen is a todo. +1. **Two packs are CC BY 4.0 and require attribution** — Helton Yan's audio and + Crusenho's UI art. A file in the source tree does not satisfy that once the + game ships, because a player who downloaded a build never sees it. **Done:** + 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 full tier *does* fix that — confirmed verbatim from the FX pack's paid licence: "You may use these assets in personal, commercial or diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 571c3db..2271e7b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,13 +20,17 @@ What "everything passes" currently means. Numbers move; the shape does not. | Gate | Covers | Runtime | | --- | --- | --- | | `tools/check.sh` | every script parses and type-checks | ~5s | -| `tools/test.sh` | 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 | | `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_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 | +`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 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 @@ -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 | | Directional sprites, hit flashes, screen shake | todo | | | Rebindable controls | done | [src/ui/settings_screen.gd](../src/ui/settings_screen.gd), [src/core/settings.gd](../src/core/settings.gd) | +| Themed controls from a UI pack | done | [src/view/ui_theme.gd](../src/view/ui_theme.gd), art in `assets/sprites/ui/` | +| Textured HUD bars and inventory slots | done | `HUD._bar`, `HUD._draw_inventory` | | Audio buses and a volume setting | done | `Settings`, an SFX bus created at runtime, sliders in the settings screen | -| **In-game credits screen** | **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). | ## Stage 2 — Characters, persistence, levels · *done* diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index f15e40e..8c1cad3 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -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 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. + +### 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. diff --git a/src/core/credits.gd b/src/core/credits.gd new file mode 100644 index 0000000..f50148c --- /dev/null +++ b/src/core/credits.gd @@ -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, "")) diff --git a/src/core/credits.gd.uid b/src/core/credits.gd.uid new file mode 100644 index 0000000..374b6a4 --- /dev/null +++ b/src/core/credits.gd.uid @@ -0,0 +1 @@ +uid://drpadmmsrvoax diff --git a/src/core/settings.gd b/src/core/settings.gd index 406d84e..378b249 100644 --- a/src/core/settings.gd +++ b/src/core/settings.gd @@ -10,7 +10,14 @@ extends RefCounted ## tool has no main loop and cannot resolve autoload names, and the settings ## 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 ## 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: _capture_defaults() 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) sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0) bindings.clear() @@ -81,9 +88,9 @@ static func save() -> void: cfg.set_value("audio", "sfx", sfx_volume) for action in bindings: cfg.set_value("input", action, bindings[action]) - var err := cfg.save(PATH) + var err := cfg.save(path) 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 ------------------------------------------------------------------- diff --git a/src/game.tscn b/src/game.tscn index 594c40e..a801f79 100644 --- a/src/game.tscn +++ b/src/game.tscn @@ -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/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/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/credits_screen.gd" id="10"] [node name="Game" type="Node2D"] script = ExtResource("1") @@ -37,3 +38,6 @@ script = ExtResource("8") [node name="SettingsScreen" type="CanvasLayer" parent="."] script = ExtResource("9") + +[node name="CreditsScreen" type="CanvasLayer" parent="."] +script = ExtResource("10") diff --git a/src/main.gd b/src/main.gd index 9be0ae5..69f654f 100644 --- a/src/main.gd +++ b/src/main.gd @@ -11,6 +11,7 @@ const CONNECT_TIMEOUT_SEC := 8.0 var _menu: Control = null var _settings: CanvasLayer = null +var _credits: CanvasLayer = null var _game: Node = null var _ticks: int = 0 var _connecting: bool = false @@ -151,10 +152,19 @@ func _open_settings() -> void: if _settings == null: _settings = preload("res://src/ui/settings_screen.gd").new() _settings.closed.connect(func() -> void: _settings.visible = false) + _settings.credits_requested.connect(_open_credits) add_child(_settings) _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: if _game != null: _game.queue_free() diff --git a/src/ui/character_select.gd b/src/ui/character_select.gd index a3a3a6a..d7c828f 100644 --- a/src/ui/character_select.gd +++ b/src/ui/character_select.gd @@ -24,8 +24,7 @@ var _selected: String = "" func _ready() -> void: layer = 30 _rng.randomize() - var root := Control.new() - root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var root := UiTheme.themed_root() add_child(root) var scrim := ColorRect.new() diff --git a/src/ui/credits_screen.gd b/src/ui/credits_screen.gd new file mode 100644 index 0000000..988e15d --- /dev/null +++ b/src/ui/credits_screen.gd @@ -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 diff --git a/src/ui/credits_screen.gd.uid b/src/ui/credits_screen.gd.uid new file mode 100644 index 0000000..6b88f64 --- /dev/null +++ b/src/ui/credits_screen.gd.uid @@ -0,0 +1 @@ +uid://bo3xikvfahke5 diff --git a/src/ui/game_menu.gd b/src/ui/game_menu.gd index 0470aa4..5a25ba5 100644 --- a/src/ui/game_menu.gd +++ b/src/ui/game_menu.gd @@ -23,8 +23,7 @@ var _open: bool = false func _ready() -> void: layer = 20 - var root := Control.new() - root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var root := UiTheme.themed_root() add_child(root) # Dim the game behind the menu so it is obvious the world is still running. diff --git a/src/ui/hud.gd b/src/ui/hud.gd index fcd4d6d..da9d45b 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -6,8 +6,17 @@ const MARGIN := 24.0 const BAR_W := 260.0 const BAR_H := 16.0 ## 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 +## 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. const SLOT_BOTTOM := 26.0 @@ -31,6 +40,7 @@ func _ready() -> void: # and the death-message centering down with it, since all three read # _canvas.size. set_anchors_and_offsets_preset() sets both correctly. _canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + UiTheme.apply_to(_canvas) _canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE _canvas.draw.connect(_draw_hud) add_child(_canvas) @@ -44,7 +54,10 @@ func _ready() -> void: # here means "56px above the bottom anchor", which is what "-56" was meant # to say in the first place. _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 # 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) _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)) - _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: - _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)) _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)) _draw_boss_bar() @@ -195,15 +208,15 @@ func _draw_inventory() -> void: var at := origin + Vector2(float(i) * (SLOT + SLOT_GAP), 0.0) var item := Items.by_index(int(client.my_inventory[i])) var def := Items.get_def(item) - var frame_col := Color(0.55, 0.6, 0.72, 0.75) if i == held \ - else Color(0.3, 0.33, 0.42, 0.6) - _canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), Color(0.07, 0.08, 0.12, 0.72)) - _canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), frame_col, false, 1.5) + # The held slot uses the pack's pressed-in slot art rather than a + # recoloured border, so "this is the one the key is on" reads the same + # way every other pressed thing in the interface does. + 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 # player actually needs to know about a slot. - _canvas.draw_string(ThemeDB.fallback_font, at + Vector2(4.0, 13.0), - str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 11, - Color(0.5, 0.55, 0.68)) + _canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0), + str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM) if def == null: continue var icon := Art.item_icon(item) @@ -248,13 +261,12 @@ func _draw_xp_bar(at: Vector2) -> void: var capped := level >= Progression.MAX_LEVEL 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) - _canvas.draw_rect(Rect2(at, Vector2(BAR_W, 5.0)), Color(0.1, 0.12, 0.18)) - _canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 5.0)), tint) + _bar(at, progress, tint, Color.BLACK) # 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. var text := "MAX" if capped else "%d%% to level %d" % [ 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) @@ -299,8 +311,13 @@ func _draw_boss_bar() -> void: var w := 560.0 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) - _canvas.draw_rect(Rect2(pos, Vector2(w, BAR_H)), Color(0.12, 0.08, 0.1)) - _canvas.draw_rect(Rect2(pos, Vector2(w * frac, BAR_H)), Color(0.95, 0.35, 0.45)) + var rect := Rect2(pos, Vector2(w, BAR_H)) + _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_name := "" 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)) -func _bar(pos: Vector2, frac: float, fill: Color, back: Color) -> void: - _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) +## A bar in the pack's art: the frame stretched horizontally, the fill tinted. +## +## [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: diff --git a/src/ui/main_menu.gd b/src/ui/main_menu.gd index 0c75090..3707fd9 100644 --- a/src/ui/main_menu.gd +++ b/src/ui/main_menu.gd @@ -24,6 +24,7 @@ func _ready() -> void: # 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(Control.PRESET_FULL_RECT) + UiTheme.apply_to(self) # A CenterContainer recomputes its child's centered position itself, every # time the child's minimum size or the window size changes -- no manual diff --git a/src/ui/settings_screen.gd b/src/ui/settings_screen.gd index cf6d40b..31abb08 100644 --- a/src/ui/settings_screen.gd +++ b/src/ui/settings_screen.gd @@ -6,6 +6,7 @@ extends CanvasLayer ## game's state that should change what it can do. signal closed +signal credits_requested var _rows: Dictionary[String, Button] = {} var _status: Label @@ -20,8 +21,7 @@ var _listening: String = "" func _ready() -> void: layer = 32 visible = false - var root := Control.new() - root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var root := UiTheme.themed_root() add_child(root) var scrim := ColorRect.new() @@ -81,6 +81,11 @@ func _ready() -> void: _status.text = "controls reset" refresh()) 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() close.text = "Close" close.custom_minimum_size = Vector2(160.0, 34.0) diff --git a/src/ui/upgrade_screen.gd b/src/ui/upgrade_screen.gd index a9a5de0..c2a1947 100644 --- a/src/ui/upgrade_screen.gd +++ b/src/ui/upgrade_screen.gd @@ -31,8 +31,7 @@ var _status: Label func _ready() -> void: layer = 28 visible = false - var root := Control.new() - root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var root := UiTheme.themed_root() add_child(root) var scrim := ColorRect.new() diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index 12a40c3..a63f992 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -10,6 +10,7 @@ extends Node2D @onready var characters: CanvasLayer = $CharacterSelect @onready var upgrades: CanvasLayer = $UpgradeScreen @onready var settings: CanvasLayer = $SettingsScreen +@onready var credits: CanvasLayer = $CreditsScreen var _bound: ClientRuntime = null ## 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) menu.settings_requested.connect(func() -> void: settings.open()) 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)) diff --git a/src/view/ui_theme.gd b/src/view/ui_theme.gd new file mode 100644 index 0000000..8383795 --- /dev/null +++ b/src/view/ui_theme.gd @@ -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 diff --git a/src/view/ui_theme.gd.uid b/src/view/ui_theme.gd.uid new file mode 100644 index 0000000..7b75081 --- /dev/null +++ b/src/view/ui_theme.gd.uid @@ -0,0 +1 @@ +uid://ddaj0c8xfjitx diff --git a/tests/unit/test_credits.gd b/tests/unit/test_credits.gd new file mode 100644 index 0000000..42e3d7c --- /dev/null +++ b/tests/unit/test_credits.gd @@ -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") diff --git a/tests/unit/test_credits.gd.uid b/tests/unit/test_credits.gd.uid new file mode 100644 index 0000000..d16e4e9 --- /dev/null +++ b/tests/unit/test_credits.gd.uid @@ -0,0 +1 @@ +uid://tb74xt0r3x5s diff --git a/tests/unit/test_settings.gd b/tests/unit/test_settings.gd index fe90778..5cf9fa9 100644 --- a/tests/unit/test_settings.gd +++ b/tests/unit/test_settings.gd @@ -8,6 +8,7 @@ const SCRATCH := "user://test_settings_%d.cfg" var _saved_bindings: Dictionary[String, Dictionary] = {} var _saved_master: float = 0.0 var _saved_sfx: float = 0.0 +var _scratch: String = "" func before_each() -> void: @@ -16,6 +17,12 @@ func before_each() -> void: _saved_bindings = Settings.bindings.duplicate() _saved_master = Settings.master_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: @@ -23,6 +30,8 @@ func after_each() -> void: Settings.master_volume = _saved_master Settings.sfx_volume = _saved_sfx Settings.apply_input() + Settings.path = Settings.DEFAULT_PATH + DirAccess.remove_absolute(ProjectSettings.globalize_path(_scratch)) 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. assert_false(loaded.has_section_key("input", "move_up")) 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) diff --git a/tests/unit/test_ui_theme.gd b/tests/unit/test_ui_theme.gd new file mode 100644 index 0000000..6f45715 --- /dev/null +++ b/tests/unit/test_ui_theme.gd @@ -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]) diff --git a/tests/unit/test_ui_theme.gd.uid b/tests/unit/test_ui_theme.gd.uid new file mode 100644 index 0000000..0d62a90 --- /dev/null +++ b/tests/unit/test_ui_theme.gd.uid @@ -0,0 +1 @@ +uid://ddwfq3jpwgfep diff --git a/tools/screenshot.gd b/tools/screenshot.gd new file mode 100644 index 0000000..b94b327 --- /dev/null +++ b/tools/screenshot.gd @@ -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) diff --git a/tools/screenshot.gd.uid b/tools/screenshot.gd.uid new file mode 100644 index 0000000..f95bcdb --- /dev/null +++ b/tools/screenshot.gd.uid @@ -0,0 +1 @@ +uid://bnuqy4w5whjce diff --git a/tools/screenshot.tscn b/tools/screenshot.tscn new file mode 100644 index 0000000..64bd4f0 --- /dev/null +++ b/tools/screenshot.tscn @@ -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")