132646f6c3
ci / verify (push) Successful in 48s
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>
153 lines
5.8 KiB
GDScript
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)
|