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