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>
150 lines
5.6 KiB
GDScript
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)
|