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()