Files
transcience/src/view/game_scene.gd
T
claude 132646f6c3
ci / verify (push) Successful in 48s
Rework the UI on Crusenho's pack; credit it in the game, not just the repo
Crusenho's Complete UI Essential Pack is CC BY 4.0 -- redistributable and
commercial-friendly, confirmed from the License.txt the pack itself ships --
so unlike the two bdragon packs a subset is committed: twelve PNGs, 48 KB,
under assets/sprites/ui/. Only what is used, because each committed PNG costs
a Godot .import sidecar and a directory nothing references is one nobody
prunes.

UiTheme builds a Theme in code from it -- button states, panels, line edits --
and every screen roots itself through UiTheme.themed_root(). The HUD's bars are
the pack's frame with a tinted fill, drawn as three horizontal slices because
Godot's nine-patch lives on nodes and the HUD is drawn rather than built from
controls. Inventory slots use the pack's slot art at exactly twice the source
size; a non-integer scale on a 1px border reads as a wobble along every edge.

The credits screen is the other half of the request and it is a licence
obligation, not a nicety: two packs are now CC BY, which asks for attribution
"in any reasonable manner", and a markdown file in a source repo is not
reasonable for someone who downloaded a build. Settings -> Credits shows every
source with its terms and a link to the licence text. test_credits.gd asserts
CREDITS.md and docs/ASSETS.md name every entry, so the three cannot drift.

Two things found by actually looking at the screen, which is the point:

  - The FIRST version of this styled nothing. A Control inherits its theme from
    Control ANCESTORS only, and the chain breaks at the first plain Node or
    CanvasLayer -- which is every screen here. get_window().theme set the
    property, changed nothing, and read as correct. check.sh, 458 tests and a
    clean smoke run all passed with the entire interface unstyled. The theme
    test now instantiates every screen and asks what its buttons resolve.
  - The settings screen showed Fire bound to the right mouse button, because
    the test suite was writing the player's real user://settings.cfg --
    rebinding calls save() and nothing had redirected the path. Settings.path
    is now redirectable, the fixture points it at a scratch file, and a test
    asserts the default is still the player's own.

tools/screenshot.tscn is what found both. It boots the client windowed and
saves the menus, the HUD, settings and credits. Manual, needs a display, and
the only thing in the project that can tell you the interface rendered.

check.sh clean, 460 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:12:13 +02:00

153 lines
5.8 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
@onready var credits: CanvasLayer = $CreditsScreen
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)
settings.credits_requested.connect(func() -> void: credits.open())
credits.closed.connect(func() -> void: credits.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)