Fix bullet atlas, animation speed, sprite flip, fire rate, silent shots
ci / verify (push) Successful in 46s

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.
This commit is contained in:
2026-09-04 00:03:04 +02:00
parent 3586555aea
commit afe76c22ce
14 changed files with 234 additions and 85 deletions
+43 -22
View File
@@ -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).
+17 -7
View File
@@ -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
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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:
+2 -2
View File
@@ -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()
+4 -1
View File
@@ -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
+5
View File
@@ -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
+1
View File
@@ -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,
+26 -16
View File
@@ -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
+10 -13
View File
@@ -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:
+18 -13
View File
@@ -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
+13 -8
View File
@@ -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
+27
View File
@@ -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)
+64
View File
@@ -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()