Compare commits

...

3 Commits

Author SHA1 Message Date
claude afe76c22ce Fix bullet atlas, animation speed, sprite flip, fire rate, silent shots
ci / verify (push) Successful in 46s
Bullet sprites were wrong because I read the pack's layout backwards. Each of
its 8 PNGs is one ANIMATION FRAME, and a column within a file is a COLOUR
variant -- so walking columns cycled the palette while the shape sat still, and
the rows I picked were in a region of larger multi-cell sprites, which is the
scattered debris that showed on screen. tools/build_local_assets.py now composes
a proper atlas: 8 frames across, one row per SimConfig.KIND_*.

Animations ran at whatever the machine's framerate was, because they counted
_process calls. At 240fps that is four times too fast on its own, before the
requested halving. Both actor and bullet animation are now driven by elapsed
seconds, so they look the same on any machine.

The knight shifted sideways instead of mirroring: a Rect2 with negative width
does not flip, the draw call normalises it, so the sprite kept its orientation
and jumped right by its own width. Mirrored through the canvas transform about
the sprite's centre instead.

Shots were silent when a bullet spawned inside an enemy. The sound rode on
BULLET_SPAWN, which is deliberately suppressed for a bullet resolved and removed
within the same tick -- so no event, no sound, and this got more likely the
closer you stood. Firing is now its own event (PLAYER_FIRED): the shot happened
whether or not a bullet survived to be replicated. Protocol 3 -> 4, because
inserting mid-enum shifts the wire value of every event after it.

Fire cooldown 7 -> 14 ticks (4.3 shots/sec).

Licence question 2, confirmed verbatim: the paid tier DOES grant commercial use
("You may use these assets in personal, commercial or non-commercial
projects"), but its next sentence still forbids redistribution "no matter how
much you modify it". Paying moves these from local-only-and-non-commercial to
local-only-and-commercial; it never makes them committable. Only replacing them
with permissively licensed art does that.

Question 3: CREDITS.md now credits every pack, including the two whose licences
do not require it and the unused ones in considering_dont_use_yet/.

158 tests. check.sh, test.sh and smoke.sh pass.
2026-09-04 00:03:04 +02:00
claude 3586555aea Remove non-redistributable art from the repo; record licence terms
Researched the licence at each of the four itch.io sources. Two of the four
forbid redistribution, and publishing a repository containing them is exactly
that, so they are out of the tree.

  0x72 DungeonTileset II    CC0 1.0    commercial ok, no credit required
  Helton Yan Pixel Combat   CC BY 4.0  commercial ok, CREDIT REQUIRED
  Fire Pixel Bullet 16x16   custom     non-commercial, NO REDISTRIBUTION
  750+ Effect and FX Pixel  custom     non-commercial, NO REDISTRIBUTION

Both bdragon1727 packs state verbatim: "You cannot do: Resell / redistribute
this asset." The FX pack's paid tier is no better for this purpose -- "You can
NOT re-distribute the file, no matter how much you modify it" -- so paying does
not unlock it and neither does editing the art.

bullets.png and impact.png therefore move to assets/local/, gitignored.
Art.bullets_texture() / impact_texture() load them at runtime and return null
when absent; the bullet renderer falls back to the generated dot it used before,
tinted per kind so bullet types stay distinguishable. Nothing there may be
preload()ed -- preload resolves at compile time and would fail the build on
every machine without the files, which is every clone.

Verified both ways rather than assumed: with the files present the game uses
them and no fallback triggers; with them moved aside, 156/156 tests pass and the
game runs clean on the generated dots.

Added CREDITS.md, because Helton Yan's CC BY 4.0 makes attribution a licence
obligation rather than a courtesy. A file in the source tree does not discharge
it once the game ships -- an in-game credits screen is now a tracked todo, and
flagged as a licence requirement rather than polish.

NOT addressed here, and it needs a decision: these files remain in git history
at 9fa2e26. Removing them from the tip does not remove them from a clone. If
this repo is ever made public, history has to be rewritten (git filter-repo)
and force-pushed. Also, both bdragon packs are non-commercial in their free
tier, which conflicts with the stated Steam goal -- either contribute for the
commercial tier (still no redistribution) or replace with CC0 art, which would
solve both problems and let the assets back into the repo.
2026-09-03 23:52:41 +02:00
claude 9fa2e260f2 Replace placeholder rendering with real sprites and audio
Terrain, actors, boss and bullets are sprites now, and four sounds play off
server events. 548KB total across seven files, out of 3.9GB of source packs.

- src/view/art.gd holds every atlas rect and sound path in one table, copied
  from the 0x72 pack's own tile_list. Scattering coordinates through draw calls
  would make re-cutting the atlas a hunt, and a wrong rect invisible.
- Bullets get one MultiMesh per kind rather than one overall: a
  MultiMeshInstance2D carries a single texture and each kind needs a different
  region of the sheet. Four draw calls, no custom shader passing UVs through
  per-instance data.
- src/view/sfx.gd is a 16-voice round-robin pool. Bullet-hell fire rates mean
  sounds overlap constantly, and identical sounds landing within two frames are
  collapsed so a ring hitting eight bullets is a bang rather than clipping.
  Every sound is triggered by a server event, never a local guess, so what you
  hear matches what happened.

Audio was converted, not copied: the pack ships 24-bit/96kHz masters averaging
2MB. Downsampled to 16-bit/44.1kHz mono, silence-trimmed, and the shoot sound
hard-capped to 0.30s -- it came out at 2.03s, against a fire cooldown of 0.12s,
which would have smeared held fire into noise.

tests/unit/test_art.gd checks what cannot be eyeballed here: every atlas rect
lands inside its texture, every animation frame of a strip fits (the last frame
is what runs off the sheet, not the first), there is a sprite per bullet kind
and per enemy visual, and no sound is long enough to stack badly. A wrong atlas
coordinate does not error -- it silently draws the wrong pixels.

Licence sources recorded in docs/ASSETS.md now that they are known. All four
are free versions and four rows still say "confirm at source": free itch packs
vary on credit and commercial use, and there is no credits screen yet.

155 tests (was 146). check.sh, test.sh and smoke.sh pass.
2026-09-03 23:43:13 +02:00
34 changed files with 897 additions and 115 deletions
+8
View File
@@ -1,5 +1,13 @@
# Godot's import cache and editor state. Regenerated by `godot --import`.
.godot/
# Assets whose licence forbids redistribution. bdragon1727's packs say
# plainly: "You cannot do: Resell / redistribute this asset." Publishing a repo
# containing them IS redistribution, so they stay local-only. The game loads
# them if present and falls back to generated placeholders if not, so a clone
# without them still runs. See docs/ASSETS.md.
/assets/local/*
!/assets/local/README.md
# Raw asset packs: gigabytes of source artwork and audio. Redistributable
# third-party material belongs where it was bought or downloaded, not in a code
# repo. The directory itself and its guards stay tracked, so a fresh clone has
+51
View File
@@ -0,0 +1,51 @@
# Credits
Every third-party asset pack used or bundled during development, credited
regardless of whether its licence requires it. Two of them do not — they are
here because the work deserves it.
## In the game
**16x16 DungeonTileset II** — [0x72 (Robert Norenberg)](https://0x72.itch.io/dungeontileset-ii)
Terrain, player, enemies and boss sprites.
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) — public domain.
*Credit not required.*
**Pixel Combat SFX** — [Helton Yan](https://heltonyan.itch.io/pixelcombat)
All sound effects. Converted to 16-bit / 44.1 kHz mono and trimmed.
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
**Credit required by licence.**
**Fire Pixel Bullet 16x16** — [bdragon1727](https://bdragon1727.itch.io/fire-pixel-bullet-16x16)
Bullet sprites. Custom licence; *credit encouraged, not required.*
Not distributed with this repository — see below.
**750+ Effect and FX Pixel All** — [bdragon1727](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all)
Impact effect artwork. Custom licence; *credit encouraged, not required.*
Not distributed with this repository — see below.
## Present during development, not used in the game
Kept in the untracked `assetpacks/considering_dont_use_yet/` and referenced by
nothing. Credited for completeness.
**RF Catacombs v1.0** — Szadi art. Public domain per the pack's bundled
`public-license.txt`: free for personal or commercial use, credit appreciated
but not required, no reselling of the pack itself.
**Tiny RPG Character Asset Pack 02** and **neo_zero** (v1.0 and 3.0 demo) —
creators not recorded in the packs themselves. If either is ever used, record
the source and terms here first.
## Why two packs are not in this repository
Both bdragon1727 packs forbid redistribution, in **every** tier:
> Free tier: "You cannot do: Resell / redistribute this asset."
>
> Paid tier: "You can NOT re-distribute the file, no matter how much you modify
> it you can use it but not share or re-sell it."
A public repository distributes what it contains, so those files live in the
untracked `assets/local/`. The game falls back to generated placeholders
without them. See [docs/ASSETS.md](docs/ASSETS.md).
+1
View File
@@ -42,6 +42,7 @@ will otherwise cost an afternoon.
- [docs/ROADMAP.md](docs/ROADMAP.md) — what is built, what is next, where each feature lives
- [docs/DECISIONS.md](docs/DECISIONS.md) — settled design decisions and their reasoning
- [CREDITS.md](CREDITS.md) — third-party asset attribution
- [CLAUDE.md](CLAUDE.md) — commands, invariants, Godot CLI gotchas
- [docs/WORKFLOW.md](docs/WORKFLOW.md) — agent-assisted Godot development, and why the setup looks like this
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — code map and the node-free simulation
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://ba050ytxjw6uf"
path="res://.godot/imported/boss_death.wav-0f78bd9c615fc1e1f69662e3e2b02b1e.sample"
[deps]
source_file="res://assets/audio/sfx/boss_death.wav"
dest_files=["res://.godot/imported/boss_death.wav-0f78bd9c615fc1e1f69662e3e2b02b1e.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://b4yijsry80ccc"
path="res://.godot/imported/enemy_death.wav-6bae1d1bbf98e070071e748aa5403ba3.sample"
[deps]
source_file="res://assets/audio/sfx/enemy_death.wav"
dest_files=["res://.godot/imported/enemy_death.wav-6bae1d1bbf98e070071e748aa5403ba3.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c7abhuhkwrnbi"
path="res://.godot/imported/player_hit.wav-478748d8be6b3a13fa393c622448db8e.sample"
[deps]
source_file="res://assets/audio/sfx/player_hit.wav"
dest_files=["res://.godot/imported/player_hit.wav-478748d8be6b3a13fa393c622448db8e.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cgxntaxdtxyqp"
path="res://.godot/imported/shoot.wav-f3ce9b4ae60220ecd3cf78df7278a10f.sample"
[deps]
source_file="res://assets/audio/sfx/shoot.wav"
dest_files=["res://.godot/imported/shoot.wav-f3ce9b4ae60220ecd3cf78df7278a10f.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
+24
View File
@@ -0,0 +1,24 @@
# Local-only assets
Files here are **deliberately not in git**, because their licences forbid
redistribution — and publishing a repository containing them is redistribution.
Currently:
| File | Pack | Restriction |
| --- | --- | --- |
| `bullets.png` | [Fire Pixel Bullet 16x16](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) | "You cannot do: Resell / redistribute this asset." Free tier is non-commercial only. |
| `impact.png` | [750 Effect and FX Pixel All](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) | Same wording. Even the paid tier: "You can NOT re-distribute the file, no matter how much you modify it." |
The game degrades rather than breaks without them: `Art.bullets_texture()`
returns null and the renderer falls back to a generated dot. That fallback is
what a fresh clone gets, and it is why nothing here may be `preload()`ed —
`preload` is resolved at compile time and would fail the whole build on a
machine that does not have these files.
To restore them locally, copy from `assetpacks/` (also untracked):
```
cp "assetpacks/New_All_Fire_Bullet_Pixel_16x16/All_Fire_Bullet_Pixel_16x16_00.png" assets/local/bullets.png
cp "assetpacks/Effect and FX Pixel All Free/Part 1/03.png" assets/local/impact.png
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bircs55cjivvx"
path="res://.godot/imported/dungeon_tileset.png-32ddb268ab9db09e8f122db6a00ca77a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/dungeon_tileset.png"
dest_files=["res://.godot/imported/dungeon_tileset.png-32ddb268ab9db09e8f122db6a00ca77a.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
+74 -23
View File
@@ -5,35 +5,86 @@ The packs themselves are **not in git** (see
licence files are not either. This file is the record that survives. Keep it
current when a pack is added or removed.
## Why nothing is imported yet
## How assets get from a pack into the game
`assetpacks/` carries a `.gdignore`, so Godot skips it entirely. Nothing there
can be referenced from a scene or script until it is copied into
`res://assets/` in game-ready form. That is deliberate: the tree is 3.9 GB, and
`tools/check.sh` runs `--import`, which would otherwise walk all of it on every
run.
`assetpacks/` carries a `.gdignore`, so Godot skips it entirely — nothing there
can be referenced from a scene or script. Files are copied into `res://assets/`
in game-ready form, and only then imported. The tree is 3.9 GB and
`tools/check.sh` runs `--import`, which would otherwise walk all of it every run.
## Packs
What is in the game today totals **548 KB** across seven files: three sprite
sheets and four sounds. Sprite rects and sound paths are all declared in one
place, [src/view/art.gd](../src/view/art.gd), and
`tests/unit/test_art.gd` asserts every rect lands inside its texture — a wrong
atlas coordinate does not error, it silently draws the wrong pixels.
| Pack | Size | Intended use | Licence |
| --- | --- | --- | --- |
| 0x72 Dungeon Tileset II v1.7 | 1.6 MB | Dungeon tiles — maps to the `MapGrid.Kind` tiles the generator already emits | **Unrecorded.** Bundled README is technical, not legal. Widely distributed as CC0 by Robert Norenberg (0x72), but confirm at the source before shipping. |
| Effect and FX Pixel All Free | 29 MB | Hit flashes, muzzle flashes, death effects | **Unrecorded** — no licence file in the pack. |
| Helton Yan's Pixel Combat | 3.9 GB | Combat SFX | **Unrecorded** — no licence file in the pack. |
| New All Fire Bullet Pixel 16x16 | 360 KB | Bullet sprites, to replace the generated dot in `bullet_renderer.gd` | **Unrecorded** — no licence file in the pack. |
| `considering_dont_use_yet/` — RF Catacombs v1.0 | part of 1.6 MB | Undecided | **Public domain**, per bundled `public-license.txt`. "Free to use, personal or commercial. Credit not required but appreciated. You can edit, but not resell the asset pack." Artwork by Szadi art. |
| `considering_dont_use_yet/` — Tiny RPG Character Pack 02, neo_zero v1.0 / 3.0 demo | part of 1.6 MB | Undecided | **Unrecorded.** |
## Licence findings
### Action needed before shipping anything
Checked against each pack's itch.io page. **Two of the four forbid
redistribution**, which is what publishing a repository containing them would
be — so they are not in the repository.
Four of the six packs arrived with no licence text at all. Redistribution terms
for pixel-art and audio packs vary a lot — some free packs forbid commercial
use, some require credit, and "free demo" versions (two of the undecided packs
are demos) are often more restricted than the paid ones.
| Pack | Licence | Commercial | Credit | In this repo? |
| --- | --- | --- | --- | --- |
| [0x72 DungeonTileset II](https://0x72.itch.io/dungeontileset-ii) | **CC0 1.0** (public domain) | Yes | Not required | **Yes**`assets/sprites/dungeon_tileset.png` |
| [Helton Yan Pixel Combat](https://heltonyan.itch.io/pixelcombat) | **CC BY 4.0** | Yes | **Required** | **Yes**`assets/audio/sfx/` |
| [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 |
Record the source URL and terms for each **Unrecorded** row above before any of
it goes into a build. The `considering_dont_use_yet/` packs are the user's own
"not chosen yet" marker; leave them there until that changes.
### The blocking clause
Both bdragon1727 packs state, verbatim:
> **You cannot do:** Resell / redistribute this asset.
And the paid tier of the FX pack is no better for this purpose:
> You can NOT re-distribute the file, no matter how much you modify it you can
> use it but not share or re-sell it.
Putting those files in a public repository distributes them to everyone who
clones it, which is squarely what that forbids. Paying for the commercial tier
does **not** unlock it — the restriction survives payment and survives
modification.
So they live in `assets/local/`, which is gitignored. `Art.bullets_texture()`
and `Art.impact_texture()` load them at runtime if present and return null if
not; the bullet renderer falls back to a generated dot. Verified both ways: with
the files present the game uses them, and with them hidden the suite still
passes 156/156 and the game runs.
This is also why nothing there may be `preload()`ed — `preload` resolves at
compile time and would fail the build on every machine that lacks the files.
### Two obligations this creates
1. **Helton Yan's CC BY 4.0 requires attribution**, and it is not satisfied by
a file in the source tree once the game ships. Players need to be able to
see it. [CREDITS.md](../CREDITS.md) records it for now; an in-game credits
screen is a todo.
2. **Both bdragon packs are non-commercial in their free tier.** Buying the
full tier *does* fix that — confirmed verbatim from the FX pack's paid
licence: "You may use these assets in personal, commercial or
non-commercial projects."
It does **not** fix the repository problem. The paid licence's very next
sentence is: "You can NOT re-distribute the file, no matter how much you
modify it you can use it but not share or re-sell it." Redistribution is
forbidden at every tier, so paying moves these assets from
*local-only-and-non-commercial* to *local-only-and-commercial*. They cannot
be committed to a public repo at any price.
The only route that puts bullet and FX art back into the repository is
replacing it with something permissive (CC0, or CC BY with attribution),
which would also remove the local-asset fallback machinery.
### Not in use
`assetpacks/considering_dont_use_yet/` is the "not chosen" marker and nothing
there is referenced. It contains RF Catacombs v1.0 (public domain per its
bundled `public-license.txt` — free for personal or commercial use, credit
appreciated, no reselling; artwork by Szadi art), plus Tiny RPG Character Pack
02 and neo_zero demos, whose terms are unrecorded.
## Converting audio before use
+18
View File
@@ -53,6 +53,24 @@ draws whatever it holds. The defence is what the server declines to send.
---
## Art and audio · *first pass done*
Placeholders are gone: terrain, actors and bullets are sprites, and four sounds
play off server events. Enough to prove the pipeline, not a finished look.
| Feature | State | Where |
| --- | --- | --- |
| Atlas/sound table in one place | done | [src/view/art.gd](../src/view/art.gd) |
| Terrain, actors, boss from the 0x72 atlas | done | `WorldView._draw_tile` / `_draw_sprite` |
| Animated bullet sprites, one MultiMesh per kind | done | [src/view/bullet_renderer.gd](../src/view/bullet_renderer.gd) |
| SFX pool driven by server events | done | [src/view/sfx.gd](../src/view/sfx.gd) |
| Rects validated without a display | done | `tests/unit/test_art.gd` |
| Impact/death VFX animation | todo | `Art.IMPACT` is loaded and validated but nothing plays it yet |
| Directional sprites, hit flashes, screen shake | todo | |
| Audio buses and a volume setting | todo | Everything plays on Master at hardcoded dB |
| **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. |
| 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 · *todo, next*
Depends on nothing in Stage 1 except a place to stand. Blocked only on the
+1 -1
View File
@@ -36,7 +36,7 @@ const PLAYER_VISUAL_RADIUS := 13.0
## ahead of the server, so this margin is what absorbs that too.
const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0
const PLAYER_MAX_HP := 100
const PLAYER_FIRE_COOLDOWN := 7 # ticks
const PLAYER_FIRE_COOLDOWN := 14 # ticks (~4.3 shots/sec)
const PLAYER_BULLET_SPEED := 620.0
const PLAYER_BULLET_RADIUS := 4.0
const PLAYER_BULLET_LIFETIME := 90 # ticks
+6 -2
View File
@@ -1,10 +1,11 @@
[gd_scene load_steps=6 format=3]
[gd_scene load_steps=7 format=3]
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"]
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
[ext_resource type="Script" path="res://src/view/bullet_renderer.gd" id="3"]
[ext_resource type="Script" path="res://src/ui/hud.gd" id="4"]
[ext_resource type="Script" path="res://src/ui/game_menu.gd" id="5"]
[ext_resource type="Script" path="res://src/view/sfx.gd" id="6"]
[node name="Game" type="Node2D"]
script = ExtResource("1")
@@ -13,7 +14,7 @@ script = ExtResource("1")
position = Vector2(640, 360)
script = ExtResource("2")
[node name="Bullets" type="MultiMeshInstance2D" parent="WorldView"]
[node name="Bullets" type="Node2D" parent="WorldView"]
script = ExtResource("3")
[node name="HUD" type="CanvasLayer" parent="."]
@@ -21,3 +22,6 @@ script = ExtResource("4")
[node name="GameMenu" type="CanvasLayer" parent="."]
script = ExtResource("5")
[node name="Sfx" type="Node" parent="."]
script = ExtResource("6")
+18
View File
@@ -10,6 +10,11 @@ extends Node
signal instance_changed
signal hud_dirty
signal local_hit(damage: int)
## Cosmetic signals for the view. Every one is raised from a server event, not
## from a local guess, so what the player hears matches what actually happened.
signal shot_fired
signal enemy_died
signal boss_died
var my_peer: int = 0
var instance_id: int = 0
@@ -200,6 +205,12 @@ func _resync_input_tick(server_tick: int, why: String) -> void:
GameLog.warn("client", "input re-sync: %s" % why)
## Whether the local player is moving, for choosing a run vs idle animation.
## View-only; nothing in the simulation asks.
func is_moving() -> bool:
return not pending.is_empty() and pending[pending.size() - 1].move.length_squared() > 0.04
func on_map_chunks(from_instance: int, data: PackedByteArray) -> void:
# A chunk still in flight when we changed instances describes the wrong map.
if from_instance != instance_id or world.map == null:
@@ -329,6 +340,7 @@ func on_events(data: PackedByteArray) -> void:
ev["team"], ev["kind"], ev["accel"], ev["turn"], ev["uid"])
if slot >= 0 and catchup > 0:
world.pool.advance_slot(slot, catchup)
SimEvent.Type.BULLET_DESPAWN:
world.apply_event(ev)
SimEvent.Type.PLAYER_HIT:
@@ -346,6 +358,12 @@ func on_events(data: PackedByteArray) -> void:
predicted_pos = ev["pos"]
pending.clear()
hud_dirty.emit()
SimEvent.Type.PLAYER_FIRED:
shot_fired.emit()
SimEvent.Type.ENEMY_DIED:
enemy_died.emit()
SimEvent.Type.BOSS_DIED:
boss_died.emit()
_:
pass
+2 -2
View File
@@ -184,7 +184,7 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
body.put_u16(clampi(int(ev["dmg"]), 0, 65535))
body.put_u16(clampi(int(ev["hp"]), 0, 65535))
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
SimEvent.Type.ESCAPE_CANCELLED:
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED:
body.put_u32(ev["peer"])
SimEvent.Type.PLAYER_RESPAWNED:
body.put_u32(ev["peer"])
@@ -235,7 +235,7 @@ static func decode_events(data: PackedByteArray) -> Dictionary:
ev["dmg"] = b.get_u16()
ev["hp"] = b.get_u16()
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
SimEvent.Type.ESCAPE_CANCELLED:
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED:
ev["peer"] = b.get_u32()
SimEvent.Type.PLAYER_RESPAWNED:
ev["peer"] = b.get_u32()
+4 -1
View File
@@ -7,7 +7,10 @@ extends RefCounted
## roster message.
## 3: dropped the post-hit invulnerability flag, added the respawn-lockout byte
## to each player record.
const VERSION := 3
## 4: added SimEvent.Type.PLAYER_FIRED. It was inserted mid-enum, which shifts
## the wire value of every event after it -- a mismatched client would
## mis-decode every hit and death, so the handshake has to reject it.
const VERSION := 4
const DEFAULT_PORT := 27015
const MAX_CLIENTS := 32
+5
View File
@@ -8,6 +8,11 @@ extends RefCounted
enum Type {
BULLET_SPAWN, ## uid, pos, vel, radius, life, kind, team, accel, turn
BULLET_DESPAWN, ## uid -- early death only; expiry is derived on both sides
## A player's weapon went off. Distinct from BULLET_SPAWN on purpose: a
## bullet born inside an enemy is resolved and removed in the same tick, so
## no spawn event is ever emitted for it -- and the shot still happened, and
## still needs to be heard.
PLAYER_FIRED, ## peer
PLAYER_HIT, ## peer, damage, hp
PLAYER_DIED, ## peer
PLAYER_RESPAWNED, ## peer, pos
+1
View File
@@ -222,6 +222,7 @@ func _take_input(p: SimPlayer) -> InputFrame:
func _fire_player_shot(p: SimPlayer) -> void:
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
events.append({"t": SimEvent.Type.PLAYER_FIRED, "peer": p.peer_id})
var dir := Vector2.RIGHT.rotated(p.aim)
pool.spawn(
p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET,
+138
View File
@@ -0,0 +1,138 @@
class_name Art
extends RefCounted
## Where every sprite rect and sound file lives.
##
## The atlas coordinates below are copied from the pack's own `tile_list_v1.7`,
## which ships alongside the art. Keeping them in one table rather than scattered
## through the renderers means re-cutting the atlas is one edit, and a wrong rect
## is visible here rather than buried in a draw call.
##
## Everything in this file is view-only. Nothing under src/sim/ may reference it:
## the simulation has no idea the game has art.
## Redistributable, so committed and safe to preload.
const TILESET := preload("res://assets/sprites/dungeon_tileset.png")
## NOT redistributable: bdragon1727's licence says "You cannot do: Resell /
## redistribute this asset", and shipping them in a public repo would be exactly
## that. They live in the untracked assets/local/ and are loaded at RUNTIME.
##
## preload() would be resolved at compile time and would fail the entire build
## on any machine that does not have them -- which is every fresh clone. Hence
## load() behind an existence check, and a generated fallback in the renderer.
const LOCAL_BULLETS := "res://assets/local/bullets.png"
const LOCAL_IMPACT := "res://assets/local/impact.png"
static var _bullets: Texture2D = null
static var _impact: Texture2D = null
static var _optional_loaded: bool = false
static func _load_optional() -> void:
if _optional_loaded:
return
_optional_loaded = true
if ResourceLoader.exists(LOCAL_BULLETS):
_bullets = load(LOCAL_BULLETS)
if ResourceLoader.exists(LOCAL_IMPACT):
_impact = load(LOCAL_IMPACT)
## Null when the licence-restricted art is not present. Callers must cope.
static func bullets_texture() -> Texture2D:
_load_optional()
return _bullets
static func impact_texture() -> Texture2D:
_load_optional()
return _impact
## Source art is 16px; the world's tiles are 32. Drawn at 2x with nearest
## filtering (project-wide `default_texture_filter=0`) so it stays crisp.
const SCALE := 2.0
# --- Terrain (0x72 dungeon tileset) -----------------------------------------
# MapGrid.Kind -> atlas rect. Floors get a few variants picked by position so
# large rooms are not a single repeated tile.
const FLOOR_VARIANTS: Array[Rect2] = [
Rect2(16, 64, 16, 16), Rect2(32, 64, 16, 16), Rect2(48, 64, 16, 16),
Rect2(16, 80, 16, 16), Rect2(32, 80, 16, 16), Rect2(48, 80, 16, 16),
Rect2(16, 96, 16, 16), Rect2(32, 96, 16, 16),
]
const WALL := Rect2(32, 16, 16, 16)
const WALL_TOP := Rect2(32, 0, 16, 16)
const PIT := Rect2(96, 144, 16, 16) # "hole"
const BARRICADE := Rect2(16, 32, 16, 16) # a banner: low, readable, see-over
# --- Actors -----------------------------------------------------------------
# 4-frame idle loops. Frames are contiguous horizontally, so only the first
# rect and a count are needed.
const PLAYER_IDLE := Rect2(128, 100, 16, 28)
const PLAYER_RUN := Rect2(192, 100, 16, 28)
const ACTOR_FRAMES := 4
## Seconds per actor animation frame. Time-based rather than frame-counted --
## counting _process calls made every animation run at whatever the machine's
## framerate happened to be, which at 240fps was four times too fast.
const FRAME_SECONDS := 0.26
## EnemyDef.visual -> first idle frame. Order matches Content: drifter, turret,
## stalker, practice dummy.
const ENEMY_IDLE: Array[Rect2] = [
Rect2(368, 64, 16, 16), # imp -> drifter
Rect2(368, 88, 16, 16), # skelet -> turret
Rect2(368, 40, 16, 16), # goblin -> stalker
Rect2(288, 336, 16, 16), # red flask -> practice target
]
const BOSS_IDLE := Rect2(16, 428, 32, 36) # big demon
# --- Bullets ----------------------------------------------------------------
# assets/local/bullets.png is composed by tools/build_local_assets.py: 8 frames
# across, one row per SimConfig.KIND_*. The raw pack could not be used directly
# because its layout is the opposite of what it looks like -- each of its 8
# files is one animation frame, and a column within a file is a COLOUR variant,
# not the next frame. Walking columns animates the palette and leaves the shape
# still, which is exactly what it looked like on screen.
const BULLET_CELL := 16.0
const BULLET_FRAMES := 8
## Seconds per bullet animation frame.
const BULLET_FRAME_SECONDS := 0.08
## Region for a bullet kind at an animation frame.
static func bullet_region(kind: int, frame_index: int) -> Rect2:
return Rect2(
Vector2(float(frame_index % BULLET_FRAMES), float(kind)) * BULLET_CELL,
Vector2(BULLET_CELL, BULLET_CELL))
# --- Impact effect ----------------------------------------------------------
const IMPACT_CELL := 64.0
const IMPACT_COLUMNS := 13
const IMPACT_FRAMES := 9
## Ticks the whole impact animation lasts.
const IMPACT_TICKS := 27
# --- Sound ------------------------------------------------------------------
const SFX_SHOOT := preload("res://assets/audio/sfx/shoot.wav")
const SFX_PLAYER_HIT := preload("res://assets/audio/sfx/player_hit.wav")
const SFX_ENEMY_DEATH := preload("res://assets/audio/sfx/enemy_death.wav")
const SFX_BOSS_DEATH := preload("res://assets/audio/sfx/boss_death.wav")
## Frame [param n] of a horizontal strip starting at [param first].
static func frame(first: Rect2, n: int) -> Rect2:
return Rect2(first.position + Vector2(first.size.x * float(n), 0.0), first.size)
## Which idle/run frame to draw. [param seconds] is elapsed wall time, so the
## animation runs at the same speed on any machine. Offset by an actor id so a
## room full of the same enemy does not breathe in unison.
static func anim_frame(seconds: float, offset: int, frames: int = ACTOR_FRAMES) -> int:
return posmod(int(seconds / FRAME_SECONDS) + offset, frames)
## Pick a floor variant from tile position -- stable, so floors do not shimmer
## as the camera moves.
static func floor_for(tx: int, ty: int) -> Rect2:
return FLOOR_VARIANTS[posmod(tx * 7 + ty * 13, FLOOR_VARIANTS.size())]
+1
View File
@@ -0,0 +1 @@
uid://clqu3bgl7fd1j
+89 -38
View File
@@ -1,60 +1,111 @@
extends MultiMeshInstance2D
## Draws the whole bullet field in one draw call.
extends Node2D
## Draws the whole bullet field.
##
## A bullet hell routinely has a thousand bullets on screen. One Node2D each
## A bullet hell routinely has hundreds of bullets on screen. One Node2D each
## would cost more than the simulation does, so the renderer reads the client's
## [BulletPool] arrays directly and pushes them into a MultiMesh. The sprite is
## generated at runtime, so the project needs no art to run.
## [BulletPool] arrays directly and pushes them into MultiMeshes.
##
## One MultiMesh per bullet kind rather than one overall: a MultiMeshInstance2D
## carries a single texture, and each kind needs a different region of the sprite
## sheet. Four kinds means four draw calls, which is nothing, and it avoids a
## custom shader passing UV offsets through per-instance custom data.
const TEXTURE_SIZE := 32
## Used only in fallback mode, where every bullet is the same generated dot and
## colour is the only thing distinguishing a kind.
const _FALLBACK_COLOURS: Array[Color] = [
Color(0.45, 0.95, 1.0), # KIND_PLAYER_SHOT
Color(1.0, 0.35, 0.75), # KIND_ORB
Color(1.0, 0.85, 0.3), # KIND_NEEDLE
Color(1.0, 0.45, 0.2), # KIND_HEAVY
]
var _colors := {
SimConfig.KIND_PLAYER_SHOT: Color(0.45, 0.95, 1.0),
SimConfig.KIND_ORB: Color(1.0, 0.35, 0.75),
SimConfig.KIND_NEEDLE: Color(1.0, 0.85, 0.3),
SimConfig.KIND_HEAVY: Color(1.0, 0.45, 0.2),
}
var _layers: Array[MultiMeshInstance2D] = []
## Advances the bullets' own animation, independent of the simulation.
var _anim_time: float = 0.0
## True when the licence-restricted bullet sheet is absent and we are drawing
## generated dots instead. See Art.bullets_texture().
var _fallback: bool = false
func _ready() -> void:
texture = _make_dot_texture()
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_2D
mm.use_colors = true
var sheet := Art.bullets_texture()
_fallback = sheet == null
var art: Texture2D = sheet if sheet != null else _make_dot_texture()
var kinds := SimConfig.KIND_HEAVY + 1
if _fallback:
GameLog.info("view", "bullet sheet absent, drawing generated dots")
var quad := QuadMesh.new()
quad.size = Vector2.ONE
mm.mesh = quad
mm.instance_count = SimConfig.MAX_BULLETS
mm.visible_instance_count = 0
multimesh = mm
for kind in kinds:
var layer := MultiMeshInstance2D.new()
layer.texture = art
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_2D
mm.use_colors = true
mm.mesh = quad
mm.instance_count = SimConfig.MAX_BULLETS
mm.visible_instance_count = 0
layer.multimesh = mm
add_child(layer)
_layers.append(layer)
## A soft-edged disc with a bright core, so overlapping bullets stay readable.
func _process(delta: float) -> void:
_anim_time += delta
if not _fallback:
_apply_frame()
## A soft-edged disc with a bright core, generated at runtime. This is what the
## game draws when the bullet sheet is not present, which is the case for any
## clone of the repository -- the sheet's licence forbids redistributing it.
func _make_dot_texture() -> ImageTexture:
var img := Image.create(TEXTURE_SIZE, TEXTURE_SIZE, false, Image.FORMAT_RGBA8)
var centre := Vector2(TEXTURE_SIZE, TEXTURE_SIZE) * 0.5
for y in TEXTURE_SIZE:
for x in TEXTURE_SIZE:
var d := Vector2(x + 0.5, y + 0.5).distance_to(centre) / (TEXTURE_SIZE * 0.5)
var alpha := clampf(1.0 - d, 0.0, 1.0)
alpha = pow(alpha, 0.6)
var size := 32
var img := Image.create(size, size, false, Image.FORMAT_RGBA8)
var centre := Vector2(size, size) * 0.5
for y in size:
for x in size:
var d := Vector2(x + 0.5, y + 0.5).distance_to(centre) / (size * 0.5)
var alpha := pow(clampf(1.0 - d, 0.0, 1.0), 0.6)
var core := clampf(1.0 - d * 1.9, 0.0, 1.0)
img.set_pixel(x, y, Color(1.0, 1.0, 1.0, alpha).lerp(Color.WHITE, core * 0.8))
return ImageTexture.create_from_image(img)
## Point each layer's texture at this frame's row/column in the atlas. Every
## bullet in a layer shares the frame, so this is one region assignment per kind
## rather than any per-instance work.
func _apply_frame() -> void:
var frame_index := int(_anim_time / Art.BULLET_FRAME_SECONDS)
for kind in _layers.size():
var atlas := _layers[kind].texture as AtlasTexture
if atlas == null:
atlas = AtlasTexture.new()
atlas.atlas = Art.bullets_texture()
_layers[kind].texture = atlas
atlas.region = Art.bullet_region(kind, frame_index)
func render_pool(pool: BulletPool) -> void:
var mm := multimesh
var n := 0
var counts := PackedInt32Array()
counts.resize(_layers.size())
counts.fill(0)
for i in pool.high_water:
if pool.alive[i] == 0:
continue
var kind := clampi(pool.kind[i], 0, _layers.size() - 1)
var n := counts[kind]
if n >= SimConfig.MAX_BULLETS:
break
# The texture's visible disc is about 80% of the quad, so scale up a
# little to make the drawn radius match the radius used for hits.
var d: float = pool.radius[i] * 2.5
mm.set_instance_transform_2d(n, Transform2D(0.0, Vector2(d, d), 0.0, pool.pos[i]))
mm.set_instance_color(n, _colors.get(pool.kind[i], Color.WHITE))
n += 1
mm.visible_instance_count = n
continue
# The art fills its 16px cell, while the radius is the hitbox. Draw a
# little larger so a bullet looks like the threat it is -- the same
# "prefer a visible near-miss" trade the ship sprite makes.
var d: float = pool.radius[i] * 3.0
var angle: float = (pool.vel[i] as Vector2).angle()
_layers[kind].multimesh.set_instance_transform_2d(
n, Transform2D(angle, Vector2(d, d), 0.0, pool.pos[i]))
_layers[kind].multimesh.set_instance_color(n,
_FALLBACK_COLOURS[kind] if _fallback else Color.WHITE)
counts[kind] = n + 1
for kind in _layers.size():
_layers[kind].multimesh.visible_instance_count = counts[kind]
+5
View File
@@ -6,6 +6,7 @@ extends Node2D
@onready var world_view: Node2D = $WorldView
@onready var hud: CanvasLayer = $HUD
@onready var menu: CanvasLayer = $GameMenu
@onready var sfx: Node = $Sfx
var _bound: ClientRuntime = null
@@ -44,6 +45,9 @@ func _process(_delta: float) -> void:
_bound = Net.client
if _bound != null and not _bound.local_hit.is_connected(_on_local_hit):
_bound.local_hit.connect(_on_local_hit)
_bound.shot_fired.connect(func() -> void: sfx.play(Art.SFX_SHOOT, -14.0))
_bound.enemy_died.connect(func() -> void: sfx.play(Art.SFX_ENEMY_DEATH, -8.0))
_bound.boss_died.connect(func() -> void: sfx.play(Art.SFX_BOSS_DEATH, -2.0))
_follow_camera()
menu.set_in_dungeon(_bound != null
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
@@ -69,3 +73,4 @@ func _on_respawn_pressed() -> void:
func _on_local_hit(_damage: int) -> void:
hud.flash_hit()
sfx.play(Art.SFX_PLAYER_HIT, -4.0)
+47
View File
@@ -0,0 +1,47 @@
extends Node
## Sound effects, played from the events the client already receives.
##
## Strictly a view concern: sound is triggered by server events arriving, never
## by anything the client decides for itself, so what you hear matches what
## actually happened. Nothing under src/sim/ knows this exists.
## Voices in the pool. Bullet-hell fire rates mean sounds overlap constantly;
## one player would cut its own tail off on every shot.
const VOICES := 16
## Identical sounds landing within this many frames are collapsed into one, so
## a boss ring hitting eight bullets at once is a bang rather than a wall of
## clipping.
const DEDUPE_FRAMES := 2
var _voices: Array[AudioStreamPlayer] = []
var _next: int = 0
var _last_played: Dictionary = {}
var _frame: int = 0
func _ready() -> void:
for i in VOICES:
var p := AudioStreamPlayer.new()
p.bus = "Master"
add_child(p)
_voices.append(p)
func _process(_delta: float) -> void:
_frame += 1
func play(stream: AudioStream, volume_db: float = 0.0) -> void:
if stream == null:
return
var last: int = _last_played.get(stream, -999)
if _frame - last < DEDUPE_FRAMES:
return
_last_played[stream] = _frame
# Round-robin rather than "find a free voice": with this many voices the
# oldest is always the best one to steal, and it needs no search.
var v := _voices[_next]
_next = (_next + 1) % _voices.size()
v.stream = stream
v.volume_db = volume_db
v.play()
+1
View File
@@ -0,0 +1 @@
uid://d0lgvl2fig3i0
+61 -48
View File
@@ -25,12 +25,16 @@ const ENEMY_COLORS := [
Color(0.5, 0.55, 0.6), # dummy
]
@onready var bullets: MultiMeshInstance2D = $Bullets
@onready var bullets: Node2D = $Bullets
var client: ClientRuntime = null
## Elapsed seconds, driving sprite animation. Wall time rather than a frame
## count, so animations run at the same speed regardless of framerate.
var _anim_time: float = 0.0
func _process(_delta: float) -> void:
func _process(delta: float) -> void:
_anim_time += delta
client = Net.client
if client != null:
bullets.render_pool(client.world.pool)
@@ -81,25 +85,26 @@ func _draw_terrain() -> void:
continue
if not map.has_line_of_sight(eye, centre):
continue
_draw_tile(kind, centre)
_draw_tile(kind, centre, tx, ty)
func _draw_tile(kind: MapGrid.Kind, centre: Vector2) -> void:
var half := MapGrid.TILE * 0.5
var r := Rect2(centre - Vector2(half, half), Vector2(MapGrid.TILE, MapGrid.TILE))
func _draw_tile(kind: MapGrid.Kind, centre: Vector2, tx: int, ty: int) -> void:
var dest := Rect2(centre - Vector2(MapGrid.TILE, MapGrid.TILE) * 0.5,
Vector2(MapGrid.TILE, MapGrid.TILE))
match kind:
MapGrid.Kind.FLOOR:
draw_rect(r.grow(-1.0), COL_FLOOR)
MapGrid.Kind.WALL:
draw_rect(r, COL_WALL)
MapGrid.Kind.PILLAR:
draw_rect(r.grow(-2.0), COL_PILLAR)
draw_texture_rect_region(Art.TILESET, dest, Art.floor_for(tx, ty))
MapGrid.Kind.WALL, MapGrid.Kind.PILLAR:
# Walls that have open floor above them show their capped top, so a
# room reads as a room rather than as a field of identical blocks.
var open_above := not client.world.map.blocks_sight(tx, ty - 1)
draw_texture_rect_region(Art.TILESET, dest,
Art.WALL_TOP if open_above else Art.WALL)
MapGrid.Kind.PIT:
draw_rect(r.grow(-1.0), COL_PIT)
draw_texture_rect_region(Art.TILESET, dest, Art.PIT)
MapGrid.Kind.BARRICADE:
# Drawn short so "you can see over this" reads at a glance.
draw_rect(Rect2(r.position + Vector2(0.0, half * 0.5),
Vector2(MapGrid.TILE, half)), COL_BARRICADE)
draw_texture_rect_region(Art.TILESET, dest, Art.floor_for(tx, ty))
draw_texture_rect_region(Art.TILESET, dest, Art.BARRICADE)
## Actors are only drawn where the player can actually see them. The server
@@ -123,36 +128,25 @@ func _draw_portal() -> void:
func _draw_enemy(e: Dictionary) -> void:
var col: Color = ENEMY_COLORS[clampi(int(e["visual"]), 0, ENEMY_COLORS.size() - 1)]
var visual := clampi(int(e["visual"]), 0, Art.ENEMY_IDLE.size() - 1)
var first: Rect2 = Art.ENEMY_IDLE[visual]
var src := Art.frame(first, Art.anim_frame(_anim_time, int(e["id"])))
_draw_sprite(Art.TILESET, src, e["pos"])
# Health ring stays: the sprite says what it is, the ring says how hurt.
var r: float = e["radius"]
if int(e["visual"]) == VISUAL_DUMMY:
_draw_target_dummy(e["pos"], r, col)
return
draw_circle(e["pos"], r, Color(col, 0.35))
draw_arc(e["pos"], r, 0.0, TAU, 24, col, 2.0)
## The hub dummy is a shooting-range target, not an enemy. It used to be drawn
## as a plain grey disc with 100k hit points, which read as scenery -- nothing
## about it said "shoot me" and nothing visibly happened when you did. Concentric
## rings make the intent obvious at a glance.
func _draw_target_dummy(pos: Vector2, r: float, col: Color) -> void:
draw_circle(pos, r, Color(col, 0.18))
for i in 3:
var ring := r * (1.0 - 0.3 * float(i))
draw_arc(pos, ring, 0.0, TAU, 24, Color(col, 0.5 + 0.15 * float(i)), 1.5)
draw_circle(pos, r * 0.12, Color(1.0, 0.5, 0.4, 0.9))
draw_arc(e["pos"], r + 3.0, 0.0, TAU, 20,
Color(0.95, 0.55, 0.55, 0.35), 1.5)
func _draw_boss() -> void:
var b := client.boss_state()
if b.is_empty() or not _visible(b["pos"]):
return
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
var pos: Vector2 = b["pos"]
draw_circle(pos, r, Color(0.9, 0.3, 0.4, 0.25))
draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5), 3.0)
draw_arc(pos, r + 10.0, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.25), 1.0)
var src := Art.frame(Art.BOSS_IDLE, Art.anim_frame(_anim_time, 0))
_draw_sprite(Art.TILESET, src, pos)
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.5), 2.0)
func _draw_remote_player(p: Dictionary) -> void:
@@ -170,7 +164,7 @@ func _draw_local_player() -> void:
if not client.my_alive:
_draw_ship(client.predicted_pos, client.aim, COL_DEAD, false)
return
_draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true)
_draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true, client.is_moving())
if client.my_spawn_grace:
_draw_grace_ring(client.predicted_pos)
if client.my_escaping:
@@ -185,18 +179,37 @@ func _draw_grace_ring(pos: Vector2) -> void:
Color(0.55, 0.85, 1.0, 0.35 + 0.45 * pulse), 2.0)
## Drawn at PLAYER_VISUAL_RADIUS, larger than the PLAYER_RADIUS hitbox actually
## used for hits -- see the comment on those constants in sim_config.gd. The
## mismatch is deliberate, not a placeholder: a bullet can visibly clip the
## sprite without registering a hit, which reads as more forgiving of latency
## than the reverse.
func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool) -> void:
var r := SimConfig.PLAYER_VISUAL_RADIUS
draw_circle(pos, r, Color(col, 0.4 if alive else 0.2))
draw_arc(pos, r, 0.0, TAU, 20, col, 2.0)
## The ship is a sprite now, but the note about sizes still holds: what is drawn
## is deliberately larger than PLAYER_RADIUS, the hitbox used for hits. A bullet
## can visibly clip the sprite without landing, which reads as more forgiving of
## latency than the reverse. See sim_config.gd.
func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool, moving: bool = false) -> void:
var strip := Art.PLAYER_RUN if moving else Art.PLAYER_IDLE
var src := Art.frame(strip, Art.anim_frame(_anim_time, 0))
# Faces the way you aim, which is the whole point of a twin-stick.
var flip := absf(wrapf(aim, -PI, PI)) > PI * 0.5
_draw_sprite(Art.TILESET, src, pos, col if not alive else Color.WHITE, flip)
if alive:
var dir := Vector2.RIGHT.rotated(aim)
draw_line(pos + dir * r, pos + dir * (r + 12.0), col, 2.0)
var r := SimConfig.PLAYER_VISUAL_RADIUS
draw_line(pos + dir * r, pos + dir * (r + 10.0), Color(col, 0.8), 2.0)
## Draw an atlas region centred on a world position, scaled to match the art's
## intended pixel size.
func _draw_sprite(tex: Texture2D, src: Rect2, at: Vector2,
modulate: Color = Color.WHITE, flip_h: bool = false) -> void:
var size := src.size * Art.SCALE
if not flip_h:
draw_texture_rect_region(tex, Rect2(at - size * 0.5, size), src, modulate)
return
# Mirror through the canvas transform, scaled about the sprite's own centre.
# The obvious alternative -- a Rect2 with negative width -- does not mirror:
# the draw call normalises the rect, so the sprite kept its orientation and
# merely jumped sideways by its own width.
draw_set_transform(at, 0.0, Vector2(-1.0, 1.0))
draw_texture_rect_region(tex, Rect2(-size * 0.5, size), src, modulate)
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
## The escape channel is drawn on the player, not just in the HUD, so other
+114
View File
@@ -0,0 +1,114 @@
extends GutTest
## Every sprite rect must land inside its texture.
##
## A wrong atlas coordinate does not error — it silently draws whatever happens
## to be at those pixels, or nothing. Without a display to check against, this
## is what catches a mistyped rect.
func _fits(tex: Texture2D, r: Rect2) -> bool:
var size := Vector2(tex.get_size())
return r.position.x >= 0.0 and r.position.y >= 0.0 \
and r.end.x <= size.x and r.end.y <= size.y
func test_the_committed_texture_loads() -> void:
assert_not_null(Art.TILESET)
assert_gt(Art.TILESET.get_width(), 0)
## The licence-restricted sheets are absent from a clean clone by design, so
## their absence must be a supported state rather than a failure. When they are
## present locally, the rect tests below still validate them.
func test_the_game_runs_without_the_restricted_art() -> void:
var bullets := Art.bullets_texture()
if bullets == null:
assert_true(true, "no bullet sheet: renderer falls back to generated dots")
else:
assert_gt(bullets.get_width(), 0)
func test_terrain_rects_are_inside_the_tileset() -> void:
var rects: Array[Rect2] = [Art.WALL, Art.WALL_TOP, Art.PIT, Art.BARRICADE]
rects.append_array(Art.FLOOR_VARIANTS)
for r in rects:
assert_true(_fits(Art.TILESET, r), "terrain rect %s is outside the atlas" % r)
## Actor strips are read frame by frame along the sheet, so the LAST frame is
## what has to fit, not the first.
func test_every_animation_frame_is_inside_the_tileset() -> void:
var strips: Array[Rect2] = [Art.PLAYER_IDLE, Art.PLAYER_RUN, Art.BOSS_IDLE]
strips.append_array(Art.ENEMY_IDLE)
for strip in strips:
for n in Art.ACTOR_FRAMES:
var f := Art.frame(strip, n)
assert_true(_fits(Art.TILESET, f),
"frame %d of strip %s runs off the atlas at %s" % [n, strip, f])
func test_every_bullet_frame_is_inside_the_sheet() -> void:
var sheet := Art.bullets_texture()
if sheet == null:
pass_test("bullet sheet not present locally; nothing to validate")
return
for kind in SimConfig.KIND_HEAVY + 1:
for n in Art.BULLET_FRAMES:
var r := Art.bullet_region(kind, n)
assert_true(_fits(sheet, r),
"bullet kind %d frame %d is outside the sheet at %s" % [kind, n, r])
## The atlas has one row per kind. A short sheet would silently draw the wrong
## bullet rather than error.
func test_the_bullet_atlas_has_a_row_for_every_kind() -> void:
var sheet := Art.bullets_texture()
if sheet == null:
pass_test("bullet sheet not present locally")
return
var needed := float(SimConfig.KIND_HEAVY + 1) * Art.BULLET_CELL
assert_gte(float(sheet.get_height()), needed,
"atlas is too short for %d bullet kinds" % (SimConfig.KIND_HEAVY + 1))
assert_gte(float(sheet.get_width()), Art.BULLET_FRAMES * Art.BULLET_CELL)
## One entry per EnemyDef.visual actually used by content, or an enemy draws as
## the wrong creature.
func test_there_is_a_sprite_for_every_enemy_visual() -> void:
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET,
Content.ENEMY_STALKER, Content.ENEMY_DUMMY]:
var v := Content.enemy(id).visual
assert_lt(v, Art.ENEMY_IDLE.size(), "%s has visual %d with no sprite" % [id, v])
func test_impact_sheet_covers_its_declared_frames() -> void:
var impact := Art.impact_texture()
if impact == null:
pass_test("impact sheet not present locally; nothing to validate")
return
var last := Rect2(
Vector2(float((Art.IMPACT_FRAMES - 1) % Art.IMPACT_COLUMNS), 0.0) * Art.IMPACT_CELL,
Vector2(Art.IMPACT_CELL, Art.IMPACT_CELL))
assert_true(_fits(impact, last))
func test_every_sound_loaded_and_is_short_enough_to_be_a_sound_effect() -> void:
var sounds := {
"shoot": Art.SFX_SHOOT,
"player_hit": Art.SFX_PLAYER_HIT,
"enemy_death": Art.SFX_ENEMY_DEATH,
"boss_death": Art.SFX_BOSS_DEATH,
}
for name in sounds:
var s: AudioStream = sounds[name]
assert_not_null(s, "%s failed to load" % name)
assert_gt(s.get_length(), 0.0, "%s is empty" % name)
# The source pack ships 2.5s+ masters. Anything that long here means the
# conversion step was skipped and shots will smear into each other.
assert_lt(s.get_length(), 1.5, "%s is too long for a game SFX" % name)
func test_the_shoot_sound_is_shorter_than_the_fire_cooldown_allows_to_overlap() -> void:
var cooldown := float(SimConfig.PLAYER_FIRE_COOLDOWN) * SimConfig.TICK_DELTA
assert_lt(Art.SFX_SHOOT.get_length(), cooldown * 4.0,
"held fire would stack more than a few voices of this sound at once")
+1
View File
@@ -0,0 +1 @@
uid://dl60m1dxxjhew
+27
View File
@@ -69,6 +69,33 @@ func test_input_flood_cannot_grow_the_queue_without_bound() -> void:
"a flood of inputs must not become unbounded server memory")
## The shot sound used to ride on BULLET_SPAWN, and a bullet born inside an
## enemy is resolved and removed within the same tick -- so no spawn event was
## ever emitted and the shot was silent. Firing is now its own event.
func test_firing_is_reported_even_when_the_bullet_dies_instantly() -> void:
var p: SimPlayer = world.players[PEER]
p.pos = Vector2.ZERO
p.aim = 0.0
# The inert practice dummy, sitting exactly where the muzzle is, so the
# bullet is consumed on the tick it is created. Deliberately not a turret:
# a turret's own ring would fill the event list with its spawns and the
# setup check below would pass for the wrong reason.
world.spawn_enemy(Content.dummy(), Vector2(SimConfig.PLAYER_MUZZLE_OFFSET, 0.0))
_drive(1, Vector2.ZERO, InputFrame.BTN_FIRE)
assert_eq(_events_of(SimEvent.Type.PLAYER_FIRED).size(), 1,
"the shot happened and has to be reported")
assert_eq(_events_of(SimEvent.Type.BULLET_SPAWN).size(), 0,
"setup check: the bullet really did die on the tick it spawned")
func test_firing_is_reported_once_per_shot() -> void:
_drive(60, Vector2.ZERO, InputFrame.BTN_FIRE)
var fired := _events_of(SimEvent.Type.PLAYER_FIRED).size()
var expected := 60 / SimConfig.PLAYER_FIRE_COOLDOWN
assert_almost_eq(float(fired), float(expected), 1.0,
"one report per shot the cooldown actually allowed")
func test_fire_rate_is_enforced_by_the_server() -> void:
# Hold fire every single tick; the server still applies its own cooldown.
_drive(60, Vector2.ZERO, InputFrame.BTN_FIRE)
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Compose the local-only sprite atlases from the raw asset packs.
Both inputs and outputs are licence-restricted and therefore untracked (see
docs/ASSETS.md), which is why this is a build step rather than a committed file:
anyone with the packs can regenerate, and nobody without them is blocked.
pip install pillow && python3 tools/build_local_assets.py
The bullet pack's layout is not obvious and cost a wrong guess once:
* Each of the 8 PNGs is one ANIMATION FRAME, not one sprite sheet.
* Within a file, a row is a bullet shape and a column is a COLOUR variant.
So animating means cycling files, not walking columns -- walking columns just
cycles through palettes while the shape sits still.
Output is one tidy atlas: 8 frames across, one row per SimConfig.KIND_*.
"""
import os
import sys
try:
from PIL import Image
except ImportError:
sys.exit("needs pillow: pip install pillow")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC = os.path.join(ROOT, "assetpacks", "New_All_Fire_Bullet_Pixel_16x16",
"All_Fire_Bullet_Pixel_16x16_%02d.png")
OUT = os.path.join(ROOT, "assets", "local", "bullets.png")
CELL = 16
FRAMES = 8
# (source row, source column) per bullet kind, in SimConfig.KIND_* order.
# Column selects the colour: 0 red, 1 orange, 2 amber, 3 gold, 4 bright yellow.
KINDS = [
("player_shot", 11, 4), # small bright dot
("orb", 2, 1), # round orange orb
("needle", 11, 6), # horizontal dart, drawn rotated to velocity
("heavy", 1, 0), # big red ball
]
def main() -> None:
for f in range(FRAMES):
if not os.path.exists(SRC % f):
sys.exit("missing source frame: %s\n"
"The raw pack is untracked; see assets/local/README.md."
% (SRC % f))
out = Image.new("RGBA", (CELL * FRAMES, CELL * len(KINDS)), (0, 0, 0, 0))
for f in range(FRAMES):
src = Image.open(SRC % f).convert("RGBA")
for k, (_name, row, col) in enumerate(KINDS):
box = (col * CELL, row * CELL, (col + 1) * CELL, (row + 1) * CELL)
out.paste(src.crop(box), (f * CELL, k * CELL))
os.makedirs(os.path.dirname(OUT), exist_ok=True)
out.save(OUT)
print("wrote %s (%dx%d): %d kinds x %d frames"
% (OUT, out.width, out.height, len(KINDS), FRAMES))
for k, (name, row, col) in enumerate(KINDS):
print(" row %d = %-12s (source row %2d, colour col %d)" % (k, name, row, col))
if __name__ == "__main__":
main()