Files
transcience/src/view/game_scene.gd
T
claude 872922e9e2
ci / verify (push) Successful in 48s
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 <noreply@anthropic.com>
2026-09-07 10:56:44 +02:00

150 lines
5.6 KiB
GDScript

extends Node2D
## Root of the playable client scene. Wires the view and HUD to whatever client
## runtime [Net] currently has, and survives that runtime being null (during
## connection) or replaced (on reconnect).
@onready var world_view: Node2D = $WorldView
@onready var hud: CanvasLayer = $HUD
@onready var menu: CanvasLayer = $GameMenu
@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
## character to play.
var _roster_open: bool = false
## The quartermaster panel. Opened by walking to the NPC and pressing interact,
## and closed the moment you walk away -- the server refuses a choice made from
## anywhere else, so leaving the panel open at a distance would only offer a
## button that gets rejected.
var _upgrades_open: bool = false
func _ready() -> void:
_recentre()
get_viewport().size_changed.connect(_recentre)
menu.return_to_hub_requested.connect(_on_return_to_hub)
menu.disconnect_requested.connect(_on_disconnect)
menu.characters_requested.connect(func() -> void: _roster_open = true)
hud.respawn_pressed.connect(_on_respawn_pressed)
characters.select_requested.connect(func(id: String) -> void:
_roster_open = false
Net.select_character(id))
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))
var _screen_centre := Vector2.ZERO
func _recentre() -> void:
_screen_centre = get_viewport_rect().size * 0.5
## Scroll the world so the local player stays centred. Dungeons are larger than
## the viewport now, so a fixed camera would simply lose the player off-screen.
func _follow_camera() -> void:
var focus := Vector2.ZERO
if _bound != null:
focus = _bound.predicted_pos
world_view.position = _screen_centre - focus
if _bound != null:
# The client converts cursor position to world space with this, so it
# has to come from the transform actually used to draw, not from a
# second assumption about where the camera is.
_bound.camera_offset = world_view.position
func _process(_delta: float) -> void:
if Net.client != _bound:
_bound = Net.client
if _bound != null and not _bound.local_hit.is_connected(_on_local_hit):
_bound.local_hit.connect(_on_local_hit)
_bound.shot_fired.connect(func() -> void: sfx.play(Art.SFX_SHOOT, -14.0))
_bound.enemy_died.connect(func() -> void: sfx.play(Art.SFX_ENEMY_DEATH, -8.0))
_bound.boss_died.connect(func() -> void: sfx.play(Art.SFX_BOSS_DEATH, -2.0))
# Item feedback rides the same rule as every other sound here: it
# plays because a server event arrived, never because the client
# guessed a pickup succeeded.
_bound.item_picked_up.connect(func(_i: StringName) -> void:
sfx.play(Art.SFX_ENEMY_DEATH, -16.0))
_bound.item_used.connect(func(_i: StringName) -> void:
sfx.play(Art.SFX_SHOOT, -10.0))
_bound.characters_changed.connect(_refresh_characters)
_bound.upgrades_changed.connect(_refresh_upgrades)
_refresh_upgrades()
_bound.select_failed.connect(func(why: String) -> void:
characters.set_status(why)
upgrades.set_status(why))
_refresh_characters()
# The roster screen is shown exactly when there is nothing to play: first
# login, or after the last living character died.
if _bound != null:
# Forced open when there is nothing to play; opened deliberately from
# the menu otherwise. Never while in a dungeon -- the server refuses
# swaps there, so offering the screen would only invite a refusal.
var forced := _bound.needs_character()
if _bound.instance_kind != Protocol.InstanceKind.LOBBY:
_roster_open = false
characters.visible = forced or _roster_open
characters.set_dismissible(not forced)
if _bound != null and _upgrades_open and not _bound.at_upgrade_npc():
_upgrades_open = false
upgrades.visible = _upgrades_open
_follow_camera()
menu.set_in_dungeon(_bound != null
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
func _unhandled_input(event: InputEvent) -> void:
if not event.is_action_pressed("interact"):
return
if _bound == null or not _bound.at_upgrade_npc():
return
# Interact is also pick-up. Loot wins, exactly as it does on the server, so
# the key never does one thing here and another there.
if not _bound.loot_in_reach().is_empty():
return
_upgrades_open = not _upgrades_open
_refresh_upgrades()
func _refresh_upgrades() -> void:
if _bound != null:
upgrades.refresh(_bound.upgrades_pending, _bound.upgrade_offer,
_bound.upgrades_taken)
## Routed through the same held-escape channel the F key uses, rather than a
## direct "teleport me" message -- the server has no such message, and adding
## one would hand clients an instant, uninterruptible exit.
func _on_return_to_hub() -> void:
if _bound != null:
_bound.request_escape = true
func _on_disconnect() -> void:
Net.shutdown()
## Sets the intent bit; the server decides whether the lockout has expired.
func _refresh_characters() -> void:
if _bound != null:
characters.refresh(_bound.characters, _bound.selected_character)
func _on_respawn_pressed() -> void:
if _bound != null:
_bound.request_respawn = true
func _on_local_hit(_damage: int) -> void:
hud.flash_hit()
sfx.play(Art.SFX_PLAYER_HIT, -4.0)