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>
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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/).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 303 B |
@@ -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
|
||||||
|
After Width: | Height: | Size: 127 B |
@@ -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
|
||||||
|
After Width: | Height: | Size: 93 B |
@@ -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
|
||||||
|
After Width: | Height: | Size: 161 B |
@@ -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
|
||||||
|
After Width: | Height: | Size: 161 B |
@@ -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
|
||||||
|
After Width: | Height: | Size: 163 B |
@@ -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
|
||||||
|
After Width: | Height: | Size: 164 B |
@@ -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
|
||||||
|
After Width: | Height: | Size: 389 B |
@@ -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
|
||||||
|
After Width: | Height: | Size: 368 B |
@@ -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
|
||||||
|
After Width: | Height: | Size: 190 B |
@@ -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
|
||||||
|
After Width: | Height: | Size: 189 B |
@@ -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
|
||||||
|
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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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, ""))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://drpadmmsrvoax
|
||||||
@@ -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 -------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bo3xikvfahke5
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ddaj0c8xfjitx
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://tb74xt0r3x5s
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ddwfq3jpwgfep
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bnuqy4w5whjce
|
||||||
@@ -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")
|
||||||