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.
This commit is contained in:
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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://j0ouktqmynvo"
|
||||
path="res://.godot/imported/bullets.png-95be159865e32af815661d75740444b7.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/bullets.png"
|
||||
dest_files=["res://.godot/imported/bullets.png-95be159865e32af815661d75740444b7.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bspcicxeqn4bq"
|
||||
path="res://.godot/imported/impact.png-e86888071ed584ee489f2674e4500cf8.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/impact.png"
|
||||
dest_files=["res://.godot/imported/impact.png-e86888071ed584ee489f2674e4500cf8.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
|
||||
+31
-21
@@ -5,35 +5,45 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
## Packs
|
||||
|
||||
| Pack | Size | Intended use | Licence |
|
||||
All four are the **free** versions. Source URLs are recorded because the packs
|
||||
themselves are gitignored, so their bundled licence files are not in the repo.
|
||||
|
||||
| Pack | Source | In 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.** |
|
||||
| 0x72 Dungeon Tileset II v1.7 | https://0x72.itch.io/dungeontileset-ii | Terrain, player, enemies, boss → `assets/sprites/dungeon_tileset.png` | Confirm at source. Widely distributed as CC0 by Robert Norenberg (0x72); the bundled README is technical, not legal. |
|
||||
| Fire Pixel Bullet 16x16 | https://bdragon1727.itch.io/fire-pixel-bullet-16x16 | Bullets → `assets/sprites/bullets.png` | Confirm at source (free version). |
|
||||
| 750+ Effect and FX Pixel All | https://bdragon1727.itch.io/750-effect-and-fx-pixel-all | Impact effect → `assets/sprites/impact.png` | Confirm at source (free version). |
|
||||
| Helton Yan — Pixel Combat | https://heltonyan.itch.io/pixelcombat | SFX → `assets/audio/sfx/*.wav` | Confirm at source (free version). |
|
||||
|
||||
### Action needed before shipping anything
|
||||
### Not in use
|
||||
|
||||
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.
|
||||
`assetpacks/considering_dont_use_yet/` is the user's own "not chosen" marker and
|
||||
nothing there is referenced by the game. 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.
|
||||
|
||||
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.
|
||||
### Still to confirm
|
||||
|
||||
Free itch.io packs vary: some are CC0, some require credit, some restrict
|
||||
commercial use, and "free version" often carries different terms from the paid
|
||||
one. Four rows above say *confirm at source* — do that before a public build,
|
||||
and if any require attribution, the credits screen does not exist yet.
|
||||
|
||||
## Converting audio before use
|
||||
|
||||
|
||||
@@ -53,6 +53,22 @@ 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 |
|
||||
|
||||
## Stage 2 — Characters, persistence, levels · *todo, next*
|
||||
|
||||
Depends on nothing in Stage 1 except a place to stand. Blocked only on the
|
||||
|
||||
+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,8 @@ 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)
|
||||
if int(ev["team"]) == SimConfig.TEAM_PLAYER:
|
||||
shot_fired.emit()
|
||||
SimEvent.Type.BULLET_DESPAWN:
|
||||
world.apply_event(ev)
|
||||
SimEvent.Type.PLAYER_HIT:
|
||||
@@ -346,6 +359,10 @@ func on_events(data: PackedByteArray) -> void:
|
||||
predicted_pos = ev["pos"]
|
||||
pending.clear()
|
||||
hud_dirty.emit()
|
||||
SimEvent.Type.ENEMY_DIED:
|
||||
enemy_died.emit()
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
boss_died.emit()
|
||||
_:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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.
|
||||
|
||||
const TILESET := preload("res://assets/sprites/dungeon_tileset.png")
|
||||
const BULLETS := preload("res://assets/sprites/bullets.png")
|
||||
const IMPACT := preload("res://assets/sprites/impact.png")
|
||||
|
||||
## 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
|
||||
## Ticks per animation frame.
|
||||
const FRAME_TICKS := 8
|
||||
|
||||
## 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 ----------------------------------------------------------------
|
||||
# The pack sheet is a 40x25 grid of 16x16 cells. SimConfig.KIND_* indexes into
|
||||
# these, so a new bullet kind is one more entry.
|
||||
const BULLET_CELL := 16.0
|
||||
const BULLET_CELLS: Array[Vector2i] = [
|
||||
Vector2i(0, 0), # KIND_PLAYER_SHOT
|
||||
Vector2i(0, 5), # KIND_ORB
|
||||
Vector2i(0, 10), # KIND_NEEDLE
|
||||
Vector2i(0, 15), # KIND_HEAVY
|
||||
]
|
||||
## Frames in a bullet's animation loop, read left to right from its cell.
|
||||
const BULLET_FRAMES := 4
|
||||
|
||||
# --- 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 at this tick. Offset by an actor id so a room
|
||||
## full of the same enemy does not breathe in unison.
|
||||
static func anim_frame(tick: int, offset: int, frames: int = ACTOR_FRAMES) -> int:
|
||||
return posmod((tick / FRAME_TICKS) + 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
|
||||
+62
-43
@@ -1,60 +1,79 @@
|
||||
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
|
||||
|
||||
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_tick: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
texture = _make_dot_texture()
|
||||
var mm := MultiMesh.new()
|
||||
mm.transform_format = MultiMesh.TRANSFORM_2D
|
||||
mm.use_colors = true
|
||||
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 SimConfig.KIND_HEAVY + 1:
|
||||
var layer := MultiMeshInstance2D.new()
|
||||
layer.texture = Art.BULLETS
|
||||
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 _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 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)
|
||||
func _process(_delta: float) -> void:
|
||||
_anim_tick += 1
|
||||
_apply_frame()
|
||||
|
||||
|
||||
## Point each layer's texture at this frame's cell in the sheet. Every bullet in
|
||||
## a layer shares the frame, which is why this is one region assignment rather
|
||||
## than per-instance work.
|
||||
func _apply_frame() -> void:
|
||||
var frame := (_anim_tick / 6) % Art.BULLET_FRAMES
|
||||
for kind in _layers.size():
|
||||
var cell: Vector2i = Art.BULLET_CELLS[kind]
|
||||
var region := Rect2(
|
||||
Vector2(float(cell.x + frame), float(cell.y)) * Art.BULLET_CELL,
|
||||
Vector2(Art.BULLET_CELL, Art.BULLET_CELL))
|
||||
var atlas := _layers[kind].texture as AtlasTexture
|
||||
if atlas == null:
|
||||
atlas = AtlasTexture.new()
|
||||
atlas.atlas = Art.BULLETS
|
||||
_layers[kind].texture = atlas
|
||||
atlas.region = region
|
||||
|
||||
|
||||
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, 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
|
||||
+55
-47
@@ -25,12 +25,15 @@ 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
|
||||
## Drives sprite animation. Local and cosmetic -- never the simulation's tick.
|
||||
var _anim_tick: int = 0
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_anim_tick += 1
|
||||
client = Net.client
|
||||
if client != null:
|
||||
bullets.render_pool(client.world.pool)
|
||||
@@ -81,25 +84,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 +127,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_tick, 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_tick, 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 +163,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 +178,33 @@ 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_tick, 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
|
||||
var dest := Rect2(at - size * 0.5, size)
|
||||
if flip_h:
|
||||
# Negative width mirrors the region in place.
|
||||
dest = Rect2(Vector2(dest.position.x + size.x, dest.position.y),
|
||||
Vector2(-size.x, size.y))
|
||||
draw_texture_rect_region(tex, dest, src, modulate)
|
||||
|
||||
|
||||
## The escape channel is drawn on the player, not just in the HUD, so other
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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_every_texture_loaded() -> void:
|
||||
for tex in [Art.TILESET, Art.BULLETS, Art.IMPACT]:
|
||||
assert_not_null(tex)
|
||||
assert_gt(tex.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:
|
||||
for kind in Art.BULLET_CELLS.size():
|
||||
var cell: Vector2i = Art.BULLET_CELLS[kind]
|
||||
for n in Art.BULLET_FRAMES:
|
||||
var r := Rect2(
|
||||
Vector2(float(cell.x + n), float(cell.y)) * Art.BULLET_CELL,
|
||||
Vector2(Art.BULLET_CELL, Art.BULLET_CELL))
|
||||
assert_true(_fits(Art.BULLETS, r),
|
||||
"bullet kind %d frame %d is outside the sheet at %s" % [kind, n, r])
|
||||
|
||||
|
||||
## One entry per SimConfig.KIND_*, or a bullet kind silently renders as another.
|
||||
func test_there_is_a_sprite_for_every_bullet_kind() -> void:
|
||||
assert_eq(Art.BULLET_CELLS.size(), SimConfig.KIND_HEAVY + 1)
|
||||
|
||||
|
||||
## 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 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(Art.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
|
||||
Reference in New Issue
Block a user