diff --git a/src/view/art.gd b/src/view/art.gd index 7c5e1bf..27ada7d 100644 --- a/src/view/art.gd +++ b/src/view/art.gd @@ -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 diff --git a/src/view/bullet_renderer.gd b/src/view/bullet_renderer.gd index a2941b3..a3d2a49 100644 --- a/src/view/bullet_renderer.gd +++ b/src/view/bullet_renderer.gd @@ -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: diff --git a/src/view/world_view.gd b/src/view/world_view.gd index 715ac8c..f5ef422 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -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 diff --git a/tests/unit/test_art.gd b/tests/unit/test_art.gd index 1635e85..6ec4e3c 100644 --- a/tests/unit/test_art.gd +++ b/tests/unit/test_art.gd @@ -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")