From 801f328093bf4fa9b75dc3b39caf8bbf82ea2169 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 4 Sep 2026 00:12:37 +0200 Subject: [PATCH] Fix idle/run animation flicker and bullet cell bleed; add F1 hitbox overlay The ship looked like it played both animations at once because it effectively did. is_moving() read the tail of `pending`, which _reconcile drains on every snapshot -- so it returned false about 20 times a second and the sprite alternated between the run and idle strips. Now reads the last sampled input, which survives the queue being emptied. Bullets were still wrong for a reason my earlier ASCII check could not see: I had dumped alpha only, and this pack animates as a colour shimmer over a fixed silhouette, so identical-looking frames told me nothing. The actual defect was that several sprites overflow their 16px cell and bleed into the neighbour below -- the first "heavy" I picked dragged in a solid slice of the sprite above it. Cells are re-picked to ones with empty borders in every frame, and build_local_assets.py now asserts that rather than trusting the choice, so a future pick that bleeds fails at build time instead of looking like a rendering bug. Added a hitbox overlay on F1 (src/view/debug_draw.gd). It draws what the simulation actually collides against over what is drawn: player hitbox against sprite radius, the muzzle point, enemy and boss radii, aggro rings, every live bullet's radius, and terrain outlined by which of the three flags each tile sets -- so a pit that stops feet but not bullets looks different from a wall. It deliberately ignores fog, since hiding half the evidence would defeat the point, and it reads every number from SimConfig/MapGrid/Content rather than keeping its own copies, or it would just confirm its own mistakes. Nearly every "that looked wrong" report in this project has been art and simulation disagreeing, and each took a round trip to diagnose. This makes that class of bug visible directly. 163 tests. check.sh, test.sh and smoke.sh pass. --- project.godot | 5 ++ src/net/client_runtime.gd | 11 +++- src/ui/hud.gd | 4 +- src/view/debug_draw.gd | 84 +++++++++++++++++++++++++++++++ src/view/debug_draw.gd.uid | 1 + src/view/world_view.gd | 40 +++++++++++++++ tests/unit/test_debug_draw.gd | 60 ++++++++++++++++++++++ tests/unit/test_debug_draw.gd.uid | 1 + tools/build_local_assets.py | 22 ++++++-- tools/setup_input_map.gd | 1 + 10 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 src/view/debug_draw.gd create mode 100644 src/view/debug_draw.gd.uid create mode 100644 tests/unit/test_debug_draw.gd create mode 100644 tests/unit/test_debug_draw.gd.uid diff --git a/project.godot b/project.godot index 32ae6c5..f36e392 100644 --- a/project.godot +++ b/project.godot @@ -84,6 +84,11 @@ system_menu={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +debug_overlay={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194332,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [rendering] diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 646b32e..37adf63 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -32,6 +32,9 @@ var input_tick: int = 0 var predicted_pos := Vector2.ZERO var aim: float = 0.0 var pending: Array[InputFrame] = [] +## Movement from the most recent sampled input. Survives `pending` being +## drained, which is the whole point -- see is_moving(). +var _last_move := Vector2.ZERO # Authoritative mirror of the local player. var my_hp: int = SimConfig.PLAYER_MAX_HP @@ -106,6 +109,7 @@ func _physics_process(delta: float) -> void: input_tick += 1 var frame := _sample_input() + _last_move = frame.move pending.append(frame) # Only enough history to cover the worst reconciliation window. while pending.size() > SimConfig.INPUT_MAX_AGE: @@ -207,8 +211,13 @@ func _resync_input_tick(server_tick: int, why: String) -> void: ## Whether the local player is moving, for choosing a run vs idle animation. ## View-only; nothing in the simulation asks. +## +## Reads the last sampled input rather than the tail of `pending`, which is +## drained on every reconcile: derived from `pending` this flickered false ~20 +## times a second, so the ship alternated between its run and idle strips and +## looked like both were playing at once. func is_moving() -> bool: - return not pending.is_empty() and pending[pending.size() - 1].move.length_squared() > 0.04 + return _last_move.length_squared() > 0.04 func on_map_chunks(from_instance: int, data: PackedByteArray) -> void: diff --git a/src/ui/hud.gd b/src/ui/hud.gd index 812d18a..f10b4a4 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -102,8 +102,8 @@ func _hint_text() -> String: if not client.my_alive: return "DOWN" if client.instance_kind == Protocol.InstanceKind.LOBBY: - return "WASD move mouse aim LMB fire E on the ring to enter a dungeon Esc menu" - return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu" + return "WASD move mouse aim LMB fire E on the ring to enter a dungeon Esc menu F1 hitboxes" + return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu F1 hitboxes" func _draw_hud() -> void: diff --git a/src/view/debug_draw.gd b/src/view/debug_draw.gd new file mode 100644 index 0000000..0141ca0 --- /dev/null +++ b/src/view/debug_draw.gd @@ -0,0 +1,84 @@ +class_name DebugDraw +extends RefCounted +## Hitbox and geometry overlay, toggled with F1. +## +## Exists because almost every "that looked wrong" bug in this project so far +## has been a mismatch between what is drawn and what the simulation actually +## uses -- a sprite larger than its hitbox, a muzzle inside the ship, bullets +## spawning off-centre. Those are invisible until you draw both and compare. +## +## Every number here is read from the same constants and server state the +## simulation uses. It must never compute its own version of anything, or it +## would confirm its own mistakes. + +const COL_HITBOX := Color(1.0, 0.25, 0.35, 0.9) # what actually collides +const COL_VISUAL := Color(0.4, 0.8, 1.0, 0.5) # what is drawn +const COL_BULLET := Color(1.0, 0.9, 0.2, 0.85) +const COL_WALL := Color(1.0, 0.35, 0.2, 0.55) +const COL_BULLET_WALL := Color(1.0, 0.6, 0.1, 0.5) +const COL_PIT := Color(0.4, 0.5, 1.0, 0.5) +const COL_AGGRO := Color(1.0, 0.5, 0.2, 0.25) +const COL_MUZZLE := Color(0.4, 1.0, 0.6, 0.9) +const COL_LOS := Color(0.5, 1.0, 0.8, 0.18) + + +## Terrain: one outline per tile, coloured by which of the three flags it sets. +## Drawn from MapGrid's own flag tables, so a tile that blocks bullets but not +## sight is visibly different rather than something you have to take on trust. +static func draw_terrain(c: CanvasItem, map: MapGrid, centre: Vector2, radius: float) -> void: + if map == null: + return + var lo := map.to_tile(centre - Vector2(radius, radius)) + var hi := map.to_tile(centre + Vector2(radius, radius)) + for ty in range(lo.y, hi.y + 1): + for tx in range(lo.x, hi.x + 1): + var kind := map.at(tx, ty) + if kind == MapGrid.Kind.FLOOR or kind == MapGrid.Kind.UNKNOWN: + continue + var at := map.tile_centre(tx, ty) - Vector2(MapGrid.TILE, MapGrid.TILE) * 0.5 + var r := Rect2(at, Vector2(MapGrid.TILE, MapGrid.TILE)) + if map.blocks_move(tx, ty): + c.draw_rect(r.grow(-1.0), COL_WALL, false, 1.0) + if map.blocks_bullet(tx, ty): + c.draw_rect(r.grow(-4.0), COL_BULLET_WALL, false, 1.0) + if not map.blocks_sight(tx, ty) and map.blocks_move(tx, ty): + # See-over tiles: pits and barricades. Marked so a sightline that + # "should" have been blocked is explicable. + c.draw_line(r.position, r.end, COL_PIT, 1.0) + + +## The two circles that matter and are easy to confuse: the hitbox the server +## resolves against, and the sprite the player judges by. +static func draw_player(c: CanvasItem, pos: Vector2, aim: float) -> void: + c.draw_arc(pos, SimConfig.PLAYER_VISUAL_RADIUS, 0.0, TAU, 24, COL_VISUAL, 1.0) + c.draw_arc(pos, SimConfig.PLAYER_RADIUS, 0.0, TAU, 20, COL_HITBOX, 1.5) + var dir := Vector2.RIGHT.rotated(aim) + var muzzle := pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET + c.draw_line(pos, muzzle, COL_MUZZLE, 1.0) + c.draw_circle(muzzle, 2.0, COL_MUZZLE) + + +static func draw_enemy(c: CanvasItem, pos: Vector2, radius: float, aggro: float) -> void: + c.draw_arc(pos, radius, 0.0, TAU, 24, COL_HITBOX, 1.5) + if aggro > 0.0: + c.draw_arc(pos, aggro, 0.0, TAU, 48, COL_AGGRO, 1.0) + + +static func draw_boss(c: CanvasItem, pos: Vector2, radius: float, room: Rect2) -> void: + c.draw_arc(pos, radius, 0.0, TAU, 48, COL_HITBOX, 2.0) + if room.size != Vector2.ZERO: + c.draw_rect(room, COL_AGGRO, false, 1.0) + + +## Bullet hitboxes are the ones most likely to disagree with the art, because +## the sprite is deliberately drawn larger than the radius that collides. +static func draw_bullets(c: CanvasItem, pool: BulletPool) -> void: + for i in pool.high_water: + if pool.alive[i] == 0: + continue + c.draw_arc(pool.pos[i], pool.radius[i], 0.0, TAU, 12, COL_BULLET, 1.0) + + +## A ring at the fog radius, so "why can I not see that" has a visible answer. +static func draw_ranges(c: CanvasItem, pos: Vector2) -> void: + c.draw_arc(pos, SimConfig.FOG_VIEW_RADIUS, 0.0, TAU, 64, COL_LOS, 1.0) diff --git a/src/view/debug_draw.gd.uid b/src/view/debug_draw.gd.uid new file mode 100644 index 0000000..e72fcdc --- /dev/null +++ b/src/view/debug_draw.gd.uid @@ -0,0 +1 @@ +uid://dd40sv1q0pba2 diff --git a/src/view/world_view.gd b/src/view/world_view.gd index d65cb3e..715ac8c 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -31,6 +31,15 @@ var client: ClientRuntime = null ## 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 +## F1 overlay: draws what the simulation collides against, over what is drawn. +var debug_hitboxes: bool = false + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("debug_overlay"): + debug_hitboxes = not debug_hitboxes + GameLog.info("view", "hitbox overlay %s" % ("on" if debug_hitboxes else "off")) + get_viewport().set_input_as_handled() func _process(delta: float) -> void: @@ -55,6 +64,37 @@ func _draw() -> void: if _visible(p["pos"]): _draw_remote_player(p) _draw_local_player() + if debug_hitboxes: + _draw_debug() + + +## Overlay showing what actually collides, on top of what is drawn. Deliberately +## ignores fog: the point is to check that art and simulation line up, and +## hiding half the evidence would defeat that. +func _draw_debug() -> void: + DebugDraw.draw_terrain(self, client.world.map, client.predicted_pos, + SimConfig.FOG_VIEW_RADIUS * 1.4) + DebugDraw.draw_bullets(self, client.world.pool) + for e in client.enemies(): + DebugDraw.draw_enemy(self, e["pos"], e["radius"], _aggro_for(int(e["visual"]))) + var b := client.boss_state() + if not b.is_empty() and client.boss_def != null: + DebugDraw.draw_boss(self, b["pos"], client.boss_def.radius, Rect2()) + for p in client.remote_players(): + DebugDraw.draw_player(self, p["pos"], p["aim"]) + DebugDraw.draw_player(self, client.predicted_pos, client.aim) + DebugDraw.draw_ranges(self, client.predicted_pos) + + +## Aggro radius by visual index. Read from the content definitions rather than +## hardcoded, so the overlay cannot drift from what the server actually uses. +func _aggro_for(visual: int) -> float: + for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, + Content.ENEMY_STALKER, Content.ENEMY_DUMMY]: + var def := Content.enemy(id) + if def.visual == visual: + return def.aggro_range if not def.emitters.is_empty() else 0.0 + return 0.0 ## Terrain, drawn only where the player can currently see it. diff --git a/tests/unit/test_debug_draw.gd b/tests/unit/test_debug_draw.gd new file mode 100644 index 0000000..11cd304 --- /dev/null +++ b/tests/unit/test_debug_draw.gd @@ -0,0 +1,60 @@ +extends GutTest +## The overlay's job is to be trustworthy: it must report the same numbers the +## simulation collides against, not its own idea of them. A debug view that +## quietly disagrees with the server is worse than none, because it makes wrong +## art look correct. + + +func test_the_overlay_reads_the_same_player_radii_the_sim_uses() -> void: + # Not a tautology worth much on its own, but it fails loudly if someone + # gives the overlay its own copy of these numbers. + assert_lt(SimConfig.PLAYER_RADIUS, SimConfig.PLAYER_VISUAL_RADIUS, + "the overlay exists to show this gap; it has to be a real gap") + assert_gt(SimConfig.PLAYER_MUZZLE_OFFSET, SimConfig.PLAYER_VISUAL_RADIUS, + "bullets must be born clear of the drawn ship") + + +func test_the_overlay_colours_are_distinguishable() -> void: + # Hitbox versus visual is the comparison the overlay is for; if they were + # drawn the same colour it could not be made. + assert_ne(DebugDraw.COL_HITBOX, DebugDraw.COL_VISUAL) + assert_ne(DebugDraw.COL_HITBOX, DebugDraw.COL_BULLET) + + +## Terrain is drawn from MapGrid's own flag tables, so a tile that blocks +## bullets but not sight renders differently from one that blocks both. This +## pins the distinction the overlay relies on. +func test_terrain_flags_remain_distinguishable() -> void: + var move_only := [] + var all_three := [] + for kind in [MapGrid.Kind.WALL, MapGrid.Kind.PILLAR, MapGrid.Kind.PIT, + MapGrid.Kind.BARRICADE]: + var m: bool = MapGrid.BLOCKS_MOVE[kind] + var b: bool = MapGrid.BLOCKS_BULLET[kind] + var s: bool = MapGrid.BLOCKS_SIGHT[kind] + if m and not b and not s: + move_only.append(kind) + if m and b and s: + all_three.append(kind) + assert_true(move_only.has(MapGrid.Kind.PIT), + "a pit blocks feet only -- the overlay draws that case specially") + assert_true(all_three.has(MapGrid.Kind.WALL)) + + +## Every animation the ship can be in must exist in the atlas, or switching +## between idle and run mid-stride draws off the sheet. +func test_idle_and_run_strips_are_both_complete() -> void: + for strip in [Art.PLAYER_IDLE, Art.PLAYER_RUN]: + for n in Art.ACTOR_FRAMES: + var f := Art.frame(strip, n) + assert_true(f.end.x <= float(Art.TILESET.get_width()) + and f.end.y <= float(Art.TILESET.get_height()), + "frame %d of %s runs off the tileset" % [n, strip]) + + +## The two strips must not overlap, or "moving" and "idle" would show the same +## pixels and the run animation would be invisible. +func test_the_idle_and_run_strips_do_not_overlap() -> void: + var idle_end := Art.PLAYER_IDLE.position.x + Art.PLAYER_IDLE.size.x * Art.ACTOR_FRAMES + assert_lte(idle_end, Art.PLAYER_RUN.position.x, + "idle frames run into the run strip; the two animations would alias") diff --git a/tests/unit/test_debug_draw.gd.uid b/tests/unit/test_debug_draw.gd.uid new file mode 100644 index 0000000..eaf48c2 --- /dev/null +++ b/tests/unit/test_debug_draw.gd.uid @@ -0,0 +1 @@ +uid://dlgc6bd4s0fwi diff --git a/tools/build_local_assets.py b/tools/build_local_assets.py index 6e7aab2..94017de 100755 --- a/tools/build_local_assets.py +++ b/tools/build_local_assets.py @@ -32,11 +32,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. +# +# Cells must have EMPTY BORDERS in every frame. Several sprites in this pack +# overflow their 16px cell and bleed into the neighbour below, so a cell picked +# purely by eye can drag in a slice of the sprite above it -- which is exactly +# what put a solid bar across the top of the first "heavy" I chose. 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 + ("player_shot", 7, 4), # small bright-yellow dot + ("orb", 3, 1), # round orange orb + ("needle", 6, 6), # compact dart, drawn rotated to velocity + ("heavy", 5, 0), # larger red ball ] @@ -53,6 +58,15 @@ def main() -> None: 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) + # Guard the rule the KINDS comment describes, so a future pick that bleeds + # fails loudly here instead of looking like a rendering bug. + for k in range(len(KINDS)): + for f in range(FRAMES): + for x in range(CELL): + for y in (0, CELL - 1): + if out.getpixel((f * CELL + x, k * CELL + y))[3] > 40: + sys.exit("cell for %s bleeds at its border; pick another" + % KINDS[k][0]) out.save(OUT) print("wrote %s (%dx%d): %d kinds x %d frames" % (OUT, out.width, out.height, len(KINDS), FRAMES)) diff --git a/tools/setup_input_map.gd b/tools/setup_input_map.gd index a058ea8..484c216 100644 --- a/tools/setup_input_map.gd +++ b/tools/setup_input_map.gd @@ -20,6 +20,7 @@ func _init() -> void: _action("emergency_escape", [_key(KEY_F)]) _action("interact", [_key(KEY_E)]) _action("system_menu", [_key(KEY_ESCAPE)]) + _action("debug_overlay", [_key(KEY_F1)]) var err := ProjectSettings.save() print("input map written, err=%d" % err) quit(0 if err == OK else 1)