Fix black wall tops, pillar art, player anchor, and whole-sheet bullets
ci / verify (push) Successful in 46s

Bullets rendered as the entire sprite sheet, which was the clue that named the
cause: a MultiMeshInstance2D cannot show a sub-region of a texture. Its mesh UVs
run 0..1 across whatever texture it is handed, and an AtlasTexture does not
help -- the draw resolves to the underlying atlas and the region is discarded.
Frames are now pre-sliced into 32 standalone 16x16 textures at startup, so each
frame is one texture assignment per kind and no region is involved. My previous
two attempts fixed the atlas contents while the renderer was structurally
incapable of showing one cell of it.

Black voids above walls: wall_top_mid has 12 of its 16 rows transparent. It is a
cap meant to be laid over a wall, not a tile, and drawn alone it was three
quarters nothing. Floor is now drawn beneath every tile so transparency shows
ground rather than clear colour, the wall face is drawn from the opaque
wall_mid, and the cap is overlaid only where open floor lies above.

Pillars used a wall segment and read as stray blocks; they now use the column
tile, which looks like something you walk around.

The player hitbox appeared at the head because it was: the knight occupies rows
8..27 of a 28-row cell, so anchoring on the cell centre put the draw origin
about 4px above the body's centre. Added a per-sprite anchor offset that cancels
it, with a test that recomputes the offset from the art so a re-cut sheet fails
rather than silently misplacing the character.

Tests pin the two tileset facts that caused this: the cap is mostly transparent
(so it may never be drawn alone) and the wall face is fully opaque (so it may).

166 tests. check.sh, test.sh and smoke.sh pass.
This commit is contained in:
2026-09-04 00:22:01 +02:00
parent 801f328093
commit ff5e527ad4
4 changed files with 117 additions and 22 deletions
+14 -1
View File
@@ -60,8 +60,15 @@ const FLOOR_VARIANTS: Array[Rect2] = [
Rect2(16, 80, 16, 16), Rect2(32, 80, 16, 16), Rect2(48, 80, 16, 16),
Rect2(16, 96, 16, 16), Rect2(32, 96, 16, 16),
]
## The solid wall face. Fully opaque, safe to draw on its own.
const WALL := Rect2(32, 16, 16, 16)
const WALL_TOP := Rect2(32, 0, 16, 16)
## The wall's top edge -- 12 of its 16 rows are TRANSPARENT, because it is a cap
## meant to be laid over a wall tile, not a tile in its own right. Drawing it
## alone produced the black void along the top of every wall.
const WALL_CAP := Rect2(32, 0, 16, 16)
## Column mid-section: an actual pillar rather than a wall segment, so a pillar
## in open floor reads as something you walk around.
const PILLAR := Rect2(80, 96, 16, 16)
const PIT := Rect2(96, 144, 16, 16) # "hole"
const BARRICADE := Rect2(16, 32, 16, 16) # a banner: low, readable, see-over
@@ -70,6 +77,12 @@ const BARRICADE := Rect2(16, 32, 16, 16) # a banner: low, readable, see-over
# rect and a count are needed.
const PLAYER_IDLE := Rect2(128, 100, 16, 28)
const PLAYER_RUN := Rect2(192, 100, 16, 28)
## The knight fills rows 8..27 of its 28-row cell, so the cell's centre sits
## about 4px above the body's. Anchoring on the cell put the hitbox level with
## the character's head. Shift the drawn sprite up by the difference so the
## body -- and therefore the hitbox drawn at the same point -- lands on the
## middle of the character. Source pixels; scaled with the sprite.
const PLAYER_ANCHOR := Vector2(0.0, -4.0)
const ACTOR_FRAMES := 4
## Seconds per actor animation frame. Time-based rather than frame-counted --
## counting _process calls made every animation run at whatever the machine's
+30 -10
View File
@@ -20,6 +20,8 @@ const _FALLBACK_COLOURS: Array[Color] = [
]
var _layers: Array[MultiMeshInstance2D] = []
## [kind][frame] -> standalone texture. See _slice_frames().
var _frames: Array[Array] = []
## Advances the bullets' own animation, independent of the simulation.
var _anim_time: float = 0.0
## True when the licence-restricted bullet sheet is absent and we are drawing
@@ -32,6 +34,9 @@ func _ready() -> void:
_fallback = sheet == null
var art: Texture2D = sheet if sheet != null else _make_dot_texture()
var kinds := SimConfig.KIND_HEAVY + 1
if not _fallback:
_slice_frames(sheet, kinds)
art = _frames[0][0]
if _fallback:
GameLog.info("view", "bullet sheet absent, drawing generated dots")
var quad := QuadMesh.new()
@@ -72,18 +77,33 @@ func _make_dot_texture() -> ImageTexture:
return ImageTexture.create_from_image(img)
## 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.
## Slice the atlas into one standalone texture per (kind, frame).
##
## A MultiMeshInstance2D cannot show a sub-region of a texture. Its mesh UVs run
## 0..1 across whatever texture it is given, and handing it an AtlasTexture does
## not help -- the draw resolves to the underlying atlas and the region is
## ignored, so every bullet rendered as the entire sheet. Pre-slicing costs 32
## tiny textures once at startup and makes each frame a single assignment.
func _slice_frames(sheet: Texture2D, kinds: int) -> void:
var img := sheet.get_image()
for kind in kinds:
var per_kind: Array[Texture2D] = []
for f in Art.BULLET_FRAMES:
var r := Art.bullet_region(kind, f)
var cell := Image.create(int(r.size.x), int(r.size.y), false, img.get_format())
cell.blit_rect(img, Rect2i(r), Vector2i.ZERO)
per_kind.append(ImageTexture.create_from_image(cell))
_frames.append(per_kind)
## Swap each layer to this frame's texture. One assignment per kind per frame;
## every bullet in a layer shares it, so there is no per-instance work.
func _apply_frame() -> void:
var frame_index := int(_anim_time / Art.BULLET_FRAME_SECONDS)
if _frames.is_empty():
return
var f := int(_anim_time / Art.BULLET_FRAME_SECONDS) % Art.BULLET_FRAMES
for kind in _layers.size():
var atlas := _layers[kind].texture as AtlasTexture
if atlas == null:
atlas = AtlasTexture.new()
atlas.atlas = Art.bullets_texture()
_layers[kind].texture = atlas
atlas.region = Art.bullet_region(kind, frame_index)
_layers[kind].texture = _frames[kind][f]
func render_pool(pool: BulletPool) -> void:
+20 -10
View File
@@ -131,19 +131,24 @@ func _draw_terrain() -> void:
func _draw_tile(kind: MapGrid.Kind, centre: Vector2, tx: int, ty: int) -> void:
var dest := Rect2(centre - Vector2(MapGrid.TILE, MapGrid.TILE) * 0.5,
Vector2(MapGrid.TILE, MapGrid.TILE))
# Floor goes under everything. Several tiles in this set have transparent
# regions -- the wall cap is three-quarters empty -- and without ground
# beneath them those pixels showed through to the clear colour as black.
draw_texture_rect_region(Art.TILESET, dest, Art.floor_for(tx, ty))
match kind:
MapGrid.Kind.FLOOR:
draw_texture_rect_region(Art.TILESET, dest, Art.floor_for(tx, ty))
MapGrid.Kind.WALL, MapGrid.Kind.PILLAR:
# Walls that have open floor above them show their capped top, so a
# room reads as a room rather than as a field of identical blocks.
var open_above := not client.world.map.blocks_sight(tx, ty - 1)
draw_texture_rect_region(Art.TILESET, dest,
Art.WALL_TOP if open_above else Art.WALL)
pass
MapGrid.Kind.WALL:
draw_texture_rect_region(Art.TILESET, dest, Art.WALL)
# Cap the face where open ground lies above, so a wall run reads as
# having a top rather than being a flat band.
if not client.world.map.blocks_sight(tx, ty - 1):
draw_texture_rect_region(Art.TILESET, dest, Art.WALL_CAP)
MapGrid.Kind.PILLAR:
draw_texture_rect_region(Art.TILESET, dest, Art.PILLAR)
MapGrid.Kind.PIT:
draw_texture_rect_region(Art.TILESET, dest, Art.PIT)
MapGrid.Kind.BARRICADE:
draw_texture_rect_region(Art.TILESET, dest, Art.floor_for(tx, ty))
draw_texture_rect_region(Art.TILESET, dest, Art.BARRICADE)
@@ -228,7 +233,8 @@ func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool, moving: bool
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)
_draw_sprite(Art.TILESET, src, pos, col if not alive else Color.WHITE, flip,
Art.PLAYER_ANCHOR)
if alive:
var dir := Vector2.RIGHT.rotated(aim)
var r := SimConfig.PLAYER_VISUAL_RADIUS
@@ -238,8 +244,12 @@ func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool, moving: bool
## Draw an atlas region centred on a world position, scaled to match the art's
## intended pixel size.
func _draw_sprite(tex: Texture2D, src: Rect2, at: Vector2,
modulate: Color = Color.WHITE, flip_h: bool = false) -> void:
modulate: Color = Color.WHITE, flip_h: bool = false,
anchor: Vector2 = Vector2.ZERO) -> void:
var size := src.size * Art.SCALE
# [param anchor] shifts the art relative to the point it is drawn at, for
# sprites whose subject is not centred in its cell.
at += anchor * Art.SCALE
if not flip_h:
draw_texture_rect_region(tex, Rect2(at - size * 0.5, size), src, modulate)
return
+53 -1
View File
@@ -29,7 +29,7 @@ func test_the_game_runs_without_the_restricted_art() -> void:
func test_terrain_rects_are_inside_the_tileset() -> void:
var rects: Array[Rect2] = [Art.WALL, Art.WALL_TOP, Art.PIT, Art.BARRICADE]
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)
@@ -112,3 +112,55 @@ func test_the_shoot_sound_is_shorter_than_the_fire_cooldown_allows_to_overlap()
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")