e0c1e0d5c6
ci / verify (push) Successful in 49s
Boss movement is a property of the PHASE, not of the boss -- a fight that
stands still and then starts hunting you is one boss with two phases. Four
modes (STATIC, ORBIT, CHASE, WAYPOINTS) handled generically in
SimWorld._move_boss, so a boss that moves is still data. BossDef.stationary
is gone rather than kept beside the phases: a flag claiming the boss stood
still while a phase walked around would be a second source of truth and the
wrong one, so moves() is derived.
CHASE holds a distance instead of closing, because a boss standing on top of
you is a boss whose bullets cannot be read. Waypoints are fractions of the
arena so one phase works in rooms of different sizes. Every mode is speed
clamped in one place -- ORBIT computes an absolute destination and would
otherwise snap onto its circle on the first tick -- and movement slides
against geometry so a boss cannot walk through the pillars its own arena was
designed around.
The room clamp moved to after movement, where it is finally load-bearing. It
was a no-op while every boss stood still, which is exactly when an invariant
is cheapest to establish: boss rooms deliberately do not lock, so walking out
is always an escape, and that only holds if the boss cannot follow.
TelegraphedStrikeEmitter marks spots and fills them a moment later. The moment
between is the feature: a burst at your feet is a coin flip, the same burst
with a second of notice is a question. It stays stateless like every other
emitter -- they are shared resources and two bosses of the same kind must not
stomp each other -- so strike positions are derived from the volley number and
a test asserts the burst lands where the marker promised. Markers are drawn
through fog and through walls, unlike everything else in the view, because a
warning you cannot see is an unavoidable hit with extra steps.
The Cantor of the Vault fights in the choir vault: static, then a four-corner
circuit, then a chase, then orbiting while marking. It exists to prove the
format stretched, and a test asserts it uses both new mechanisms.
Which boss a run has now comes from its SEED rather than its depth. Depth is a
dev flag nothing in play raises, so the arena was keyed to something no player
can change and the second boss was unreachable in an actual game.
Two things found while finishing:
- tools/export_content.gd had a hand-maintained boss list and had already
gone stale, silently not writing the Cantor. Content.ALL_ENEMIES and
ALL_BOSSES now feed the export tool, the renderer and five tests that each
kept their own copy.
- diag_loot failed intermittently after another diagnostic. Taking over from
the bot cleared its input queue but not its HELD input, so a starved server
coasted on the bot's last movement vector for half a second and walked the
player off the item it had been placed on. The press arrived correctly,
which is why "the press reached the simulation" passed while everything it
should have caused failed.
check.sh clean, 409 tests, SMOKE PASS (19 assertions), all four diagnostics
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
6.3 KiB
GDScript
166 lines
6.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_CAP, Art.PIT, Art.BARRICADE, Art.PILLAR]
|
|
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.ALL_ENEMIES:
|
|
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")
|
|
|
|
|
|
## The wall cap is mostly transparent by design -- it is an overlay for the top
|
|
## edge of a wall, not a tile. Drawing it on its own is what produced a black
|
|
## void along every wall, so this records why it must never be used alone.
|
|
func test_the_wall_cap_is_mostly_transparent() -> void:
|
|
var img := Art.TILESET.get_image()
|
|
var clear := 0
|
|
for y in int(Art.WALL_CAP.size.y):
|
|
for x in int(Art.WALL_CAP.size.x):
|
|
var a := img.get_pixel(int(Art.WALL_CAP.position.x) + x,
|
|
int(Art.WALL_CAP.position.y) + y).a
|
|
if a < 0.15:
|
|
clear += 1
|
|
assert_gt(clear, 100,
|
|
"if this ever becomes opaque the cap could stand alone, but while it " +
|
|
"is mostly empty it must always be drawn over floor and wall")
|
|
|
|
|
|
## The wall face, by contrast, has to be solid: it is the one tile drawn with
|
|
## nothing but floor beneath it.
|
|
func test_the_wall_face_is_opaque() -> void:
|
|
var img := Art.TILESET.get_image()
|
|
var clear := 0
|
|
for y in int(Art.WALL.size.y):
|
|
for x in int(Art.WALL.size.x):
|
|
if img.get_pixel(int(Art.WALL.position.x) + x,
|
|
int(Art.WALL.position.y) + y).a < 0.15:
|
|
clear += 1
|
|
assert_eq(clear, 0, "the wall face must not have holes in it")
|
|
|
|
|
|
## The player anchor exists because the knight is bottom-heavy in its cell.
|
|
## If the art is ever re-cut, this catches the anchor silently becoming wrong.
|
|
func test_the_player_anchor_matches_where_the_knight_actually_is() -> void:
|
|
var img := Art.TILESET.get_image()
|
|
var top := -1
|
|
var bottom := -1
|
|
for y in int(Art.PLAYER_IDLE.size.y):
|
|
for x in int(Art.PLAYER_IDLE.size.x):
|
|
if img.get_pixel(int(Art.PLAYER_IDLE.position.x) + x,
|
|
int(Art.PLAYER_IDLE.position.y) + y).a > 0.15:
|
|
if top < 0:
|
|
top = y
|
|
bottom = y
|
|
break
|
|
assert_gte(top, 0, "the idle frame should not be empty")
|
|
var body_centre := (float(top) + float(bottom)) * 0.5
|
|
var cell_centre := Art.PLAYER_IDLE.size.y * 0.5
|
|
assert_almost_eq(Art.PLAYER_ANCHOR.y, cell_centre - body_centre, 1.0,
|
|
"anchor should cancel the offset between the cell's centre and the " +
|
|
"character's, or the hitbox sits away from the body")
|