872922e9e2
ci / verify (push) Successful in 48s
The jitter had two causes, and the larger one is embarrassing: boss_state() handed back the newest snapshot raw while players and enemies both went through the interpolator. The boss therefore stepped at the 20Hz snapshot rate instead of the frame rate. Invisible for as long as every boss stood still, and the first one that moved looked broken. It is interpolated now -- but not across an instance change, where the previous snapshot describes a different fight in a different room and lerping to it would fling the new boss across the map for a frame. The smaller cause was server-side: a CHASE boss corrects by the SIGN of its distance error, so at the standoff the sign flipped every tick and the boss vibrated a couple of pixels at 60Hz. It has a dead band now. The settings screen covers rebindable controls and volume, reachable from both the main menu and the in-game menu. Bindings are stored as physical keycodes -- following key position, the choice setup_input_map.gd already made -- and labelled back through the active layout so an AZERTY player reads the letter on the key their fingers are on. A rebind replaces every event on the action rather than the first, because an action that kept its alternates would still answer to the key you just moved away from. A key already in use is refused and the clash is named. Reset restores what the PROJECT shipped, captured once before anything overrides it -- captured later it would restore the last session's choice, which is the thing being undone. Effects play on an SFX bus created at runtime, so both sliders are real mixer settings rather than a number multiplied into every play() call. Worth recording: the test suite AND the smoke test both passed while a client logged twelve engine errors on every startup. ConfigFile.get_value(s, k, null) does not mean "no default" -- it means the key is absent and no default was given, and the engine logs an error per action. Nothing caught it because the smoke refutations matched SCRIPT ERROR and friends, and a plain ERROR: is none of those. It surfaced from running the client and reading the output. smoke.sh now asserts no plain engine errors either, excluding by name the one line Godot prints on every clean exit, and reintroducing the bug makes it fail. One mutation caught nothing and should not have: the early return in linear_to_db_clamped was dead code, since the clamp beneath it already prevents negative infinity. Removed rather than left looking tested. check.sh clean, 439 tests, SMOKE PASS (23 assertions), all four diagnostics green, and a real client boots with zero engine errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
9.6 KiB
GDScript
267 lines
9.6 KiB
GDScript
extends GutTest
|
|
## Telegraphed strikes: the warning, the strike, and the promise that the two
|
|
## land in the same place.
|
|
##
|
|
## The emitter is stateless -- emitters are shared resources and two bosses of
|
|
## the same kind must not stomp each other -- so the strike positions are
|
|
## derived from the volley number rather than rolled and remembered. Most of
|
|
## what is worth testing here follows from that.
|
|
|
|
const BOUNDS := Rect2(Vector2(-400.0, -300.0), Vector2(800.0, 600.0))
|
|
|
|
var emitter: TelegraphedStrikeEmitter
|
|
var ctx: EmitContext
|
|
var pool: BulletPool
|
|
|
|
|
|
func before_each() -> void:
|
|
emitter = TelegraphedStrikeEmitter.new()
|
|
emitter.interval = 120
|
|
emitter.warn_ticks = 60
|
|
emitter.strikes = 3
|
|
emitter.burst_count = 8
|
|
emitter.blast_radius = 60.0
|
|
emitter.speed = 140.0
|
|
emitter.damage = 100
|
|
pool = BulletPool.new()
|
|
ctx = EmitContext.new()
|
|
ctx.pool = pool
|
|
ctx.bounds = BOUNDS
|
|
ctx.rng = RandomNumberGenerator.new()
|
|
|
|
|
|
func _run(local_tick: int) -> void:
|
|
ctx.local_tick = local_tick
|
|
ctx.shot_index = emitter.shot_index_at(local_tick)
|
|
if emitter.should_fire(local_tick):
|
|
emitter.fire(ctx)
|
|
|
|
|
|
func _telegraphs() -> Array[Dictionary]:
|
|
var out: Array[Dictionary] = []
|
|
for ev in ctx.events:
|
|
if int(ev["t"]) == SimEvent.Type.TELEGRAPH:
|
|
out.append(ev)
|
|
return out
|
|
|
|
|
|
# --- The two moments ---------------------------------------------------------
|
|
|
|
func test_a_volley_announces_before_it_fires() -> void:
|
|
_run(0)
|
|
assert_eq(_telegraphs().size(), emitter.strikes, "one warning per strike")
|
|
assert_eq(pool.live_count, 0, "and not a single bullet yet")
|
|
|
|
|
|
func test_the_burst_lands_when_the_warning_runs_out() -> void:
|
|
_run(0)
|
|
_run(emitter.warn_ticks)
|
|
assert_eq(pool.live_count, emitter.strikes * emitter.burst_count)
|
|
|
|
|
|
func test_nothing_happens_between_the_warning_and_the_strike() -> void:
|
|
for t in range(0, emitter.warn_ticks):
|
|
_run(t)
|
|
assert_eq(pool.live_count, 0)
|
|
assert_eq(_telegraphs().size(), emitter.strikes,
|
|
"and the warning is announced once, not every tick")
|
|
|
|
|
|
func test_the_warning_carries_where_how_big_and_how_long() -> void:
|
|
_run(0)
|
|
for ev in _telegraphs():
|
|
assert_eq(float(ev["r"]), emitter.blast_radius)
|
|
assert_eq(int(ev["ticks"]), emitter.warn_ticks)
|
|
assert_true(BOUNDS.has_point(ev["pos"]))
|
|
|
|
|
|
## The whole reason the emitter is written the way it is: the warning and the
|
|
## burst are computed at different ticks with nothing stored in between, and
|
|
## they have to agree. A marker that lied about where the strike would land
|
|
## would be worse than no marker.
|
|
func test_the_burst_lands_where_the_warning_said_it_would() -> void:
|
|
_run(0)
|
|
var promised: Array[Vector2] = []
|
|
for ev in _telegraphs():
|
|
promised.append(ev["pos"])
|
|
_run(emitter.warn_ticks)
|
|
for i in pool.high_water:
|
|
if pool.alive[i] == 0:
|
|
continue
|
|
var nearest := INF
|
|
for spot in promised:
|
|
nearest = minf(nearest, spot.distance_to(pool.pos[i]))
|
|
assert_lt(nearest, emitter.blast_radius,
|
|
"a bullet appeared %.0f from any marked spot" % nearest)
|
|
|
|
|
|
func test_strike_points_are_a_pure_function_of_the_volley() -> void:
|
|
for volley in 20:
|
|
for index in emitter.strikes:
|
|
assert_eq(emitter.strike_point(volley, index, BOUNDS),
|
|
emitter.strike_point(volley, index, BOUNDS))
|
|
|
|
|
|
func test_consecutive_volleys_pick_different_places() -> void:
|
|
var moved := 0
|
|
for volley in 30:
|
|
if emitter.strike_point(volley, 0, BOUNDS) \
|
|
!= emitter.strike_point(volley + 1, 0, BOUNDS):
|
|
moved += 1
|
|
assert_gt(moved, 25, "a strike that always lands in one place is a wall")
|
|
|
|
|
|
func test_the_strikes_in_one_volley_are_not_all_the_same_spot() -> void:
|
|
var distinct := {}
|
|
for index in emitter.strikes:
|
|
distinct[emitter.strike_point(3, index, BOUNDS)] = true
|
|
assert_eq(distinct.size(), emitter.strikes)
|
|
|
|
|
|
## Two strike emitters in one phase would otherwise derive identical points and
|
|
## stack every burst on top of itself.
|
|
func test_the_pattern_seed_separates_two_emitters() -> void:
|
|
var other := TelegraphedStrikeEmitter.new()
|
|
other.pattern_seed = 91
|
|
var same := 0
|
|
for volley in 20:
|
|
if emitter.strike_point(volley, 0, BOUNDS) == other.strike_point(volley, 0, BOUNDS):
|
|
same += 1
|
|
assert_lt(same, 3)
|
|
|
|
|
|
## Half a burst spent against a wall is half a burst the player never had to
|
|
## dodge.
|
|
func test_strikes_stay_clear_of_the_arena_edge() -> void:
|
|
var inner := BOUNDS.grow(-emitter.margin)
|
|
for volley in 50:
|
|
for index in emitter.strikes:
|
|
assert_true(inner.has_point(emitter.strike_point(volley, index, BOUNDS)))
|
|
|
|
|
|
## A tiny room cannot be shrunk by the margin without inverting. Falling back to
|
|
## the whole room beats emitting at a negative-size rectangle's corner.
|
|
func test_a_room_smaller_than_the_margin_still_produces_points_inside_it() -> void:
|
|
var tiny := Rect2(Vector2(-20.0, -20.0), Vector2(40.0, 40.0))
|
|
for volley in 20:
|
|
assert_true(tiny.has_point(emitter.strike_point(volley, 0, tiny)))
|
|
|
|
|
|
# --- Wired into a real fight -------------------------------------------------
|
|
|
|
func test_a_boss_running_the_emitter_announces_through_the_world() -> void:
|
|
var world := SimWorld.new(5)
|
|
var def := BossDef.new()
|
|
def.max_hp = 5000
|
|
def.radius = 30.0
|
|
var phase := BossPhase.new()
|
|
phase.enter_at_hp_fraction = 1.0
|
|
phase.telegraph_ticks = 0
|
|
phase.loop_ticks = 600
|
|
phase.emitters = [emitter]
|
|
def.phases = [phase]
|
|
var boss := world.spawn_boss(def)
|
|
boss.room = Rect2(Vector2(-300.0, -200.0), Vector2(600.0, 400.0))
|
|
world.step()
|
|
var announced := 0
|
|
for ev in world.events:
|
|
if int(ev["t"]) == SimEvent.Type.TELEGRAPH:
|
|
announced += 1
|
|
assert_eq(announced, emitter.strikes,
|
|
"the emitter's warning has to reach the world's event list")
|
|
|
|
|
|
## Every strike emitter in the game has to warn inside its own cycle, or a
|
|
## volley lands after the next one has already been announced and the markers
|
|
## stop meaning anything.
|
|
func test_every_authored_strike_warns_before_its_next_volley() -> void:
|
|
var phases: Array[BossPhase] = []
|
|
phases.append_array(Content.warden().phases)
|
|
phases.append_array(Content.cantor().phases)
|
|
var found := 0
|
|
for phase in phases:
|
|
for e in phase.emitters:
|
|
if e is TelegraphedStrikeEmitter:
|
|
found += 1
|
|
assert_lt((e as TelegraphedStrikeEmitter).warn_ticks, e.interval,
|
|
"a strike must land before the next volley is called")
|
|
assert_gt(found, 0, "setup: some boss should actually use these")
|
|
|
|
|
|
# --- The client's side ------------------------------------------------------
|
|
|
|
## Warnings expire on the SERVER's clock, not on wall time: a frame-rate dip
|
|
## must not leave a marker sitting over ground that was struck seconds ago.
|
|
func test_the_client_drops_a_warning_when_its_attack_has_landed() -> void:
|
|
var client: ClientRuntime = autofree(ClientRuntime.new())
|
|
client.server_tick_est = 100
|
|
client.telegraphs = [
|
|
{"pos": Vector2.ZERO, "r": 60.0, "ticks": 60, "until": 130},
|
|
{"pos": Vector2(10.0, 0.0), "r": 60.0, "ticks": 60, "until": 160},
|
|
]
|
|
client._expire_telegraphs()
|
|
assert_eq(client.telegraphs.size(), 2, "neither has landed yet")
|
|
client.server_tick_est = 140
|
|
client._expire_telegraphs()
|
|
assert_eq(client.telegraphs.size(), 1)
|
|
client.server_tick_est = 200
|
|
client._expire_telegraphs()
|
|
assert_eq(client.telegraphs.size(), 0)
|
|
|
|
|
|
## Entering an instance has to clear them, or a warning from the dungeon you
|
|
## just left would hang over the hub floor.
|
|
func test_arriving_somewhere_clears_the_old_warnings() -> void:
|
|
var client: ClientRuntime = autofree(ClientRuntime.new())
|
|
client.telegraphs = [{"pos": Vector2.ZERO, "r": 60.0, "ticks": 60, "until": 999}]
|
|
client.on_enter_instance(7, Protocol.InstanceKind.LOBBY, 0, "", Vector2.ZERO,
|
|
20, 20, PackedByteArray(), "", Vector2.ZERO)
|
|
assert_eq(client.telegraphs.size(), 0)
|
|
|
|
|
|
# --- Boss interpolation -----------------------------------------------------
|
|
|
|
func _client_with_snapshots(prev_pos: Vector2, curr_pos: Vector2,
|
|
prev_id: int = 1, curr_id: int = 1) -> ClientRuntime:
|
|
var client: ClientRuntime = autofree(ClientRuntime.new())
|
|
client.snap_prev = {"boss": {"id": prev_id, "pos": prev_pos, "hp": 10, "phase": 0}}
|
|
client.snap_curr = {"boss": {"id": curr_id, "pos": curr_pos, "hp": 10, "phase": 0}}
|
|
return client
|
|
|
|
|
|
## The boss is drawn between the last two snapshots like every other actor. It
|
|
## was not, which is invisible while bosses stand still and looks like a
|
|
## stuttering mess the moment one moves.
|
|
func test_the_boss_is_interpolated_between_snapshots() -> void:
|
|
var client := _client_with_snapshots(Vector2.ZERO, Vector2(100.0, 0.0))
|
|
client._interp = 0.0
|
|
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 0.0, 0.01)
|
|
client._interp = 0.5
|
|
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 50.0, 0.01)
|
|
client._interp = 1.0
|
|
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 100.0, 0.01)
|
|
|
|
|
|
## Across an instance change the previous snapshot describes a different fight
|
|
## in a different room; lerping to it would fling the new boss across the map.
|
|
func test_two_different_bosses_are_never_interpolated_together() -> void:
|
|
var client := _client_with_snapshots(Vector2.ZERO, Vector2(900.0, 0.0), 1, 2)
|
|
client._interp = 0.5
|
|
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 900.0, 0.01)
|
|
|
|
|
|
func test_the_first_snapshot_of_a_fight_is_used_as_is() -> void:
|
|
var client: ClientRuntime = autofree(ClientRuntime.new())
|
|
client.snap_curr = {"boss": {"id": 1, "pos": Vector2(40.0, 0.0), "hp": 5, "phase": 0}}
|
|
client._interp = 0.5
|
|
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 40.0, 0.01)
|
|
|
|
|
|
## Reading it must not rewrite the snapshot -- the next frame interpolates from
|
|
## the same pair again, and a mutated one would creep.
|
|
func test_reading_the_boss_does_not_modify_the_snapshot() -> void:
|
|
var client := _client_with_snapshots(Vector2.ZERO, Vector2(100.0, 0.0))
|
|
client._interp = 0.5
|
|
client.boss_state()
|
|
assert_almost_eq((client.snap_curr["boss"]["pos"] as Vector2).x, 100.0, 0.01)
|