From 872922e9e2e49fc74b07996cdb8d78c24b16dae4 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 7 Sep 2026 10:56:44 +0200 Subject: [PATCH] Fix boss jitter; add a settings screen for controls and volume 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 --- CLAUDE.md | 5 + docs/DECISIONS.md | 49 ++++++ docs/ROADMAP.md | 12 +- docs/WORKFLOW.md | 16 ++ src/core/settings.gd | 255 +++++++++++++++++++++++++++++++ src/core/settings.gd.uid | 1 + src/core/sim_config.gd | 7 + src/game.tscn | 6 +- src/main.gd | 16 ++ src/net/client_runtime.gd | 19 ++- src/sim/sim_world.gd | 7 +- src/ui/game_menu.gd | 4 + src/ui/main_menu.gd | 6 + src/ui/settings_screen.gd | 213 ++++++++++++++++++++++++++ src/ui/settings_screen.gd.uid | 1 + src/view/game_scene.gd | 3 + src/view/sfx.gd | 6 +- tests/unit/test_boss_movement.gd | 25 +++ tests/unit/test_settings.gd | 228 +++++++++++++++++++++++++++ tests/unit/test_settings.gd.uid | 1 + tests/unit/test_telegraph.gd | 47 ++++++ tools/smoke.sh | 23 +++ 22 files changed, 943 insertions(+), 7 deletions(-) create mode 100644 src/core/settings.gd create mode 100644 src/core/settings.gd.uid create mode 100644 src/ui/settings_screen.gd create mode 100644 src/ui/settings_screen.gd.uid create mode 100644 tests/unit/test_settings.gd create mode 100644 tests/unit/test_settings.gd.uid diff --git a/CLAUDE.md b/CLAUDE.md index 04e8eed..4909bf8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,7 @@ and `tests/integration/test_replica_parity.gd` pin this down. | `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. | | `src/instances/` | Lobby hub and dungeon runs. | | `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. | +| `src/core/settings.gd` | Client-local preferences: key bindings and volumes. Never reaches the server. | | `src/autoload/net.gd` | The only autoload. RPC surface. | | `tools/` | Headless tooling. | @@ -205,6 +206,10 @@ ticks in milliseconds with no SceneTree. - **A boss never leaves its arena.** `SimWorld._step_boss` clamps to `SimBoss.room` *after* movement. Boss rooms deliberately do not lock, so walking out is always an escape — which only holds if the boss cannot follow. +- **Every actor the client draws is interpolated between snapshots.** Players, + enemies and the boss all go through the same lerp. The boss did not for a + long time, which is invisible while bosses stand still and looks broken the + moment one moves. - **A telegraph must be visible through fog.** `WorldView._draw_telegraphs` ignores line of sight on purpose; everything else in the view respects it. A warning you cannot see is an unavoidable hit with extra steps. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 9c67f51..5c02780 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -429,3 +429,52 @@ longest-lived bullet in the game. The other seven frames stay in the atlas because that is what the source art is. Nothing draws them, and the renderer now slices four textures at startup instead of thirty-two. + +--- + +## Settings + +**Preferences are local to the client and nothing else knows about them.** +Which key fires produces the same [InputFrame] either way, so the server has no +opinion and never hears about it. `Settings` is static for the same reason +`GameOpts` is: a `--script` tool has no main loop and cannot resolve autoloads. + +**Bindings are stored as physical keycodes and labelled through the active +layout.** Physical is what `tools/setup_input_map.gd` already uses, so bindings +follow key *position*; translating the label back means an AZERTY player reads +the letter printed on the key their fingers are on. The translation is skipped +on a display server with no keyboard — headless does not merely lack the call, +it logs an engine error and hands the argument back, and there is no feature +flag to test for. + +**A rebind replaces every event on the action, not just the first.** Keeping the +alternates would mean the action still answered to the key you just moved away +from, which reads as the rebind not having worked. The cost is that rebinding +movement loses the arrow keys; untouched actions keep all their defaults. + +**One key doing two things is refused, and the clash is named.** Silently +accepting it produces a control scheme that is broken in a way the player has to +diagnose themselves. + +**"Reset" restores what the PROJECT shipped**, captured once before anything +overrides it. Captured later it would restore the last session's choice — that +is, the thing the player was trying to undo. + +**Effects play on an SFX bus created at runtime.** A bus layout resource would +be one more file to keep in step with the code that reads it, and the sliders +being real mixer settings beats multiplying a number into every `play()` call. + +--- + +## Drawing a boss that moves + +**The boss is interpolated between snapshots like every other actor.** It was +not — `boss_state()` handed back the newest snapshot raw, so the boss stepped at +the 20 Hz snapshot rate rather than the frame rate. Invisible for as long as +every boss stood still, and the first one that moved looked broken. Not +interpolated across an instance change, though: the previous snapshot describes +a different fight in a different room. + +**A CHASE boss has a dead band around its preferred distance.** The correction +is signed, so without one the sign flips every tick at the standoff and the boss +vibrates on the spot at the tick rate — a couple of pixels, and unmistakable. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 87d6d56..571c3db 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,8 +20,8 @@ What "everything passes" currently means. Numbers move; the shape does not. | Gate | Covers | Runtime | | --- | --- | --- | | `tools/check.sh` | every script parses and type-checks | ~5s | -| `tools/test.sh` | 414 GUT tests, no SceneTree | ~4s | -| `tools/smoke.sh` | 22 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s | +| `tools/test.sh` | 439 GUT tests, no SceneTree | ~4s | +| `tools/smoke.sh` | 23 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s | | `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s | | `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s | | `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s | @@ -128,7 +128,8 @@ play off server events. Enough to prove the pipeline, not a finished look. | Rects validated without a display | done | `tests/unit/test_art.gd` | | Impact/death VFX animation | todo | `Art.IMPACT` is loaded and validated but nothing plays it yet | | Directional sprites, hit flashes, screen shake | todo | | -| Audio buses and a volume setting | todo | Everything plays on Master at hardcoded dB | +| Rebindable controls | done | [src/ui/settings_screen.gd](../src/ui/settings_screen.gd), [src/core/settings.gd](../src/core/settings.gd) | +| Audio buses and a volume setting | done | `Settings`, an SFX bus created at runtime, sliders in the settings screen | | **In-game credits screen** | **todo** | Not cosmetic: the SFX are CC BY 4.0 and attribution is a licence *requirement*. [CREDITS.md](../CREDITS.md) is not reachable by a player. | | Replace the two non-redistributable packs | todo | Bullet and FX art is local-only and non-commercial. CC0 replacements would let them into the repo and unblock a commercial release. See [ASSETS.md](ASSETS.md). | @@ -391,6 +392,11 @@ had already gone stale and silently stopped writing the second boss. ### Known gaps +- **Nothing tests how the fight *looks*.** The boss was drawn straight from the + newest snapshot with no interpolation — a visible step at 20 Hz — and every + test passed for as long as bosses stood still. There is still no automated + check that a moving actor renders smoothly; the ones added cover the + interpolation call, not the impression. - **No boss-specific music, intro or death sequence.** A boss dies like an enemy, only louder. - **Telegraph markers are one shape.** A circle is the only warning the client diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index be9c44b..f15e40e 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -235,3 +235,19 @@ Godot preinstalled. All three are headless, so nothing extra is needed. - [GUT](https://github.com/bitwes/Gut) · [gdUnit4](https://github.com/godot-gdunit-labs/gdUnit4) - [Godot: high-level multiplayer](https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html) - [Godot: command line tutorial](https://docs.godotengine.org/en/stable/tutorials/editor/command_line_tutorial.html) + +### A green suite is not a quiet one + +`tools/test.sh` and `tools/smoke.sh` both passed while a client logged twelve +engine errors at every startup. `ConfigFile.get_value(section, key, null)` does +not mean "no default" — it means the key is absent *and* no default was given, +so the engine logs an error for each one. Nothing caught it because the smoke +test's refutations matched `SCRIPT ERROR|Parse Error|USER ERROR`, and a plain +`ERROR:` is none of those. + +It surfaced from actually running the client (`--listen --autoquit`) and reading +the output, which is worth doing after any change to startup. + +`smoke.sh` now asserts no plain engine errors either, excluding by name the one +line Godot prints on every clean exit — naming it means anything else that turns +up is a real finding rather than noise to be squinted past. diff --git a/src/core/settings.gd b/src/core/settings.gd new file mode 100644 index 0000000..406d84e --- /dev/null +++ b/src/core/settings.gd @@ -0,0 +1,255 @@ +class_name Settings +extends RefCounted +## Player preferences: key bindings and volumes. +## +## Client-side and local. Nothing here reaches the server or the simulation -- +## which key you press to fire is not something the server has an opinion about, +## and the input frame it eventually produces looks identical either way. +## +## Static rather than an autoload for the same reason [GameOpts] is: a `--script` +## tool has no main loop and cannot resolve autoload names, and the settings +## file is something a headless test may well want to read. + +const PATH := "user://settings.cfg" + +## Actions the settings screen offers, in the order it lists them. Anything not +## here keeps whatever `tools/setup_input_map.gd` gave it and cannot be changed +## -- which is the right default for a binding nobody should be reassigning. +const REBINDABLE: Array = [ + ["move_up", "Move up"], + ["move_down", "Move down"], + ["move_left", "Move left"], + ["move_right", "Move right"], + ["fire", "Fire"], + ["interact", "Interact / pick up"], + ["emergency_escape", "Emergency escape"], + ["use_slot_1", "Use slot 1"], + ["use_slot_2", "Use slot 2"], + ["use_slot_3", "Use slot 3"], + ["use_slot_4", "Use slot 4"], + ["system_menu", "Menu"], + ["debug_overlay", "Hitbox overlay"], +] + +## Bus every sound effect plays on. Created at runtime rather than shipped as a +## bus layout resource, so there is one fewer file to keep in step with the code +## that reads it. +const SFX_BUS := "SFX" + +static var master_volume: float = 0.8 +static var sfx_volume: float = 0.8 +## action -> a serialisable description of its event. See _describe(). +static var bindings: Dictionary[String, Dictionary] = {} + +## What the project shipped, captured before anything is overridden. Without it +## "reset to defaults" would restore whatever the last session happened to save. +static var _defaults: Dictionary[String, Dictionary] = {} +static var _defaults_captured: bool = false + + +# --- Lifecycle --------------------------------------------------------------- + +## Read the file and apply everything. Safe to call with no file present, which +## is every first run. +static func load_and_apply() -> void: + _capture_defaults() + var cfg := ConfigFile.new() + if cfg.load(PATH) == OK: + master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0) + sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0) + bindings.clear() + for action in REBINDABLE: + var key := String(action[0]) + # has_section_key first: passing null as the default to get_value() + # does NOT mean "no default", it means the key is absent AND no + # default was given, and the engine logs an error for every missing + # one. A settings file written before an action existed is normal. + if not cfg.has_section_key("input", key): + continue + var stored: Variant = cfg.get_value("input", key) + # A binding this build cannot make sense of is dropped, not + # guessed at: an unusable control is worse than the default one. + if typeof(stored) == TYPE_DICTIONARY and _event_from(stored) != null: + bindings[key] = stored + apply_audio() + apply_input() + + +static func save() -> void: + var cfg := ConfigFile.new() + cfg.set_value("audio", "master", master_volume) + cfg.set_value("audio", "sfx", sfx_volume) + for action in bindings: + cfg.set_value("input", action, bindings[action]) + var err := cfg.save(PATH) + if err != OK: + GameLog.warn("settings", "could not write %s (error %d)" % [PATH, err]) + + +# --- Audio ------------------------------------------------------------------- + +## Ensure the SFX bus exists and push both volumes onto the mixer. +static func apply_audio() -> void: + var sfx := AudioServer.get_bus_index(SFX_BUS) + if sfx < 0: + AudioServer.add_bus() + sfx = AudioServer.bus_count - 1 + AudioServer.set_bus_name(sfx, SFX_BUS) + AudioServer.set_bus_send(sfx, "Master") + AudioServer.set_bus_volume_db(0, linear_to_db_clamped(master_volume)) + AudioServer.set_bus_mute(0, master_volume <= 0.0) + AudioServer.set_bus_volume_db(sfx, linear_to_db_clamped(sfx_volume)) + AudioServer.set_bus_mute(sfx, sfx_volume <= 0.0) + + +## Godot's linear_to_db(0) is -inf, which serialises badly and reads as a bug +## when it turns up in a log. The clamp is what prevents that -- 0.0001 comes +## out at exactly -80dB, far below audible -- so a zero or a negative from a +## corrupt settings file lands there rather than at negative infinity. Actual +## silence is the bus mute flag, set alongside this. +static func linear_to_db_clamped(linear: float) -> float: + return linear_to_db(clampf(linear, 0.0001, 1.0)) + + +# --- Input ------------------------------------------------------------------- + +## Snapshot the project's own bindings. Called before anything overrides them, +## and only once -- a second call after an override would capture the override. +static func _capture_defaults() -> void: + if _defaults_captured: + return + _defaults_captured = true + for entry in REBINDABLE: + var action := String(entry[0]) + if not InputMap.has_action(action): + continue + var events := InputMap.action_get_events(action) + if not events.is_empty(): + _defaults[action] = _describe(events[0]) + + +## Rewrite the InputMap from [member bindings]. Actions with no override are +## restored to what the project shipped, so clearing one binding cannot leave a +## previous session's choice behind. +static func apply_input() -> void: + _capture_defaults() + for entry in REBINDABLE: + var action := String(entry[0]) + if not InputMap.has_action(action): + continue + var described: Dictionary = bindings.get(action, _defaults.get(action, {})) + var event := _event_from(described) + if event == null: + continue + # Replacing every event rather than the first: an action that kept its + # alternates would still answer to the key the player just moved away + # from, which reads as the rebind not having worked. + InputMap.action_erase_events(action) + InputMap.action_add_event(action, event) + + +## Assign [param event] to [param action]. Returns the action it collided with, +## or an empty string on success -- one key doing two things is a broken +## control scheme, so it is refused rather than silently accepted. +static func rebind(action: String, event: InputEvent) -> String: + var described := _describe(event) + if described.is_empty(): + return action + for entry in REBINDABLE: + var other := String(entry[0]) + if other == action: + continue + if current_binding(other) == described: + return other + bindings[action] = described + apply_input() + save() + return "" + + +static func reset_bindings() -> void: + bindings.clear() + apply_input() + save() + + +## What [param action] is bound to right now: the override if there is one, +## otherwise what the project shipped. +static func current_binding(action: String) -> Dictionary: + _capture_defaults() + return bindings.get(action, _defaults.get(action, {})) + + +static func binding_label(action: String) -> String: + return describe_label(current_binding(action)) + + +static func describe_label(described: Dictionary) -> String: + match String(described.get("type", "")): + "key": + var code := int(described.get("code", 0)) + # 0 is not a key. _event_from refuses to build one, so a stored 0 + # never reaches the input map -- but it would render as a blank + # button, which looks like a bound key with no name. + if code == 0: + return "unbound" + return OS.get_keycode_string(_layout_keycode(code)) + "mouse": + match int(described.get("code", 0)): + MOUSE_BUTTON_LEFT: return "Mouse Left" + MOUSE_BUTTON_RIGHT: return "Mouse Right" + MOUSE_BUTTON_MIDDLE: return "Mouse Middle" + return "Mouse %d" % int(described["code"]) + return "unbound" + + +## Bindings are stored as PHYSICAL keycodes, so they follow key position rather +## than layout -- the same choice tools/setup_input_map.gd makes. The label is +## translated back through the active layout so an AZERTY player reads "A" for +## the key their fingers are on, rather than the QWERTY name of that position. +## +## The translation is skipped on a display server that has no keyboard, which +## is headless: the call is not merely unsupported there, it pushes an engine +## error and hands the argument straight back, so asking is worse than not. +## There is no feature flag to test for it. +static func _layout_keycode(physical: int) -> int: + if physical == 0 or DisplayServer.get_name() == "headless": + return physical + return DisplayServer.keyboard_get_keycode_from_physical(physical) + + +## Only keys and mouse buttons are accepted. A rebind listener that took any +## InputEvent would happily capture mouse MOTION the instant the player moved +## the mouse, which is not a binding anyone meant to make. +static func is_bindable(event: InputEvent) -> bool: + return not _describe(event).is_empty() + + +static func _describe(event: InputEvent) -> Dictionary: + if event is InputEventKey: + var key := event as InputEventKey + var code := key.physical_keycode if key.physical_keycode != 0 else key.keycode + if code == 0: + return {} + return {"type": "key", "code": int(code)} + if event is InputEventMouseButton: + return {"type": "mouse", "code": int((event as InputEventMouseButton).button_index)} + return {} + + +static func _event_from(described: Dictionary) -> InputEvent: + match String(described.get("type", "")): + "key": + var k := InputEventKey.new() + # device -1 is the only value that matches input from a real + # device; a freshly constructed event defaults to 16, which + # silently matches nothing. See tools/setup_input_map.gd. + k.device = -1 + k.physical_keycode = int(described.get("code", 0)) + return k if k.physical_keycode != 0 else null + "mouse": + var m := InputEventMouseButton.new() + m.device = -1 + m.button_index = int(described.get("code", 0)) + return m if m.button_index != 0 else null + return null diff --git a/src/core/settings.gd.uid b/src/core/settings.gd.uid new file mode 100644 index 0000000..21148c7 --- /dev/null +++ b/src/core/settings.gd.uid @@ -0,0 +1 @@ +uid://pdbmt03hxfum diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index 25257f0..ea14382 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -139,6 +139,13 @@ const PARALLEL_OFFSET := 15.0 const SPLIT_ANGLE_DEG := 45.0 ## How long one dose of Poison takes to deliver its damage. const POISON_DURATION_TICKS := 600 # 10 seconds +## How near its preferred distance a CHASE boss counts as "there". +## +## Without a dead band the sign of the correction flips every tick once it +## arrives, and the boss buzzes on the spot at 60Hz. Small in world units, and +## unmistakable on screen. +const BOSS_CHASE_DEADBAND := 8.0 + ## How close to the hub's upgrade NPC you must stand to spend a choice. ## Enforced on the server, like the portal: standing somewhere is the only ## thing a client cannot lie about. diff --git a/src/game.tscn b/src/game.tscn index 2d159ee..594c40e 100644 --- a/src/game.tscn +++ b/src/game.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=9 format=3] +[gd_scene load_steps=10 format=3] [ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"] [ext_resource type="Script" path="res://src/view/world_view.gd" id="2"] @@ -8,6 +8,7 @@ [ext_resource type="Script" path="res://src/view/sfx.gd" id="6"] [ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"] [ext_resource type="Script" path="res://src/ui/upgrade_screen.gd" id="8"] +[ext_resource type="Script" path="res://src/ui/settings_screen.gd" id="9"] [node name="Game" type="Node2D"] script = ExtResource("1") @@ -33,3 +34,6 @@ script = ExtResource("7") [node name="UpgradeScreen" type="CanvasLayer" parent="."] script = ExtResource("8") + +[node name="SettingsScreen" type="CanvasLayer" parent="."] +script = ExtResource("9") diff --git a/src/main.gd b/src/main.gd index e1d4c23..9be0ae5 100644 --- a/src/main.gd +++ b/src/main.gd @@ -10,6 +10,7 @@ const GAME_SCENE := preload("res://src/game.tscn") const CONNECT_TIMEOUT_SEC := 8.0 var _menu: Control = null +var _settings: CanvasLayer = null var _game: Node = null var _ticks: int = 0 var _connecting: bool = false @@ -23,6 +24,10 @@ func _ready() -> void: # engine default and would silently desync server and client if that # default ever changed. Engine.physics_ticks_per_second = SimConfig.TICK_RATE + # Bindings and volumes, before anything can read the input map or play a + # sound. A dedicated server has neither, so it does not pay for them. + if not GameOpts.is_server: + Settings.load_and_apply() Net.state_changed.connect(_on_net_state) if GameOpts.is_server: @@ -63,6 +68,7 @@ func _show_menu() -> void: _menu = preload("res://src/ui/main_menu.gd").new() _menu.join_requested.connect(_join) _menu.host_requested.connect(_host_and_play) + _menu.settings_requested.connect(_open_settings) add_child(_menu) # Unconditional, and the reason this is not an early-return when the menu # already exists: a failed connection returns here with the buttons still @@ -139,6 +145,16 @@ func _enter_game() -> void: add_child(_game) +## The main menu's copy of the settings screen. The in-game menu has its own -- +## they are the same screen, and neither exists while the other is on display. +func _open_settings() -> void: + if _settings == null: + _settings = preload("res://src/ui/settings_screen.gd").new() + _settings.closed.connect(func() -> void: _settings.visible = false) + add_child(_settings) + _settings.open() + + func _clear_game() -> void: if _game != null: _game.queue_free() diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 8ad67fd..e1a0313 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -625,10 +625,27 @@ func inventory_full() -> bool: return true +## The boss, interpolated between the last two snapshots exactly like every +## other actor. +## +## It used to be handed back raw, which meant it jumped to each new snapshot the +## moment it arrived -- a visible step three times slower than the frame rate. +## Nobody noticed while every boss stood still. The first one that moved looked +## broken. func boss_state() -> Dictionary: if snap_curr.is_empty() or snap_curr.get("boss") == null: return {} - return snap_curr["boss"] + var rec: Dictionary = (snap_curr["boss"] as Dictionary).duplicate() + if snap_prev.is_empty() or snap_prev.get("boss") == null: + return rec + var old: Dictionary = snap_prev["boss"] + # Only between the same boss. Across an instance change the previous + # snapshot describes a different fight in a different room, and lerping to + # it would fling the new boss across the map for one frame. + if int(old["id"]) != int(rec["id"]): + return rec + rec["pos"] = (old["pos"] as Vector2).lerp(rec["pos"], _interp) + return rec func _interpolated(list_key: String, id_key: String, exclude: Array) -> Array[Dictionary]: diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index ca7728f..58f6c5b 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -613,7 +613,12 @@ func _move_boss(phase: BossPhase) -> void: # Signed, so it backs off when you close inside its preferred # distance. A boss that ends up standing on you is a boss whose # bullets you cannot see coming. - step = (to_player / gap) * signf(gap - phase.move_param) * phase.move_speed * dt + var error := gap - phase.move_param + # Close enough. Without this the sign flips every tick once it + # arrives and the boss buzzes on the spot at the tick rate. + if absf(error) <= SimConfig.BOSS_CHASE_DEADBAND: + return + step = (to_player / gap) * signf(error) * phase.move_speed * dt BossPhase.Move.WAYPOINTS: if phase.waypoints.is_empty(): return diff --git a/src/ui/game_menu.gd b/src/ui/game_menu.gd index 4310a10..0470aa4 100644 --- a/src/ui/game_menu.gd +++ b/src/ui/game_menu.gd @@ -10,6 +10,7 @@ signal resumed signal return_to_hub_requested signal disconnect_requested signal characters_requested +signal settings_requested var _panel: VBoxContainer var _hub_button: Button @@ -60,6 +61,9 @@ func _ready() -> void: _characters_button = _button("Change character", func() -> void: characters_requested.emit() close()) + _button("Settings", func() -> void: + settings_requested.emit() + close()) _button("Resume", func() -> void: close()) _disconnect_button = _button("Disconnect to menu", func() -> void: disconnect_requested.emit() diff --git a/src/ui/main_menu.gd b/src/ui/main_menu.gd index 1eae6ac..0c75090 100644 --- a/src/ui/main_menu.gd +++ b/src/ui/main_menu.gd @@ -4,6 +4,7 @@ extends Control signal join_requested(address: String, port: int) signal host_requested(port: int) +signal settings_requested var _address: LineEdit var _port: LineEdit @@ -55,6 +56,11 @@ func _ready() -> void: _host_button.pressed.connect(_on_host) panel.add_child(_host_button) + var settings := Button.new() + settings.text = "Settings" + settings.pressed.connect(func() -> void: settings_requested.emit()) + panel.add_child(settings) + var quit := Button.new() quit.text = "Quit" quit.pressed.connect(func() -> void: get_tree().quit()) diff --git a/src/ui/settings_screen.gd b/src/ui/settings_screen.gd new file mode 100644 index 0000000..cf6d40b --- /dev/null +++ b/src/ui/settings_screen.gd @@ -0,0 +1,213 @@ +extends CanvasLayer +## Volumes and key bindings. +## +## Reachable from the main menu and from the in-game menu, and the same screen +## both times: settings are local to this client, so there is nothing about the +## game's state that should change what it can do. + +signal closed + +var _rows: Dictionary[String, Button] = {} +var _status: Label +var _master: HSlider +var _sfx: HSlider +var _master_value: Label +var _sfx_value: Label +## The action currently waiting for a key press, or empty. +var _listening: String = "" + + +func _ready() -> void: + layer = 32 + visible = false + var root := Control.new() + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(root) + + var scrim := ColorRect.new() + scrim.color = Color(0.03, 0.03, 0.06, 0.94) + scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + scrim.mouse_filter = Control.MOUSE_FILTER_STOP + root.add_child(scrim) + + var centre := CenterContainer.new() + centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_child(centre) + + var panel := VBoxContainer.new() + panel.custom_minimum_size = Vector2(520.0, 0.0) + panel.add_theme_constant_override("separation", 8) + centre.add_child(panel) + + var title := Label.new() + title.text = "SETTINGS" + title.add_theme_font_size_override("font_size", 26) + panel.add_child(title) + + panel.add_child(_heading("SOUND")) + var master_row := _slider_row(panel, "Master") + _master = master_row[0] + _master_value = master_row[1] + var sfx_row := _slider_row(panel, "Effects") + _sfx = sfx_row[0] + _sfx_value = sfx_row[1] + + panel.add_child(_heading("CONTROLS")) + _status = Label.new() + _status.add_theme_font_size_override("font_size", 12) + _status.add_theme_color_override("font_color", Color(1.0, 0.7, 0.5)) + panel.add_child(_status) + + # Scrolled: thirteen rows is more than fits on a short window, and a list + # that runs off the bottom of the screen is a list with unreachable entries. + var scroll := ScrollContainer.new() + scroll.custom_minimum_size = Vector2(520.0, 300.0) + panel.add_child(scroll) + var list := VBoxContainer.new() + list.custom_minimum_size = Vector2(500.0, 0.0) + list.add_theme_constant_override("separation", 3) + scroll.add_child(list) + for entry in Settings.REBINDABLE: + _rows[String(entry[0])] = _binding_row(list, String(entry[0]), String(entry[1])) + + var buttons := HBoxContainer.new() + buttons.add_theme_constant_override("separation", 8) + panel.add_child(buttons) + var reset := Button.new() + reset.text = "Reset controls" + reset.custom_minimum_size = Vector2(160.0, 34.0) + reset.pressed.connect(func() -> void: + Settings.reset_bindings() + _status.text = "controls reset" + refresh()) + buttons.add_child(reset) + var close := Button.new() + close.text = "Close" + close.custom_minimum_size = Vector2(160.0, 34.0) + close.pressed.connect(func() -> void: closed.emit()) + buttons.add_child(close) + + +func _heading(text: String) -> Label: + var l := Label.new() + l.text = text + l.add_theme_font_size_override("font_size", 14) + l.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78)) + return l + + +func _slider_row(parent: Control, label: String) -> Array: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + parent.add_child(row) + var name_label := Label.new() + name_label.text = label + name_label.custom_minimum_size = Vector2(90.0, 0.0) + row.add_child(name_label) + var slider := HSlider.new() + slider.min_value = 0.0 + slider.max_value = 1.0 + slider.step = 0.05 + slider.custom_minimum_size = Vector2(340.0, 20.0) + row.add_child(slider) + var value := Label.new() + value.custom_minimum_size = Vector2(50.0, 0.0) + row.add_child(value) + return [slider, value] + + +func _binding_row(parent: Control, action: String, label: String) -> Button: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + parent.add_child(row) + var name_label := Label.new() + name_label.text = label + name_label.custom_minimum_size = Vector2(260.0, 0.0) + row.add_child(name_label) + var button := Button.new() + button.custom_minimum_size = Vector2(200.0, 30.0) + button.pressed.connect(func() -> void: _listen_for(action)) + row.add_child(button) + return button + + +func open() -> void: + visible = true + _listening = "" + _status.text = "" + refresh() + + +func refresh() -> void: + _master.value = Settings.master_volume + _sfx.value = Settings.sfx_volume + _master_value.text = "%d%%" % roundi(Settings.master_volume * 100.0) + _sfx_value.text = "%d%%" % roundi(Settings.sfx_volume * 100.0) + # Connected here rather than in _ready so setting the values above cannot + # fire the handler and write the file back on every open. + if not _master.value_changed.is_connected(_on_master): + _master.value_changed.connect(_on_master) + _sfx.value_changed.connect(_on_sfx) + for action in _rows: + _rows[action].text = "listening..." if action == _listening \ + else Settings.binding_label(action) + + +func _on_master(value: float) -> void: + Settings.master_volume = value + Settings.apply_audio() + Settings.save() + _master_value.text = "%d%%" % roundi(value * 100.0) + + +func _on_sfx(value: float) -> void: + Settings.sfx_volume = value + Settings.apply_audio() + Settings.save() + _sfx_value.text = "%d%%" % roundi(value * 100.0) + + +func _listen_for(action: String) -> void: + _listening = action + _status.text = "press a key or mouse button (Escape cancels)" + refresh() + + +## Captures the next key or mouse button while a row is listening. +## +## _input rather than _unhandled_input on purpose: the buttons in this screen +## consume clicks, and a rebind that could never see a mouse button would be a +## rebind that cannot bind Fire. +func _input(event: InputEvent) -> void: + if not visible or _listening.is_empty(): + return + if event is InputEventKey and not (event as InputEventKey).pressed: + return + if event is InputEventMouseButton and not (event as InputEventMouseButton).pressed: + return + if not Settings.is_bindable(event): + return + get_viewport().set_input_as_handled() + var action := _listening + _listening = "" + # Escape is the way out of a listening row, so it cannot also be captured + # by one -- binding the cancel key would leave no way to cancel. + if event is InputEventKey \ + and (event as InputEventKey).physical_keycode == KEY_ESCAPE: + _status.text = "cancelled" + refresh() + return + var clash := Settings.rebind(action, event) + if clash.is_empty(): + _status.text = "" + else: + _status.text = "%s is already %s" % [ + Settings.binding_label(clash), _label_of(clash)] + refresh() + + +func _label_of(action: String) -> String: + for entry in Settings.REBINDABLE: + if String(entry[0]) == action: + return String(entry[1]) + return action diff --git a/src/ui/settings_screen.gd.uid b/src/ui/settings_screen.gd.uid new file mode 100644 index 0000000..95b428f --- /dev/null +++ b/src/ui/settings_screen.gd.uid @@ -0,0 +1 @@ +uid://m6dfka8r5y2o diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index 570a5f4..12a40c3 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -9,6 +9,7 @@ extends Node2D @onready var sfx: Node = $Sfx @onready var characters: CanvasLayer = $CharacterSelect @onready var upgrades: CanvasLayer = $UpgradeScreen +@onready var settings: CanvasLayer = $SettingsScreen var _bound: ClientRuntime = null ## Opened deliberately from the menu, as opposed to forced open by having no @@ -34,6 +35,8 @@ func _ready() -> void: characters.create_requested.connect(func(n: String) -> void: Net.create_character(n)) characters.closed.connect(func() -> void: _roster_open = false) upgrades.closed.connect(func() -> void: _upgrades_open = false) + menu.settings_requested.connect(func() -> void: settings.open()) + settings.closed.connect(func() -> void: settings.visible = false) upgrades.choose_requested.connect(func(i: int) -> void: Net.choose_upgrade(i)) diff --git a/src/view/sfx.gd b/src/view/sfx.gd index bd60ae9..ec3f8f6 100644 --- a/src/view/sfx.gd +++ b/src/view/sfx.gd @@ -20,9 +20,13 @@ var _frame: int = 0 func _ready() -> void: + # Settings.apply_audio() creates the bus; make sure it exists before the + # players are pointed at it, or they silently fall back to Master and the + # effects slider does nothing. + Settings.apply_audio() for i in VOICES: var p := AudioStreamPlayer.new() - p.bus = "Master" + p.bus = Settings.SFX_BUS add_child(p) _voices.append(p) diff --git a/tests/unit/test_boss_movement.gd b/tests/unit/test_boss_movement.gd index b1e775d..ded2879 100644 --- a/tests/unit/test_boss_movement.gd +++ b/tests/unit/test_boss_movement.gd @@ -284,3 +284,28 @@ func test_the_cantor_survives_its_own_fight() -> void: assert_eq(phases_seen.size(), b.def.phases.size(), "every phase ran") assert_gt(telegraphs, 0, "and it warned before striking at least once") assert_gt(inst.world.pool.live_count, 0, "and it is actually shooting") + + +# --- Not looking jittery ---------------------------------------------------- + +## Once it has arrived it stays arrived. The correction is signed, so without a +## dead band the sign flips every tick at the standoff distance and the boss +## vibrates on the spot -- tiny in world units, unmistakable on screen. +func test_a_chasing_boss_settles_instead_of_buzzing() -> void: + var b := _with_phase(_chase_phase(150.0), Vector2(-250.0, 0.0)) + var p := world.add_player(1, "bait") + p.pos = Vector2(100.0, 0.0) + _step(300) + var settled := b.pos + var worst := 0.0 + for _i in 120: + world.step() + worst = maxf(worst, b.pos.distance_to(settled)) + assert_lt(worst, 0.001, "it moved %.2fpx after settling" % worst) + + +func test_the_dead_band_is_smaller_than_the_distance_it_guards() -> void: + # A band as wide as the standoff would mean the boss stops anywhere. + for phase in Content.cantor().phases: + if phase.move == BossPhase.Move.CHASE: + assert_lt(SimConfig.BOSS_CHASE_DEADBAND, phase.move_param * 0.25) diff --git a/tests/unit/test_settings.gd b/tests/unit/test_settings.gd new file mode 100644 index 0000000..fe90778 --- /dev/null +++ b/tests/unit/test_settings.gd @@ -0,0 +1,228 @@ +extends GutTest +## Player preferences. Local, client-side, and touching nothing the server or +## the simulation cares about -- which key fires produces the same input frame +## either way. + +const SCRATCH := "user://test_settings_%d.cfg" + +var _saved_bindings: Dictionary[String, Dictionary] = {} +var _saved_master: float = 0.0 +var _saved_sfx: float = 0.0 + + +func before_each() -> void: + # Settings is static, so a test that changed it would leak into the next + # one and into every other suite that reads the input map. + _saved_bindings = Settings.bindings.duplicate() + _saved_master = Settings.master_volume + _saved_sfx = Settings.sfx_volume + + +func after_each() -> void: + Settings.bindings = _saved_bindings + Settings.master_volume = _saved_master + Settings.sfx_volume = _saved_sfx + Settings.apply_input() + + +func _key_event(code: Key) -> InputEventKey: + var e := InputEventKey.new() + e.device = -1 + e.physical_keycode = code + return e + + +# --- What can be bound ------------------------------------------------------- + +func test_every_rebindable_action_actually_exists() -> void: + for entry in Settings.REBINDABLE: + assert_true(InputMap.has_action(String(entry[0])), + "%s is offered in settings but not in the input map" % entry[0]) + assert_false(String(entry[1]).is_empty(), "%s has no label" % entry[0]) + + +func test_every_action_starts_with_something_bound() -> void: + for entry in Settings.REBINDABLE: + assert_ne(Settings.binding_label(String(entry[0])), "unbound", + "%s shows as unbound before anything is changed" % entry[0]) + + +## Keys and mouse buttons only. A listener that accepted any event would bind +## mouse MOTION the instant the player moved the mouse. +func test_only_keys_and_mouse_buttons_are_bindable() -> void: + assert_true(Settings.is_bindable(_key_event(KEY_J))) + var click := InputEventMouseButton.new() + click.button_index = MOUSE_BUTTON_RIGHT + assert_true(Settings.is_bindable(click)) + assert_false(Settings.is_bindable(InputEventMouseMotion.new())) + assert_false(Settings.is_bindable(InputEventJoypadMotion.new())) + assert_false(Settings.is_bindable(_key_event(KEY_NONE))) + + +# --- Rebinding --------------------------------------------------------------- + +func test_rebinding_changes_what_the_input_map_answers_to() -> void: + assert_eq(Settings.rebind("fire", _key_event(KEY_J)), "", + "an unused key should be accepted") + assert_true(InputMap.action_has_event("fire", _key_event(KEY_J))) + assert_eq(Settings.binding_label("fire"), Settings.describe_label( + {"type": "key", "code": int(KEY_J)})) + + +## The old key must stop working. An action that kept its alternates would still +## answer to the key you just moved away from, which reads as the rebind having +## failed. +func test_the_previous_key_stops_working() -> void: + var before := Settings.current_binding("interact") + Settings.rebind("interact", _key_event(KEY_J)) + var old := InputEventKey.new() + old.device = -1 + old.physical_keycode = int(before["code"]) + assert_false(InputMap.action_has_event("interact", old)) + + +## One key doing two things is a broken control scheme, so it is refused rather +## than silently accepted and left for the player to work out. +func test_a_key_already_in_use_is_refused_and_names_the_clash() -> void: + Settings.rebind("interact", _key_event(KEY_J)) + assert_eq(Settings.rebind("fire", _key_event(KEY_J)), "interact") + assert_false(InputMap.action_has_event("fire", _key_event(KEY_J)), + "and the refusal changes nothing") + + +func test_rebinding_an_action_to_its_own_key_is_allowed() -> void: + Settings.rebind("fire", _key_event(KEY_J)) + assert_eq(Settings.rebind("fire", _key_event(KEY_J)), "", + "re-confirming a binding is not a clash with itself") + + +func test_reset_restores_what_the_project_shipped() -> void: + var original := Settings.binding_label("move_up") + Settings.rebind("move_up", _key_event(KEY_J)) + assert_ne(Settings.binding_label("move_up"), original) + Settings.reset_bindings() + assert_eq(Settings.binding_label("move_up"), original) + assert_true(Settings.bindings.is_empty()) + + +## The bindings the reset restores are the PROJECT's, captured before anything +## overrode them. Captured later they would be whatever the last session chose, +## and "reset" would restore the thing you were trying to undo. +func test_defaults_are_the_projects_own_not_the_last_sessions() -> void: + Settings.rebind("move_left", _key_event(KEY_J)) + Settings.apply_input() + Settings._capture_defaults() # a second call must be a no-op + Settings.reset_bindings() + assert_ne(Settings.binding_label("move_left"), + Settings.describe_label({"type": "key", "code": int(KEY_J)})) + + +func test_a_mouse_button_can_be_bound() -> void: + var click := InputEventMouseButton.new() + click.device = -1 + click.button_index = MOUSE_BUTTON_RIGHT + assert_eq(Settings.rebind("fire", click), "") + assert_eq(Settings.binding_label("fire"), "Mouse Right") + + +# --- Persistence ------------------------------------------------------------- + +func test_settings_survive_a_save_and_reload() -> void: + var path := SCRATCH % randi() + var cfg := ConfigFile.new() + cfg.set_value("audio", "master", 0.25) + cfg.set_value("audio", "sfx", 0.5) + cfg.set_value("input", "fire", {"type": "key", "code": int(KEY_J)}) + cfg.save(path) + + var loaded := ConfigFile.new() + assert_eq(loaded.load(path), OK) + assert_almost_eq(float(loaded.get_value("audio", "master")), 0.25, 0.001) + var described: Dictionary = loaded.get_value("input", "fire") + assert_eq(Settings.describe_label(described), + Settings.describe_label({"type": "key", "code": int(KEY_J)})) + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +## A binding this build cannot make sense of is dropped rather than guessed at. +## An unusable control is worse than the default one. +func test_a_nonsense_saved_binding_does_not_break_the_action() -> void: + assert_eq(Settings.describe_label({}), "unbound") + assert_eq(Settings.describe_label({"type": "gamepad", "code": 3}), "unbound") + assert_eq(Settings.describe_label({"type": "key", "code": 0}), "unbound") + + +# --- Audio ------------------------------------------------------------------- + +## linear_to_db(0) is -inf, which serialises badly and reads as a bug in a log. +## Silence is the mute flag; this only has to floor well below audible. +func test_silence_is_a_finite_number() -> void: + var quiet := Settings.linear_to_db_clamped(0.0) + assert_true(is_finite(quiet)) + assert_lt(quiet, -60.0) + + +func test_full_volume_is_unattenuated() -> void: + assert_almost_eq(Settings.linear_to_db_clamped(1.0), 0.0, 0.01) + + +func test_volume_is_monotonic() -> void: + var last := -999.0 + for step in 11: + var db := Settings.linear_to_db_clamped(float(step) / 10.0) + assert_gt(db, last, "louder input must not be quieter output") + last = db + + +## Effects play on their own bus so the sliders are real mixer settings rather +## than a number multiplied into every play() call. +func test_applying_audio_creates_the_effects_bus() -> void: + Settings.apply_audio() + var index := AudioServer.get_bus_index(Settings.SFX_BUS) + assert_gte(index, 0, "the SFX bus should exist after apply") + assert_eq(AudioServer.get_bus_send(index), &"Master") + + +func test_applying_audio_twice_does_not_add_a_second_bus() -> void: + Settings.apply_audio() + var before := AudioServer.bus_count + Settings.apply_audio() + assert_eq(AudioServer.bus_count, before) + + +func test_zero_volume_mutes_rather_than_merely_attenuating() -> void: + Settings.master_volume = 0.0 + Settings.apply_audio() + assert_true(AudioServer.is_bus_mute(0)) + Settings.master_volume = 0.8 + Settings.apply_audio() + assert_false(AudioServer.is_bus_mute(0)) + + +## A corrupt or hand-edited file can hold anything. Every one of these has to +## come out as a real number the mixer will accept. +func test_nonsense_volumes_still_produce_a_usable_number() -> void: + for value in [-5.0, -0.0001, 0.0, 1.5, 1e9]: + var db := Settings.linear_to_db_clamped(value) + assert_true(is_finite(db), "%f produced %f" % [value, db]) + assert_lte(db, 0.0, "%f produced gain above unity" % value) + + +## A settings file from before an action existed -- or with a section missing +## entirely -- is an ordinary thing to find, not an error to log about. Reading +## it must be silent. +func test_a_settings_file_missing_keys_loads_without_complaint() -> void: + var path := SCRATCH % randi() + var cfg := ConfigFile.new() + cfg.set_value("audio", "master", 0.4) # audio only, no input section + cfg.save(path) + + var loaded := ConfigFile.new() + assert_eq(loaded.load(path), OK) + for entry in Settings.REBINDABLE: + assert_false(loaded.has_section_key("input", String(entry[0])), + "setup: this file has no bindings at all") + # The read path must consult has_section_key rather than relying on a + # default, which is what made this log an engine error per missing action. + assert_false(loaded.has_section_key("input", "move_up")) + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) diff --git a/tests/unit/test_settings.gd.uid b/tests/unit/test_settings.gd.uid new file mode 100644 index 0000000..6589a6f --- /dev/null +++ b/tests/unit/test_settings.gd.uid @@ -0,0 +1 @@ +uid://cyicgu8318br2 diff --git a/tests/unit/test_telegraph.gd b/tests/unit/test_telegraph.gd index e465fdb..54d7e73 100644 --- a/tests/unit/test_telegraph.gd +++ b/tests/unit/test_telegraph.gd @@ -217,3 +217,50 @@ func test_arriving_somewhere_clears_the_old_warnings() -> void: 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) diff --git a/tools/smoke.sh b/tools/smoke.sh index 1d58497..98c7662 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -97,6 +97,27 @@ check() { # check