Replace placeholder rendering with real sprites and audio
Terrain, actors, boss and bullets are sprites now, and four sounds play off server events. 548KB total across seven files, out of 3.9GB of source packs. - src/view/art.gd holds every atlas rect and sound path in one table, copied from the 0x72 pack's own tile_list. Scattering coordinates through draw calls would make re-cutting the atlas a hunt, and a wrong rect invisible. - Bullets get one MultiMesh per kind rather than one overall: a MultiMeshInstance2D carries a single texture and each kind needs a different region of the sheet. Four draw calls, no custom shader passing UVs through per-instance data. - src/view/sfx.gd is a 16-voice round-robin pool. Bullet-hell fire rates mean sounds overlap constantly, and identical sounds landing within two frames are collapsed so a ring hitting eight bullets is a bang rather than clipping. Every sound is triggered by a server event, never a local guess, so what you hear matches what happened. Audio was converted, not copied: the pack ships 24-bit/96kHz masters averaging 2MB. Downsampled to 16-bit/44.1kHz mono, silence-trimmed, and the shoot sound hard-capped to 0.30s -- it came out at 2.03s, against a fire cooldown of 0.12s, which would have smeared held fire into noise. tests/unit/test_art.gd checks what cannot be eyeballed here: every atlas rect lands inside its texture, every animation frame of a strip fits (the last frame is what runs off the sheet, not the first), there is a sprite per bullet kind and per enemy visual, and no sound is long enough to stack badly. A wrong atlas coordinate does not error -- it silently draws the wrong pixels. Licence sources recorded in docs/ASSETS.md now that they are known. All four are free versions and four rows still say "confirm at source": free itch packs vary on credit and commercial use, and there is no credits screen yet. 155 tests (was 146). check.sh, test.sh and smoke.sh pass.
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: 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
|
||||
+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