Compare commits
3 Commits
33845a8a42
...
afe76c22ce
| Author | SHA1 | Date | |
|---|---|---|---|
| afe76c22ce | |||
| 3586555aea | |||
| 9fa2e260f2 |
@@ -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
@@ -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).
|
||||
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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
|
||||
@@ -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 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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())]
|
||||
@@ -0,0 +1 @@
|
||||
uid://clqu3bgl7fd1j
|
||||
+89
-38
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0lgvl2fig3i0
|
||||
+61
-48
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dl60m1dxxjhew
|
||||
@@ -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)
|
||||
|
||||
Executable
+64
@@ -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()
|
||||
Reference in New Issue
Block a user