From afe76c22ce6c155d56bbd5e10a28715886059ca1 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 4 Sep 2026 00:03:04 +0200 Subject: [PATCH] Fix bullet atlas, animation speed, sprite flip, fire rate, silent shots Bullet sprites were wrong because I read the pack's layout backwards. Each of its 8 PNGs is one ANIMATION FRAME, and a column within a file is a COLOUR variant -- so walking columns cycled the palette while the shape sat still, and the rows I picked were in a region of larger multi-cell sprites, which is the scattered debris that showed on screen. tools/build_local_assets.py now composes a proper atlas: 8 frames across, one row per SimConfig.KIND_*. Animations ran at whatever the machine's framerate was, because they counted _process calls. At 240fps that is four times too fast on its own, before the requested halving. Both actor and bullet animation are now driven by elapsed seconds, so they look the same on any machine. The knight shifted sideways instead of mirroring: a Rect2 with negative width does not flip, the draw call normalises it, so the sprite kept its orientation and jumped right by its own width. Mirrored through the canvas transform about the sprite's centre instead. Shots were silent when a bullet spawned inside an enemy. The sound rode on BULLET_SPAWN, which is deliberately suppressed for a bullet resolved and removed within the same tick -- so no event, no sound, and this got more likely the closer you stood. Firing is now its own event (PLAYER_FIRED): the shot happened whether or not a bullet survived to be replicated. Protocol 3 -> 4, because inserting mid-enum shifts the wire value of every event after it. Fire cooldown 7 -> 14 ticks (4.3 shots/sec). Licence question 2, confirmed verbatim: the paid tier DOES grant commercial use ("You may use these assets in personal, commercial or non-commercial projects"), but its next sentence still forbids redistribution "no matter how much you modify it". Paying moves these from local-only-and-non-commercial to local-only-and-commercial; it never makes them committable. Only replacing them with permissively licensed art does that. Question 3: CREDITS.md now credits every pack, including the two whose licences do not require it and the unused ones in considering_dont_use_yet/. 158 tests. check.sh, test.sh and smoke.sh pass. --- CREDITS.md | 65 +++++++++++++++++++---------- docs/ASSETS.md | 24 +++++++---- src/core/sim_config.gd | 2 +- src/net/client_runtime.gd | 5 ++- src/net/net_codec.gd | 4 +- src/net/protocol.gd | 5 ++- src/sim/sim_events.gd | 5 +++ src/sim/sim_world.gd | 1 + src/view/art.gd | 42 ++++++++++++------- src/view/bullet_renderer.gd | 23 +++++----- src/view/world_view.gd | 31 ++++++++------ tests/unit/test_art.gd | 21 ++++++---- tests/unit/test_server_authority.gd | 27 ++++++++++++ tools/build_local_assets.py | 64 ++++++++++++++++++++++++++++ 14 files changed, 234 insertions(+), 85 deletions(-) create mode 100755 tools/build_local_assets.py diff --git a/CREDITS.md b/CREDITS.md index 40fae52..1bbd0b0 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,30 +1,51 @@ # Credits -Third-party assets used in Transcience, with the attribution their licences -require. +Every third-party asset pack used or bundled during development, credited +regardless of whether its licence requires it. Two of them do not — they are +here because the work deserves it. -## Audio - -**Pixel Combat SFX** — [Helton Yan](https://heltonyan.itch.io/pixelcombat) -Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). -Converted to 16-bit / 44.1 kHz mono and trimmed for use in game. - -> Attribution is **required** by this licence, not optional. It has to appear -> somewhere a player can reach — this file is not enough on its own once the -> game ships. See docs/ROADMAP.md. - -## Art +## In the game **16x16 DungeonTileset II** — [0x72 (Robert Norenberg)](https://0x72.itch.io/dungeontileset-ii) -Released under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) -(public domain). Credit is not required; it is here because the work deserves -it. +Terrain, player, enemies and boss sprites. +[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) — public domain. +*Credit not required.* -## Not distributed with this repository +**Pixel Combat SFX** — [Helton Yan](https://heltonyan.itch.io/pixelcombat) +All sound effects. Converted to 16-bit / 44.1 kHz mono and trimmed. +[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). +**Credit required by licence.** -Two packs are used locally but deliberately **excluded** from the repository, -because their licences forbid redistribution. They are not part of any build -made from this source alone. See [docs/ASSETS.md](docs/ASSETS.md). +**Fire Pixel Bullet 16x16** — [bdragon1727](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) +Bullet sprites. Custom licence; *credit encouraged, not required.* +Not distributed with this repository — see below. -- Fire Pixel Bullet 16x16 — [bdragon1727](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) -- 750+ Effect and FX Pixel All — [bdragon1727](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) +**750+ Effect and FX Pixel All** — [bdragon1727](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) +Impact effect artwork. Custom licence; *credit encouraged, not required.* +Not distributed with this repository — see below. + +## Present during development, not used in the game + +Kept in the untracked `assetpacks/considering_dont_use_yet/` and referenced by +nothing. Credited for completeness. + +**RF Catacombs v1.0** — Szadi art. Public domain per the pack's bundled +`public-license.txt`: free for personal or commercial use, credit appreciated +but not required, no reselling of the pack itself. + +**Tiny RPG Character Asset Pack 02** and **neo_zero** (v1.0 and 3.0 demo) — +creators not recorded in the packs themselves. If either is ever used, record +the source and terms here first. + +## Why two packs are not in this repository + +Both bdragon1727 packs forbid redistribution, in **every** tier: + +> Free tier: "You cannot do: Resell / redistribute this asset." +> +> Paid tier: "You can NOT re-distribute the file, no matter how much you modify +> it you can use it but not share or re-sell it." + +A public repository distributes what it contains, so those files live in the +untracked `assets/local/`. The game falls back to generated placeholders +without them. See [docs/ASSETS.md](docs/ASSETS.md). diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 0e6f5c3..72f4cfa 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -28,8 +28,8 @@ be — so they are not in the repository. | --- | --- | --- | --- | --- | | [0x72 DungeonTileset II](https://0x72.itch.io/dungeontileset-ii) | **CC0 1.0** (public domain) | Yes | Not required | **Yes** — `assets/sprites/dungeon_tileset.png` | | [Helton Yan Pixel Combat](https://heltonyan.itch.io/pixelcombat) | **CC BY 4.0** | Yes | **Required** | **Yes** — `assets/audio/sfx/` | -| [Fire Pixel Bullet 16x16](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) | Custom | **Non-commercial only** unless you contribute | Encouraged | **No** — local only | -| [750+ Effect and FX Pixel All](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) | Custom | **Non-commercial only** unless you contribute | Encouraged | **No** — local only | +| [Fire Pixel Bullet 16x16](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) | Custom | Free tier non-commercial; **paid tier allows commercial** | Encouraged | **No, at any tier** — local only | +| [750+ Effect and FX Pixel All](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) | Custom | Free tier non-commercial; **paid tier allows commercial** | Encouraged | **No, at any tier** — local only | ### The blocking clause @@ -62,11 +62,21 @@ compile time and would fail the build on every machine that lacks the files. a file in the source tree once the game ships. Players need to be able to see it. [CREDITS.md](../CREDITS.md) records it for now; an in-game credits screen is a todo. -2. **Both bdragon packs are non-commercial in their free tier.** The stated - goal is a Steam release. Before that happens they need either a paid - contribution to bdragon1727 (which permits commercial use but still not - redistribution) or replacement with CC0 art. A CC0 replacement solves both - problems at once and lets the assets go back in the repo. +2. **Both bdragon packs are non-commercial in their free tier.** Buying the + full tier *does* fix that — confirmed verbatim from the FX pack's paid + licence: "You may use these assets in personal, commercial or + non-commercial projects." + + It does **not** fix the repository problem. The paid licence's very next + sentence is: "You can NOT re-distribute the file, no matter how much you + modify it you can use it but not share or re-sell it." Redistribution is + forbidden at every tier, so paying moves these assets from + *local-only-and-non-commercial* to *local-only-and-commercial*. They cannot + be committed to a public repo at any price. + + The only route that puts bullet and FX art back into the repository is + replacing it with something permissive (CC0, or CC BY with attribution), + which would also remove the local-asset fallback machinery. ### Not in use diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index 9c63d4e..107a79e 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -36,7 +36,7 @@ const PLAYER_VISUAL_RADIUS := 13.0 ## ahead of the server, so this margin is what absorbs that too. const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0 const PLAYER_MAX_HP := 100 -const PLAYER_FIRE_COOLDOWN := 7 # ticks +const PLAYER_FIRE_COOLDOWN := 14 # ticks (~4.3 shots/sec) const PLAYER_BULLET_SPEED := 620.0 const PLAYER_BULLET_RADIUS := 4.0 const PLAYER_BULLET_LIFETIME := 90 # ticks diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index dc25de3..646b32e 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -340,8 +340,7 @@ func on_events(data: PackedByteArray) -> void: ev["team"], ev["kind"], ev["accel"], ev["turn"], ev["uid"]) if slot >= 0 and catchup > 0: world.pool.advance_slot(slot, catchup) - if int(ev["team"]) == SimConfig.TEAM_PLAYER: - shot_fired.emit() + SimEvent.Type.BULLET_DESPAWN: world.apply_event(ev) SimEvent.Type.PLAYER_HIT: @@ -359,6 +358,8 @@ func on_events(data: PackedByteArray) -> void: predicted_pos = ev["pos"] pending.clear() hud_dirty.emit() + SimEvent.Type.PLAYER_FIRED: + shot_fired.emit() SimEvent.Type.ENEMY_DIED: enemy_died.emit() SimEvent.Type.BOSS_DIED: diff --git a/src/net/net_codec.gd b/src/net/net_codec.gd index ed31997..3b0377e 100644 --- a/src/net/net_codec.gd +++ b/src/net/net_codec.gd @@ -184,7 +184,7 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed body.put_u16(clampi(int(ev["dmg"]), 0, 65535)) body.put_u16(clampi(int(ev["hp"]), 0, 65535)) SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \ - SimEvent.Type.ESCAPE_CANCELLED: + SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED: body.put_u32(ev["peer"]) SimEvent.Type.PLAYER_RESPAWNED: body.put_u32(ev["peer"]) @@ -235,7 +235,7 @@ static func decode_events(data: PackedByteArray) -> Dictionary: ev["dmg"] = b.get_u16() ev["hp"] = b.get_u16() SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \ - SimEvent.Type.ESCAPE_CANCELLED: + SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED: ev["peer"] = b.get_u32() SimEvent.Type.PLAYER_RESPAWNED: ev["peer"] = b.get_u32() diff --git a/src/net/protocol.gd b/src/net/protocol.gd index 85b6c51..4c8607e 100644 --- a/src/net/protocol.gd +++ b/src/net/protocol.gd @@ -7,7 +7,10 @@ extends RefCounted ## roster message. ## 3: dropped the post-hit invulnerability flag, added the respawn-lockout byte ## to each player record. -const VERSION := 3 +## 4: added SimEvent.Type.PLAYER_FIRED. It was inserted mid-enum, which shifts +## the wire value of every event after it -- a mismatched client would +## mis-decode every hit and death, so the handshake has to reject it. +const VERSION := 4 const DEFAULT_PORT := 27015 const MAX_CLIENTS := 32 diff --git a/src/sim/sim_events.gd b/src/sim/sim_events.gd index 5be3048..0a39aa4 100644 --- a/src/sim/sim_events.gd +++ b/src/sim/sim_events.gd @@ -8,6 +8,11 @@ extends RefCounted enum Type { BULLET_SPAWN, ## uid, pos, vel, radius, life, kind, team, accel, turn BULLET_DESPAWN, ## uid -- early death only; expiry is derived on both sides + ## A player's weapon went off. Distinct from BULLET_SPAWN on purpose: a + ## bullet born inside an enemy is resolved and removed in the same tick, so + ## no spawn event is ever emitted for it -- and the shot still happened, and + ## still needs to be heard. + PLAYER_FIRED, ## peer PLAYER_HIT, ## peer, damage, hp PLAYER_DIED, ## peer PLAYER_RESPAWNED, ## peer, pos diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index 7abc367..8d81ad5 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -222,6 +222,7 @@ func _take_input(p: SimPlayer) -> InputFrame: func _fire_player_shot(p: SimPlayer) -> void: p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN + events.append({"t": SimEvent.Type.PLAYER_FIRED, "peer": p.peer_id}) var dir := Vector2.RIGHT.rotated(p.aim) pool.spawn( p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET, diff --git a/src/view/art.gd b/src/view/art.gd index 412c150..7c5e1bf 100644 --- a/src/view/art.gd +++ b/src/view/art.gd @@ -71,8 +71,10 @@ const BARRICADE := Rect2(16, 32, 16, 16) # a banner: low, readable, see-over 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 +## Seconds per actor animation frame. Time-based rather than frame-counted -- +## counting _process calls made every animation run at whatever the machine's +## framerate happened to be, which at 240fps was four times too fast. +const FRAME_SECONDS := 0.26 ## EnemyDef.visual -> first idle frame. Order matches Content: drifter, turret, ## stalker, practice dummy. @@ -85,17 +87,24 @@ const ENEMY_IDLE: Array[Rect2] = [ 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. +# assets/local/bullets.png is composed by tools/build_local_assets.py: 8 frames +# across, one row per SimConfig.KIND_*. The raw pack could not be used directly +# because its layout is the opposite of what it looks like -- each of its 8 +# files is one animation frame, and a column within a file is a COLOUR variant, +# not the next frame. Walking columns animates the palette and leaves the shape +# still, which is exactly what it looked like on screen. const BULLET_CELL := 16.0 -const BULLET_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 +const BULLET_FRAMES := 8 +## Seconds per bullet animation frame. +const BULLET_FRAME_SECONDS := 0.08 + + +## Region for a bullet kind at an animation frame. +static func bullet_region(kind: int, frame_index: int) -> Rect2: + return Rect2( + Vector2(float(frame_index % BULLET_FRAMES), float(kind)) * BULLET_CELL, + Vector2(BULLET_CELL, BULLET_CELL)) + # --- Impact effect ---------------------------------------------------------- const IMPACT_CELL := 64.0 @@ -116,10 +125,11 @@ 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) +## Which idle/run frame to draw. [param seconds] is elapsed wall time, so the +## animation runs at the same speed on any machine. Offset by an actor id so a +## room full of the same enemy does not breathe in unison. +static func anim_frame(seconds: float, offset: int, frames: int = ACTOR_FRAMES) -> int: + return posmod(int(seconds / FRAME_SECONDS) + offset, frames) ## Pick a floor variant from tile position -- stable, so floors do not shimmer diff --git a/src/view/bullet_renderer.gd b/src/view/bullet_renderer.gd index 6249fd7..a2941b3 100644 --- a/src/view/bullet_renderer.gd +++ b/src/view/bullet_renderer.gd @@ -21,7 +21,7 @@ const _FALLBACK_COLOURS: Array[Color] = [ var _layers: Array[MultiMeshInstance2D] = [] ## Advances the bullets' own animation, independent of the simulation. -var _anim_tick: int = 0 +var _anim_time: float = 0.0 ## True when the licence-restricted bullet sheet is absent and we are drawing ## generated dots instead. See Art.bullets_texture(). var _fallback: bool = false @@ -31,11 +31,12 @@ func _ready() -> void: var sheet := Art.bullets_texture() _fallback = sheet == null var art: Texture2D = sheet if sheet != null else _make_dot_texture() + var kinds := SimConfig.KIND_HEAVY + 1 if _fallback: GameLog.info("view", "bullet sheet absent, drawing generated dots") var quad := QuadMesh.new() quad.size = Vector2.ONE - for kind in SimConfig.KIND_HEAVY + 1: + for kind in kinds: var layer := MultiMeshInstance2D.new() layer.texture = art var mm := MultiMesh.new() @@ -49,8 +50,8 @@ func _ready() -> void: _layers.append(layer) -func _process(_delta: float) -> void: - _anim_tick += 1 +func _process(delta: float) -> void: + _anim_time += delta if not _fallback: _apply_frame() @@ -71,22 +72,18 @@ func _make_dot_texture() -> ImageTexture: return ImageTexture.create_from_image(img) -## 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. +## Point each layer's texture at this frame's row/column in the atlas. Every +## bullet in a layer shares the frame, so this is one region assignment per kind +## rather than any per-instance work. func _apply_frame() -> void: - var frame := (_anim_tick / 6) % Art.BULLET_FRAMES + var frame_index := int(_anim_time / Art.BULLET_FRAME_SECONDS) 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_texture() _layers[kind].texture = atlas - atlas.region = region + atlas.region = Art.bullet_region(kind, frame_index) func render_pool(pool: BulletPool) -> void: diff --git a/src/view/world_view.gd b/src/view/world_view.gd index 2be1732..d65cb3e 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -28,12 +28,13 @@ const ENEMY_COLORS := [ @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 +## Elapsed seconds, driving sprite animation. Wall time rather than a frame +## count, so animations run at the same speed regardless of framerate. +var _anim_time: float = 0.0 -func _process(_delta: float) -> void: - _anim_tick += 1 +func _process(delta: float) -> void: + _anim_time += delta client = Net.client if client != null: bullets.render_pool(client.world.pool) @@ -129,7 +130,7 @@ func _draw_portal() -> void: func _draw_enemy(e: Dictionary) -> void: 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"]))) + var src := Art.frame(first, Art.anim_frame(_anim_time, int(e["id"]))) _draw_sprite(Art.TILESET, src, e["pos"]) # Health ring stays: the sprite says what it is, the ring says how hurt. var r: float = e["radius"] @@ -142,7 +143,7 @@ func _draw_boss() -> void: if b.is_empty() or not _visible(b["pos"]): return var pos: Vector2 = b["pos"] - var src := Art.frame(Art.BOSS_IDLE, Art.anim_frame(_anim_tick, 0)) + var src := Art.frame(Art.BOSS_IDLE, Art.anim_frame(_anim_time, 0)) _draw_sprite(Art.TILESET, src, pos) var r: float = client.boss_def.radius if client.boss_def != null else 42.0 draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.5), 2.0) @@ -184,7 +185,7 @@ func _draw_grace_ring(pos: Vector2) -> void: ## 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)) + var src := Art.frame(strip, Art.anim_frame(_anim_time, 0)) # Faces the way you aim, which is the whole point of a twin-stick. var flip := absf(wrapf(aim, -PI, PI)) > PI * 0.5 _draw_sprite(Art.TILESET, src, pos, col if not alive else Color.WHITE, flip) @@ -199,12 +200,16 @@ func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool, moving: bool 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) + if not flip_h: + draw_texture_rect_region(tex, Rect2(at - size * 0.5, size), src, modulate) + return + # Mirror through the canvas transform, scaled about the sprite's own centre. + # The obvious alternative -- a Rect2 with negative width -- does not mirror: + # the draw call normalises the rect, so the sprite kept its orientation and + # merely jumped sideways by its own width. + draw_set_transform(at, 0.0, Vector2(-1.0, 1.0)) + draw_texture_rect_region(tex, Rect2(-size * 0.5, size), src, modulate) + draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) ## The escape channel is drawn on the player, not just in the HUD, so other diff --git a/tests/unit/test_art.gd b/tests/unit/test_art.gd index c758eb6..1635e85 100644 --- a/tests/unit/test_art.gd +++ b/tests/unit/test_art.gd @@ -52,19 +52,24 @@ func test_every_bullet_frame_is_inside_the_sheet() -> void: if sheet == null: pass_test("bullet sheet not present locally; nothing to validate") return - for kind in Art.BULLET_CELLS.size(): - var cell: Vector2i = Art.BULLET_CELLS[kind] + for kind in SimConfig.KIND_HEAVY + 1: 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)) + var r := Art.bullet_region(kind, n) assert_true(_fits(sheet, r), "bullet kind %d frame %d is outside the sheet at %s" % [kind, n, r]) -## 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) +## The atlas has one row per kind. A short sheet would silently draw the wrong +## bullet rather than error. +func test_the_bullet_atlas_has_a_row_for_every_kind() -> void: + var sheet := Art.bullets_texture() + if sheet == null: + pass_test("bullet sheet not present locally") + return + var needed := float(SimConfig.KIND_HEAVY + 1) * Art.BULLET_CELL + assert_gte(float(sheet.get_height()), needed, + "atlas is too short for %d bullet kinds" % (SimConfig.KIND_HEAVY + 1)) + assert_gte(float(sheet.get_width()), Art.BULLET_FRAMES * Art.BULLET_CELL) ## One entry per EnemyDef.visual actually used by content, or an enemy draws as diff --git a/tests/unit/test_server_authority.gd b/tests/unit/test_server_authority.gd index fe6f48c..5a0ef54 100644 --- a/tests/unit/test_server_authority.gd +++ b/tests/unit/test_server_authority.gd @@ -69,6 +69,33 @@ func test_input_flood_cannot_grow_the_queue_without_bound() -> void: "a flood of inputs must not become unbounded server memory") +## The shot sound used to ride on BULLET_SPAWN, and a bullet born inside an +## enemy is resolved and removed within the same tick -- so no spawn event was +## ever emitted and the shot was silent. Firing is now its own event. +func test_firing_is_reported_even_when_the_bullet_dies_instantly() -> void: + var p: SimPlayer = world.players[PEER] + p.pos = Vector2.ZERO + p.aim = 0.0 + # The inert practice dummy, sitting exactly where the muzzle is, so the + # bullet is consumed on the tick it is created. Deliberately not a turret: + # a turret's own ring would fill the event list with its spawns and the + # setup check below would pass for the wrong reason. + world.spawn_enemy(Content.dummy(), Vector2(SimConfig.PLAYER_MUZZLE_OFFSET, 0.0)) + _drive(1, Vector2.ZERO, InputFrame.BTN_FIRE) + assert_eq(_events_of(SimEvent.Type.PLAYER_FIRED).size(), 1, + "the shot happened and has to be reported") + assert_eq(_events_of(SimEvent.Type.BULLET_SPAWN).size(), 0, + "setup check: the bullet really did die on the tick it spawned") + + +func test_firing_is_reported_once_per_shot() -> void: + _drive(60, Vector2.ZERO, InputFrame.BTN_FIRE) + var fired := _events_of(SimEvent.Type.PLAYER_FIRED).size() + var expected := 60 / SimConfig.PLAYER_FIRE_COOLDOWN + assert_almost_eq(float(fired), float(expected), 1.0, + "one report per shot the cooldown actually allowed") + + func test_fire_rate_is_enforced_by_the_server() -> void: # Hold fire every single tick; the server still applies its own cooldown. _drive(60, Vector2.ZERO, InputFrame.BTN_FIRE) diff --git a/tools/build_local_assets.py b/tools/build_local_assets.py new file mode 100755 index 0000000..6e7aab2 --- /dev/null +++ b/tools/build_local_assets.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Compose the local-only sprite atlases from the raw asset packs. + +Both inputs and outputs are licence-restricted and therefore untracked (see +docs/ASSETS.md), which is why this is a build step rather than a committed file: +anyone with the packs can regenerate, and nobody without them is blocked. + + pip install pillow && python3 tools/build_local_assets.py + +The bullet pack's layout is not obvious and cost a wrong guess once: + * Each of the 8 PNGs is one ANIMATION FRAME, not one sprite sheet. + * Within a file, a row is a bullet shape and a column is a COLOUR variant. +So animating means cycling files, not walking columns -- walking columns just +cycles through palettes while the shape sits still. + +Output is one tidy atlas: 8 frames across, one row per SimConfig.KIND_*. +""" +import os +import sys + +try: + from PIL import Image +except ImportError: + sys.exit("needs pillow: pip install pillow") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SRC = os.path.join(ROOT, "assetpacks", "New_All_Fire_Bullet_Pixel_16x16", + "All_Fire_Bullet_Pixel_16x16_%02d.png") +OUT = os.path.join(ROOT, "assets", "local", "bullets.png") +CELL = 16 +FRAMES = 8 + +# (source row, source column) per bullet kind, in SimConfig.KIND_* order. +# Column selects the colour: 0 red, 1 orange, 2 amber, 3 gold, 4 bright yellow. +KINDS = [ + ("player_shot", 11, 4), # small bright dot + ("orb", 2, 1), # round orange orb + ("needle", 11, 6), # horizontal dart, drawn rotated to velocity + ("heavy", 1, 0), # big red ball +] + + +def main() -> None: + for f in range(FRAMES): + if not os.path.exists(SRC % f): + sys.exit("missing source frame: %s\n" + "The raw pack is untracked; see assets/local/README.md." + % (SRC % f)) + out = Image.new("RGBA", (CELL * FRAMES, CELL * len(KINDS)), (0, 0, 0, 0)) + for f in range(FRAMES): + src = Image.open(SRC % f).convert("RGBA") + for k, (_name, row, col) in enumerate(KINDS): + box = (col * CELL, row * CELL, (col + 1) * CELL, (row + 1) * CELL) + out.paste(src.crop(box), (f * CELL, k * CELL)) + os.makedirs(os.path.dirname(OUT), exist_ok=True) + out.save(OUT) + print("wrote %s (%dx%d): %d kinds x %d frames" + % (OUT, out.width, out.height, len(KINDS), FRAMES)) + for k, (name, row, col) in enumerate(KINDS): + print(" row %d = %-12s (source row %2d, colour col %d)" % (k, name, row, col)) + + +if __name__ == "__main__": + main()