Files
transcience/tests/unit/test_art.gd
T
claude afe76c22ce
ci / verify (push) Successful in 46s
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.
2026-09-04 00:03:04 +02:00

115 lines
4.3 KiB
GDScript

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_the_committed_texture_loads() -> void:
assert_not_null(Art.TILESET)
assert_gt(Art.TILESET.get_width(), 0)
## The licence-restricted sheets are absent from a clean clone by design, so
## their absence must be a supported state rather than a failure. When they are
## present locally, the rect tests below still validate them.
func test_the_game_runs_without_the_restricted_art() -> void:
var bullets := Art.bullets_texture()
if bullets == null:
assert_true(true, "no bullet sheet: renderer falls back to generated dots")
else:
assert_gt(bullets.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:
var sheet := Art.bullets_texture()
if sheet == null:
pass_test("bullet sheet not present locally; nothing to validate")
return
for kind in SimConfig.KIND_HEAVY + 1:
for n in Art.BULLET_FRAMES:
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])
## 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
## 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 impact := Art.impact_texture()
if impact == null:
pass_test("impact sheet not present locally; nothing to validate")
return
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(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")