From 94ad6b34290ffa258e2b7f3fa50cbdd72d3c222a Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 5 Oct 2026 13:57:50 +0200 Subject: [PATCH] Stop drawing bullets past the edge of sight -- but never sooner than is safe Bullets were drawn everywhere on screen, including out in the black past the edge of sight: shots that could not reach you, streaking across the dark. They now fade out beyond sight, under one condition that bounds the whole change: no enemy bullet may hit you without having been on screen, at full strength, for at least WARNING_TIME (0.75s) first. So each enemy bullet is drawn out to the edge of sight or to however far it could close in that window with you running straight at it -- its own speed, plus acceleration over the window, plus yours -- whichever is further, and fades over 80 units beyond. With today's content the edge of sight always wins: the fastest enemy bullet is 300 u/s, so head-on it is on screen 0.83s before it lands. The speed term is what keeps that true when something faster is added. Player shots fade at the edge of sight however fast they are, since they cannot hurt you; a Sniper's 1500 u/s shot was the commonest thing crossing the dark. Shadow does not hide a bullet. One behind a wall within sight range is still drawn over the darkness, because you can step round the corner into it, and hiding it would be exactly the invisible hit the condition rules out. The guarantee is tested the hard way: every emitter in the content, and bullets far faster than any of them, simulated head-on against a player running at them, measuring how long each was fully drawn before it landed. The rule's weakenings -- no speed term, forgetting the player's own approach, ignoring acceleration -- each fail it. The connect screen's backdrop passes no eye and draws everything, as before. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 ++- docs/DECISIONS.md | 17 ++++- docs/ROADMAP.md | 1 + src/view/bullet_renderer.gd | 56 ++++++++++++++- src/view/world_view.gd | 8 ++- tests/unit/test_bullet_draw.gd | 112 +++++++++++++++++++++++++++++ tests/unit/test_bullet_draw.gd.uid | 1 + tools/screenshot.gd | 4 +- 8 files changed, 197 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_bullet_draw.gd create mode 100644 tests/unit/test_bullet_draw.gd.uid diff --git a/CLAUDE.md b/CLAUDE.md index 8fbfb30..08d2e93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ the obvious default. ```bash tools/check.sh # parse-check every script (~5s) -- run after every edit -tools/test.sh # 568 GUT tests, headless (~4s) +tools/test.sh # 576 GUT tests, headless (~4s) tools/smoke.sh # real server + 4 bot clients over ENet (~40s) tools/server.sh # dedicated server tools/client.sh --listen # host and play, no menu @@ -254,6 +254,12 @@ ticks in milliseconds with no SceneTree. `Visibility` over them, then whatever must stay visible. That is what lets a tile or an enemy be half lit. Anything new that fog may hide goes *before* `_draw_darkness`; anything it must not goes after. +- **No enemy bullet may land without having been drawn first.** Bullets fade + beyond the edge of sight, but `BulletRenderer.visibility()` stretches each + enemy bullet's draw distance with its speed so it is always fully on screen + `WARNING_TIME` before it could reach you. A faster bullet in new content is + covered automatically; `test_bullet_draw.gd` fails if it is not. Never hide + bullets by shadow alone — you can walk round a corner into one. - **A telegraph must be visible through fog.** `WorldView._draw_telegraphs` is drawn after the darkness on purpose; everything else in the view is under it. A warning you cannot see is an unavoidable hit with extra steps. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2d9036b..a2ed327 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -108,8 +108,21 @@ stopping at a circle. Nothing is remembered: still hard fog. Two lines this deliberately does not cross: light stops the moment a ray steps back out of the wall it was in, so it never rounds a corner or reaches the far side of a one-tile wall; and two blocking tiles that touch only at a corner are -closed, not a crack. Bullets are drawn over the darkness, as they always were — -a shot out of the black is still one you can dodge. +closed, not a crack. Bullets are drawn over the darkness — a shot out of the +black is still one you can dodge. + +**Bullets fade out past the edge of sight, but never sooner than is safe.** +*(User asked to stop drawing bullets beyond sight, with the condition that +nobody is ever hit by a bullet they could not see.)* Every enemy bullet is +drawn at full strength out to the edge of sight or to however far it could close +in `BulletRenderer.WARNING_TIME` (0.75s) with you running straight at it, +whichever is further, then fades over 80 units. Today the edge of sight is always +further: the fastest enemy bullet is 300 u/s, so even head-on it is on screen +0.83s before it lands. `test_bullet_draw.gd` checks that for every emitter in the +content, and for bullets far faster. Player shots fade at the edge of sight +regardless, since they cannot hurt you. Bullets in shadow *within* sight range +are still drawn: you can step round a corner into one, and hiding it would be +exactly the invisible hit the condition rules out. **Dungeon size scales with depth.** *(User, this session.)* `--depth N` is a dev flag; what raises depth in actual play is still open. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4f29945..0d86ff6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -98,6 +98,7 @@ against itself. | Prompts name the keys actually bound | done | `Settings.short_label()` | | Health bar: dark track, number inside, damage trail | done | `Hud._bar`, `Hud._watch_health` | | Centre banners: level-ups, boss phases, leaving | done | `Hud.banner`, `Hud.track_boss` | +| Bullets fade out beyond sight, never sooner than is safe | done | `BulletRenderer.visibility()` — every enemy bullet is drawn at least 0.75s before it could land | | Markers pointing to party members out of sight | done | `Hud._draw_party_markers` | | Connect screen: live boss pattern behind it, port validation, credits | done | [src/ui/main_menu.gd](../src/ui/main_menu.gd), [src/ui/menu_backdrop.gd](../src/ui/menu_backdrop.gd) | | Boss bar waits until the boss has been seen | done | `Hud._draw_boss_bar`, reading the fog's `Visibility` | diff --git a/src/view/bullet_renderer.gd b/src/view/bullet_renderer.gd index 0a84f2e..29f3ca5 100644 --- a/src/view/bullet_renderer.gd +++ b/src/view/bullet_renderer.gd @@ -17,6 +17,21 @@ extends Node2D ## Used only in fallback mode, where every bullet is the same generated dot and ## colour is the only thing distinguishing a kind. +## How long an enemy bullet is guaranteed to be drawn at full strength before +## it could possibly reach you. The whole point of fading bullets with distance +## is to stop drawing shots that cannot matter -- and the one thing that must +## never come of it is a hit from a bullet you were not shown. +## +## So each enemy bullet is drawn out to however far it, plus you running straight +## at it, could close in this long -- or to the edge of sight, whichever is +## further. With today's content the edge of sight is always the further: the +## fastest enemy bullet does 300 u/s, so even head-on it is on screen for 0.83s +## before it lands. The speed term is what keeps that true for anything faster. +const WARNING_TIME := 0.75 +## Past the distance a bullet is drawn to, it fades out over this much more +## rather than vanishing on a line -- a hard edge reads as a rendering fault. +const FADE_OUT := 80.0 + const _FALLBACK_COLOURS: Array[Color] = [ Color(0.45, 0.95, 1.0), # KIND_PLAYER_SHOT Color(1.0, 0.35, 0.75), # KIND_ORB @@ -99,13 +114,47 @@ func _slice_still_frames(sheet: Texture2D, kinds: int) -> Array[Texture2D]: return out -func render_pool(pool: BulletPool) -> void: +## How strongly to draw a bullet [param dist] from the eye: 1 is fully, 0 is not +## at all. See [constant WARNING_TIME] for why the distance depends on the bullet. +## +## Shots by players fade at the edge of sight and no further, however fast they +## are: they cannot hurt you, so there is nothing to warn about, and a Sniper's +## 1500 u/s shot was the most common thing streaking across the dark. +## +## Distance only -- not shadow. A bullet behind a wall within sight range is +## still drawn, over the darkness, because you can step round the corner into +## it; hiding it would be the invisible hit this is careful not to cause. +static func visibility(dist: float, speed: float, accel: float, team: int, + sight_radius: float) -> float: + var reach := sight_radius + if team == SimConfig.TEAM_ENEMY: + # Top speed over the warning window, times the window: an over-estimate + # of how far it can come, which is the safe direction to be wrong in. + var top := speed + maxf(accel, 0.0) * WARNING_TIME + reach = maxf(sight_radius, (top + SimConfig.PLAYER_SPEED) * WARNING_TIME) + if dist <= reach: + return 1.0 + return clampf(1.0 - (dist - reach) / FADE_OUT, 0.0, 1.0) + + +## Push the pool into the MultiMeshes. With an [param eye], bullets far beyond +## [param sight_radius] are left out; without one -- the connect screen's +## backdrop -- everything is drawn. +func render_pool(pool: BulletPool, eye: Vector2 = Vector2.INF, + sight_radius: float = 0.0) -> void: var counts := PackedInt32Array() counts.resize(_layers.size()) counts.fill(0) + var cull := eye.is_finite() for i in pool.high_water: if pool.alive[i] == 0: continue + var alpha := 1.0 + if cull: + alpha = visibility((pool.pos[i] as Vector2).distance_to(eye), + (pool.vel[i] as Vector2).length(), pool.accel[i], pool.team[i], sight_radius) + if alpha <= 0.0: + continue var kind := clampi(pool.kind[i], 0, _layers.size() - 1) var n := counts[kind] if n >= SimConfig.MAX_BULLETS: @@ -121,8 +170,9 @@ func render_pool(pool: BulletPool) -> void: + float(pool.uid[i] % 64) * (TAU / 64.0) _layers[kind].multimesh.set_instance_transform_2d( n, Transform2D(angle, Vector2(d, d), 0.0, pool.pos[i])) - _layers[kind].multimesh.set_instance_color(n, - _FALLBACK_COLOURS[kind] if _fallback else Color.WHITE) + var tint: Color = _FALLBACK_COLOURS[kind] if _fallback else Color.WHITE + tint.a *= alpha + _layers[kind].multimesh.set_instance_color(n, tint) counts[kind] = n + 1 for kind in _layers.size(): _layers[kind].multimesh.visible_instance_count = counts[kind] diff --git a/src/view/world_view.gd b/src/view/world_view.gd index 53a4ec0..dfe34af 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -57,7 +57,8 @@ func _process(delta: float) -> void: _anim_time += delta client = Net.client if client != null: - bullets.render_pool(client.world.pool) + bullets.render_pool(client.world.pool, client.predicted_pos, + SimConfig.FOG_VIEW_RADIUS) queue_redraw() @@ -70,8 +71,9 @@ func _process(delta: float) -> void: ## darkness because a warning you cannot see is no warning (see ## _draw_telegraphs). The local player goes on top because it is the eye. And ## bullets are a child node, so they are drawn after all of this, over the dark -## -- as they always were: a shot coming out of the black is a shot you can -## still dodge. +## -- a shot coming out of the black is a shot you can still dodge. Out past the +## edge of sight they fade away, but only as far as is safe: see +## BulletRenderer.visibility(). func _draw() -> void: if client == null: return diff --git a/tests/unit/test_bullet_draw.gd b/tests/unit/test_bullet_draw.gd new file mode 100644 index 0000000..025ff48 --- /dev/null +++ b/tests/unit/test_bullet_draw.gd @@ -0,0 +1,112 @@ +extends GutTest +## Which bullets are drawn, and the promise that bounds it: no enemy bullet can +## hit you without having been on screen, at full strength, for at least +## BulletRenderer.WARNING_TIME first. Fading distant bullets is only worth doing +## if that holds for every bullet the game can fire. + +const RENDERER := preload("res://src/view/bullet_renderer.gd") +const SIGHT := SimConfig.FOG_VIEW_RADIUS + + +## The worst case for warning: the bullet coming straight at you and you running +## straight at it. Stepped the way BulletPool integrates, from far enough out +## that it starts undrawn. Returns seconds it was fully drawn before it hit. +func _warning(speed: float, accel: float) -> float: + var dt := SimConfig.TICK_DELTA + var gap := 6000.0 + var v := speed + var t := 0 + var first_full := -1 + var reach := SimConfig.PLAYER_RADIUS + 7.0 + while gap > reach and t < 60 * 120: + if first_full < 0 and RENDERER.visibility(gap, v, accel, + SimConfig.TEAM_ENEMY, SIGHT) >= 1.0: + first_full = t + v = maxf(v + accel * dt, 0.0) + gap -= (v + SimConfig.PLAYER_SPEED) * dt + t += 1 + assert_gte(first_full, 0, "the bullet was drawn at all before it arrived") + return float(t - first_full) * dt + + +func test_every_enemy_bullet_in_the_game_is_seen_in_time() -> void: + var emitters: Array[BulletEmitter] = [] + for id in Content.ALL_ENEMIES: + emitters.append_array(Content.enemy(id).emitters) + for id in Content.ALL_BOSSES: + for phase in Content.boss(id).phases: + emitters.append_array(phase.emitters) + assert_gt(emitters.size(), 0) + for e in emitters: + var warned := _warning(e.speed, e.accel) + assert_gte(warned, RENDERER.WARNING_TIME - SimConfig.TICK_DELTA, + "a %.0f u/s bullet was on screen %.2fs before it could land" % [e.speed, warned]) + + +func test_a_bullet_far_faster_than_anything_today_is_still_seen_in_time() -> void: + # The sight radius covers today's content on its own. This is the case the + # speed term exists for: content added later, faster than the radius allows. + for case in [[1200.0, 0.0], [600.0, 900.0], [2000.0, 0.0]]: + var warned := _warning(case[0], case[1]) + assert_gte(warned, RENDERER.WARNING_TIME - SimConfig.TICK_DELTA, + "%.0f u/s, accelerating %.0f: on screen %.2fs" % [case[0], case[1], warned]) + + +func test_inside_sight_everything_is_drawn_fully() -> void: + for team in [SimConfig.TEAM_ENEMY, SimConfig.TEAM_PLAYER]: + assert_eq(RENDERER.visibility(SIGHT - 1.0, 130.0, 0.0, team, SIGHT), 1.0) + + +func test_a_slow_shot_far_beyond_sight_is_not_drawn() -> void: + assert_eq(RENDERER.visibility(SIGHT + 400.0, 130.0, 0.0, SimConfig.TEAM_ENEMY, SIGHT), + 0.0, "out in the black and nowhere near you") + + +func test_the_edge_fades_rather_than_cuts() -> void: + var mid := RENDERER.visibility(SIGHT + RENDERER.FADE_OUT * 0.5, 130.0, 0.0, + SimConfig.TEAM_ENEMY, SIGHT) + assert_between(mid, 0.3, 0.7) + + +func test_player_shots_fade_at_the_edge_of_sight_however_fast() -> void: + # They cannot hurt you, so there is nothing to warn about -- and a Sniper's + # shot streaking across the dark was the commonest case of all. + assert_eq(RENDERER.visibility(SIGHT + 200.0, SimConfig.MAX_BULLET_SPEED, 0.0, + SimConfig.TEAM_PLAYER, SIGHT), 0.0) + assert_gt(RENDERER.visibility(SIGHT + 200.0, SimConfig.MAX_BULLET_SPEED, 0.0, + SimConfig.TEAM_ENEMY, SIGHT), 0.0, + "an enemy bullet that fast is drawn further out, because it could reach you") + + +# --- The renderer actually applies it --------------------------------------- + +func _drawn(r: Node2D) -> int: + var n := 0 + for layer in r._layers: + n += (layer as MultiMeshInstance2D).multimesh.visible_instance_count + return n + + +func test_the_renderer_leaves_out_what_is_too_far_to_matter() -> void: + var r: Node2D = RENDERER.new() + add_child_autofree(r) + var pool := BulletPool.new() + var eye := Vector2(100.0, 100.0) + pool.spawn(eye + Vector2(200.0, 0.0), Vector2(-130.0, 0.0), 7.0, 300, 10, + SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) + pool.spawn(eye + Vector2(SIGHT + 500.0, 0.0), Vector2(-130.0, 0.0), 7.0, 300, 10, + SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) + r.render_pool(pool, eye, SIGHT) + assert_eq(_drawn(r), 1, "the near bullet only") + + +func test_with_no_eye_everything_is_drawn() -> void: + # The connect screen's backdrop has no player and wants the whole pattern. + var r: Node2D = RENDERER.new() + add_child_autofree(r) + var pool := BulletPool.new() + pool.spawn(Vector2.ZERO, Vector2.RIGHT, 7.0, 300, 10, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) + pool.spawn(Vector2(5000.0, 0.0), Vector2.RIGHT, 7.0, 300, 10, + SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) + r.render_pool(pool) + assert_eq(_drawn(r), 2) diff --git a/tests/unit/test_bullet_draw.gd.uid b/tests/unit/test_bullet_draw.gd.uid new file mode 100644 index 0000000..8d668d0 --- /dev/null +++ b/tests/unit/test_bullet_draw.gd.uid @@ -0,0 +1 @@ +uid://noaqo4x7so77 diff --git a/tools/screenshot.gd b/tools/screenshot.gd index 7b78c9b..8e2cb18 100644 --- a/tools/screenshot.gd +++ b/tools/screenshot.gd @@ -105,7 +105,9 @@ func _process(_delta: float) -> void: # High until just before the picture and low after, so the damage trail # -- the part of the bar the drop took -- is still draining when it is taken. if _frame > TO_BOSS and not _left_shot: - _hold_health(0.62 if _frame < AT_BOSS - 10 else 0.22) + # Forty frames, not ten: at a high frame rate ten is shorter than one + # snapshot, and the picture showed the health from before the drop. + _hold_health(0.62 if _frame < AT_BOSS - 40 else 0.22) # Captured by progress rather than by frame count. The channel is one second # of server ticks and frames run as fast as the display allows, so a fixed # frame number lands anywhere from a quarter of the way in to after it has