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)