diff --git a/assets/audio/sfx/boss_death.wav b/assets/audio/sfx/boss_death.wav new file mode 100644 index 0000000..d15c05f Binary files /dev/null and b/assets/audio/sfx/boss_death.wav differ diff --git a/assets/audio/sfx/boss_death.wav.import b/assets/audio/sfx/boss_death.wav.import new file mode 100644 index 0000000..876ee4b --- /dev/null +++ b/assets/audio/sfx/boss_death.wav.import @@ -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 diff --git a/assets/audio/sfx/enemy_death.wav b/assets/audio/sfx/enemy_death.wav new file mode 100644 index 0000000..7ae851d Binary files /dev/null and b/assets/audio/sfx/enemy_death.wav differ diff --git a/assets/audio/sfx/enemy_death.wav.import b/assets/audio/sfx/enemy_death.wav.import new file mode 100644 index 0000000..8e13a46 --- /dev/null +++ b/assets/audio/sfx/enemy_death.wav.import @@ -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 diff --git a/assets/audio/sfx/player_hit.wav b/assets/audio/sfx/player_hit.wav new file mode 100644 index 0000000..6284788 Binary files /dev/null and b/assets/audio/sfx/player_hit.wav differ diff --git a/assets/audio/sfx/player_hit.wav.import b/assets/audio/sfx/player_hit.wav.import new file mode 100644 index 0000000..99cc801 --- /dev/null +++ b/assets/audio/sfx/player_hit.wav.import @@ -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 diff --git a/assets/audio/sfx/shoot.wav b/assets/audio/sfx/shoot.wav new file mode 100644 index 0000000..5aa7e17 Binary files /dev/null and b/assets/audio/sfx/shoot.wav differ diff --git a/assets/audio/sfx/shoot.wav.import b/assets/audio/sfx/shoot.wav.import new file mode 100644 index 0000000..6fd9dc7 --- /dev/null +++ b/assets/audio/sfx/shoot.wav.import @@ -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 diff --git a/assets/sprites/dungeon_tileset.png b/assets/sprites/dungeon_tileset.png new file mode 100644 index 0000000..5420120 Binary files /dev/null and b/assets/sprites/dungeon_tileset.png differ diff --git a/assets/sprites/dungeon_tileset.png.import b/assets/sprites/dungeon_tileset.png.import new file mode 100644 index 0000000..87cb835 --- /dev/null +++ b/assets/sprites/dungeon_tileset.png.import @@ -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 diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 9cafa6c..26b02f1 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 609cdaa..b623c99 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/src/game.tscn b/src/game.tscn index a1da2df..5e974dc 100644 --- a/src/game.tscn +++ b/src/game.tscn @@ -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") diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 3a7cde6..dc25de3 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -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 diff --git a/src/view/art.gd b/src/view/art.gd new file mode 100644 index 0000000..4764f4d --- /dev/null +++ b/src/view/art.gd @@ -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())] diff --git a/src/view/art.gd.uid b/src/view/art.gd.uid new file mode 100644 index 0000000..fb50a7b --- /dev/null +++ b/src/view/art.gd.uid @@ -0,0 +1 @@ +uid://clqu3bgl7fd1j diff --git a/src/view/bullet_renderer.gd b/src/view/bullet_renderer.gd index 3ad8bbc..b567b59 100644 --- a/src/view/bullet_renderer.gd +++ b/src/view/bullet_renderer.gd @@ -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] diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index 99494a6..8f84882 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -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) diff --git a/src/view/sfx.gd b/src/view/sfx.gd new file mode 100644 index 0000000..bd60ae9 --- /dev/null +++ b/src/view/sfx.gd @@ -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() diff --git a/src/view/sfx.gd.uid b/src/view/sfx.gd.uid new file mode 100644 index 0000000..2839da1 --- /dev/null +++ b/src/view/sfx.gd.uid @@ -0,0 +1 @@ +uid://d0lgvl2fig3i0 diff --git a/src/view/world_view.gd b/src/view/world_view.gd index 48e9095..2be1732 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -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 diff --git a/tests/unit/test_art.gd b/tests/unit/test_art.gd new file mode 100644 index 0000000..7f0f3e5 --- /dev/null +++ b/tests/unit/test_art.gd @@ -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") diff --git a/tests/unit/test_art.gd.uid b/tests/unit/test_art.gd.uid new file mode 100644 index 0000000..65d76ac --- /dev/null +++ b/tests/unit/test_art.gd.uid @@ -0,0 +1 @@ +uid://dl60m1dxxjhew