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>
373 lines
15 KiB
GDScript
373 lines
15 KiB
GDScript
extends CanvasLayer
|
|
## Heads-up display. Built in code rather than as a scene because every element
|
|
## is data-driven -- there is no layout here a designer would want to drag.
|
|
|
|
const MARGIN := 24.0
|
|
const BAR_W := 260.0
|
|
const BAR_H := 16.0
|
|
## Inventory slot box, and the gap between boxes.
|
|
##
|
|
## Exactly twice the 32px source sprite. A non-integer scale on pixel art with
|
|
## nearest filtering gives uneven pixel widths, which on a 1px border reads as
|
|
## a wobble along the edge of every slot.
|
|
const SLOT := 64.0
|
|
const SLOT_GAP := 8.0
|
|
## Source-pixel width of the caps on the bar sprite. The bar is stretched only
|
|
## horizontally, so the ends are drawn at their own size and the middle takes
|
|
## whatever is left -- a plain stretched draw would smear a 1px border out to
|
|
## eight.
|
|
const BAR_CAP := 6.0
|
|
## How far above the bottom of the screen the inventory row sits.
|
|
const SLOT_BOTTOM := 26.0
|
|
|
|
signal respawn_pressed
|
|
|
|
var _canvas: Control
|
|
var _status: Label
|
|
var _hint: Label
|
|
var _respawn_button: Button
|
|
var _hit_flash: float = 0.0
|
|
var client: ClientRuntime = null
|
|
|
|
|
|
func _ready() -> void:
|
|
layer = 10
|
|
_canvas = Control.new()
|
|
# set_anchors_preset() alone leaves the offsets at whatever preserves the
|
|
# control's rect at the moment of the call -- (0,0)-sized for a brand new
|
|
# node, regardless of the anchors -- so _canvas.size silently stayed
|
|
# (0, 0) forever. That took the hit-flash overlay, the boss bar centering
|
|
# and the death-message centering down with it, since all three read
|
|
# _canvas.size. set_anchors_and_offsets_preset() sets both correctly.
|
|
_canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
UiTheme.apply_to(_canvas)
|
|
_canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_canvas.draw.connect(_draw_hud)
|
|
add_child(_canvas)
|
|
|
|
_status = _make_label(Vector2(MARGIN, MARGIN))
|
|
_hint = _make_label(Vector2(MARGIN, 0.0))
|
|
_hint.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT)
|
|
# `.position` assigns an ABSOLUTE coordinate regardless of anchors (that's
|
|
# what stranded this label off-screen despite the anchor being correct).
|
|
# offset_left/offset_top are the anchor-relative ones -- negative offset_top
|
|
# here means "56px above the bottom anchor", which is what "-56" was meant
|
|
# to say in the first place.
|
|
_hint.offset_left = MARGIN
|
|
# Above the inventory row, not through it. Derived from the row's own
|
|
# geometry rather than written down, because the slots grew from 46px to 64
|
|
# and a hardcoded -56 put the control hints straight across them.
|
|
_hint.offset_top = -(SLOT_BOTTOM + SLOT + 34.0)
|
|
|
|
# A real Button rather than something drawn in _draw_hud: this is the one
|
|
# HUD element the player has to actually click, and it starts disabled so
|
|
# death is not exited by whatever key happened to be under a finger.
|
|
_respawn_button = Button.new()
|
|
_respawn_button.custom_minimum_size = Vector2(240.0, 40.0)
|
|
_respawn_button.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
|
_respawn_button.offset_left = -120.0
|
|
_respawn_button.offset_right = 120.0
|
|
_respawn_button.offset_top = 40.0
|
|
_respawn_button.offset_bottom = 80.0
|
|
_respawn_button.pressed.connect(func() -> void: respawn_pressed.emit())
|
|
_respawn_button.visible = false
|
|
_canvas.add_child(_respawn_button)
|
|
|
|
|
|
func _make_label(pos: Vector2) -> Label:
|
|
var l := Label.new()
|
|
l.position = pos
|
|
l.add_theme_font_size_override("font_size", 14)
|
|
l.add_theme_color_override("font_color", Color(0.8, 0.85, 0.95))
|
|
_canvas.add_child(l)
|
|
return l
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
client = Net.client
|
|
_hit_flash = maxf(_hit_flash - delta * 2.5, 0.0)
|
|
_status.text = _status_text()
|
|
_hint.text = _hint_text()
|
|
_update_respawn_button()
|
|
_canvas.queue_redraw()
|
|
|
|
|
|
func _update_respawn_button() -> void:
|
|
var downed := client != null and not client.my_alive
|
|
_respawn_button.visible = downed
|
|
if not downed:
|
|
return
|
|
# Mirrors SimConfig.RESPAWN_LOCKOUT_TICKS, which the server enforces
|
|
# independently -- this is presentation, not the rule.
|
|
var ready := client.my_respawn_wait <= 0.0
|
|
_respawn_button.disabled = not ready
|
|
_respawn_button.text = "Return to hub" if ready \
|
|
else "Return to hub (%.0fs)" % ceil(client.my_respawn_wait)
|
|
|
|
|
|
func _status_text() -> String:
|
|
if client == null:
|
|
return "connecting..."
|
|
# Named rather than just "DUNGEON": there is more than one kind now, and
|
|
# knowing which one you are standing in is the whole point of having two.
|
|
var where := "LOBBY"
|
|
if client.instance_kind != Protocol.InstanceKind.LOBBY:
|
|
where = Dungeons.get_or_default(client.dungeon_id).display_name.to_upper()
|
|
var who := client.current_character()
|
|
var name_part := ""
|
|
if not who.is_empty():
|
|
name_part = "%s lv %d " % [
|
|
who["name"], Progression.level_for_xp(client.my_total_xp)]
|
|
return "%s%s instance %d hp %d/%d %d fps" % [
|
|
name_part, where, client.instance_id, client.my_hp, client.my_max_hp,
|
|
Engine.get_frames_per_second()]
|
|
|
|
|
|
func _hint_text() -> String:
|
|
if client == null:
|
|
return ""
|
|
if not client.my_alive:
|
|
return "DOWN"
|
|
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
|
return "WASD move mouse aim LMB fire E on a ring to enter that dungeon Esc menu F1 hitboxes"
|
|
return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu F1 hitboxes"
|
|
|
|
|
|
func _draw_hud() -> void:
|
|
if client == null:
|
|
return
|
|
var origin := Vector2(MARGIN, MARGIN + 28.0)
|
|
_bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)),
|
|
Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18))
|
|
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 4.0))
|
|
|
|
if client.my_escaping:
|
|
_bar(origin + Vector2(0.0, (BAR_H + 4.0) * 2.0), client.my_escape,
|
|
Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2))
|
|
_canvas.draw_string(ThemeDB.fallback_font,
|
|
origin + Vector2(BAR_W + 12.0, (BAR_H + 4.0) * 2.0 + BAR_H - 3.0),
|
|
"ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0))
|
|
|
|
_draw_boss_bar()
|
|
|
|
if client.my_spawn_grace:
|
|
_canvas.draw_string(ThemeDB.fallback_font,
|
|
origin + Vector2(BAR_W + 12.0, BAR_H),
|
|
"ARRIVING -- invulnerable, weapons cold",
|
|
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.6, 0.9, 1.0))
|
|
|
|
_draw_cleared_countdown()
|
|
_draw_roster()
|
|
_draw_inventory()
|
|
_draw_pickup_prompt()
|
|
_draw_level_up_notice()
|
|
|
|
if not client.my_alive:
|
|
# Centred on the canvas, which is only correct because _canvas actually
|
|
# has the viewport's size now -- see the anchor note in _ready().
|
|
var centre := _canvas.size * 0.5
|
|
_canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(52.0, 8.0),
|
|
"DOWN", HORIZONTAL_ALIGNMENT_LEFT, -1, 34, Color(1.0, 0.4, 0.4))
|
|
|
|
|
|
if _hit_flash > 0.0:
|
|
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
|
|
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
|
|
|
|
|
|
## Unspent level-ups. Shown wherever you are, because the choice is banked in a
|
|
## dungeon and spent in the hub -- if it only appeared next to the NPC, a player
|
|
## would have to already know to go and look.
|
|
func _draw_level_up_notice() -> void:
|
|
if client.upgrades_pending <= 0:
|
|
return
|
|
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004)
|
|
var where := " — see the quartermaster in the hub" \
|
|
if client.instance_kind != Protocol.InstanceKind.LOBBY else ""
|
|
_canvas.draw_string(ThemeDB.fallback_font,
|
|
Vector2(MARGIN, MARGIN + 74.0),
|
|
"%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending,
|
|
"" if client.upgrades_pending == 1 else "S", where],
|
|
HORIZONTAL_ALIGNMENT_LEFT, -1, 14,
|
|
Color(1.0, 0.85, 0.4, 0.6 + 0.4 * pulse))
|
|
|
|
|
|
## Four slots, always on screen. Deliberately not a panel you open: an
|
|
## inventory you have to stop and read is a menu, and a menu is a death in a
|
|
## game where the floor is bullets. Everything drawn here comes from the
|
|
## snapshot, so it is what the server says you have, never a local guess.
|
|
func _draw_inventory() -> void:
|
|
if client.my_inventory.is_empty():
|
|
return
|
|
var count := client.my_inventory.size()
|
|
var total := float(count) * SLOT + float(count - 1) * SLOT_GAP
|
|
var origin := Vector2((_canvas.size.x - total) * 0.5,
|
|
_canvas.size.y - SLOT_BOTTOM - SLOT)
|
|
var held := client.held_slot()
|
|
for i in count:
|
|
var at := origin + Vector2(float(i) * (SLOT + SLOT_GAP), 0.0)
|
|
var item := Items.by_index(int(client.my_inventory[i]))
|
|
var def := Items.get_def(item)
|
|
# The held slot uses the pack's pressed-in slot art rather than a
|
|
# recoloured border, so "this is the one the key is on" reads the same
|
|
# way every other pressed thing in the interface does.
|
|
var slot_art := UiTheme.texture("slot_active" if i == held else "slot")
|
|
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false)
|
|
# The slot number, because the key that uses it is the only thing the
|
|
# player actually needs to know about a slot.
|
|
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
|
|
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM)
|
|
if def == null:
|
|
continue
|
|
var icon := Art.item_icon(item)
|
|
var size := icon.size * Art.SCALE
|
|
_canvas.draw_texture_rect_region(Art.TILESET,
|
|
Rect2(at + (Vector2(SLOT, SLOT) - size) * 0.5, size), icon)
|
|
_canvas.draw_string(ThemeDB.fallback_font,
|
|
Vector2(origin.x, origin.y + SLOT + 15.0),
|
|
"1-%d use shift+1-%d drop E pick up" % [count, count],
|
|
HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.5, 0.55, 0.66))
|
|
|
|
|
|
## What pressing interact right now would do. The server runs the same search
|
|
## for itself and does not care what this concluded -- this is a label, not a
|
|
## decision.
|
|
func _draw_pickup_prompt() -> void:
|
|
var near := client.loot_in_reach()
|
|
if near.is_empty() or not client.my_alive:
|
|
return
|
|
var item := Items.by_index(int(near["item"]))
|
|
var def := Items.get_def(item)
|
|
if def == null:
|
|
return
|
|
var full := client.inventory_full()
|
|
var text := "inventory full -- %s stays where it is" % def.display_name \
|
|
if full else "E take %s" % def.display_name
|
|
var tint := Color(0.85, 0.5, 0.45) if full else def.tint
|
|
_canvas.draw_string(ThemeDB.fallback_font,
|
|
Vector2(_canvas.size.x * 0.5 - 150.0,
|
|
_canvas.size.y - SLOT_BOTTOM - SLOT - 22.0),
|
|
text, HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
|
|
|
|
|
|
## A thin bar under health: progress toward the next level, and the level
|
|
## itself. Drawn from the server's numbers, never recomputed locally.
|
|
func _draw_xp_bar(at: Vector2) -> void:
|
|
if client.current_character().is_empty():
|
|
return
|
|
# Derived from the snapshot's live experience total rather than the roster's
|
|
# copy, which only arrives when the set of characters changes.
|
|
var level := Progression.level_for_xp(client.my_total_xp)
|
|
var capped := level >= Progression.MAX_LEVEL
|
|
var progress := Progression.level_progress(client.my_total_xp)
|
|
var tint := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.0)
|
|
_bar(at, progress, tint, Color.BLACK)
|
|
# The number as well as the bar: "how far to the next level" is a question
|
|
# a bar answers vaguely and a percentage answers exactly.
|
|
var text := "MAX" if capped else "%d%% to level %d" % [
|
|
int(floor(progress * 100.0)), level + 1]
|
|
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 13.0),
|
|
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, tint)
|
|
|
|
|
|
## Shown after the boss dies, so the victory lap has a visible clock on it.
|
|
func _draw_cleared_countdown() -> void:
|
|
if client.cleared_countdown >= Protocol.COUNTDOWN_NONE:
|
|
return
|
|
var text := "DUNGEON CLEARED -- returning to the hub in %ds" % client.cleared_countdown
|
|
_canvas.draw_string(ThemeDB.fallback_font,
|
|
Vector2(_canvas.size.x * 0.5 - 190.0, _canvas.size.y * 0.5 - 60.0),
|
|
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(0.6, 1.0, 0.75))
|
|
|
|
|
|
## Who else is online, and whether they are already in a dungeon. Only useful in
|
|
## the hub, which is the one place you are deciding whether to go in.
|
|
func _draw_roster() -> void:
|
|
if client.instance_kind != Protocol.InstanceKind.LOBBY or client.roster.is_empty():
|
|
return
|
|
var x := _canvas.size.x - 240.0
|
|
var y := MARGIN + 4.0
|
|
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y), "ONLINE",
|
|
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.65, 0.7, 0.8))
|
|
y += 20.0
|
|
for entry in client.roster:
|
|
var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON
|
|
var where := "dungeon %d" % int(entry["instance"]) if in_dungeon else "hub"
|
|
var col := Color(1.0, 0.7, 0.45) if in_dungeon else Color(0.7, 0.8, 0.9)
|
|
if not entry["alive"]:
|
|
col = Color(0.85, 0.4, 0.4)
|
|
where += " (down)"
|
|
var me := " <- you" if int(entry["peer"]) == client.my_peer else ""
|
|
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y),
|
|
"%s -- %s%s" % [entry["name"], where, me],
|
|
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col)
|
|
y += 18.0
|
|
|
|
|
|
func _draw_boss_bar() -> void:
|
|
var b := client.boss_state()
|
|
if b.is_empty() or client.boss_def == null:
|
|
return
|
|
var w := 560.0
|
|
var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN)
|
|
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
|
|
var rect := Rect2(pos, Vector2(w, BAR_H))
|
|
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
|
|
var inner := Rect2(rect.position + Vector2(3.0, 3.0),
|
|
Vector2((w - 6.0) * frac, BAR_H - 6.0))
|
|
if inner.size.x > 0.5:
|
|
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false,
|
|
Color(0.95, 0.35, 0.45))
|
|
var phase_index := int(b["phase"])
|
|
var phase_name := ""
|
|
if phase_index < client.boss_def.phases.size():
|
|
phase_name = client.boss_def.phases[phase_index].name
|
|
_canvas.draw_string(ThemeDB.fallback_font, pos + Vector2(0.0, -6.0),
|
|
"%s -- %s" % [client.boss_def.display_name, phase_name],
|
|
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8))
|
|
|
|
|
|
## A bar in the pack's art: the frame stretched horizontally, the fill tinted.
|
|
##
|
|
## [param back] is no longer a colour -- the frame sprite is the background --
|
|
## but the signature keeps it so callers read the same and a future flat-bar
|
|
## fallback has somewhere to go.
|
|
func _bar(pos: Vector2, frac: float, fill: Color, _back: Color) -> void:
|
|
var rect := Rect2(pos, Vector2(BAR_W, BAR_H))
|
|
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
|
|
var inset := 3.0
|
|
var inner := Rect2(rect.position + Vector2(inset, inset),
|
|
Vector2((rect.size.x - inset * 2.0) * clampf(frac, 0.0, 1.0),
|
|
rect.size.y - inset * 2.0))
|
|
if inner.size.x > 0.5:
|
|
# The fill sprite is a flat strip, so tinting it is the whole palette --
|
|
# one texture covers health, experience, the escape channel and the
|
|
# boss.
|
|
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false, fill)
|
|
|
|
|
|
## Draw a horizontally stretchable sprite as three slices: left cap, stretched
|
|
## middle, right cap. Godot's nine-patch lives on nodes rather than on the
|
|
## immediate-mode API, and the HUD is drawn rather than built out of controls.
|
|
func _hslice(tex: Texture2D, rect: Rect2, tint: Color) -> void:
|
|
if tex == null:
|
|
return
|
|
var src := Vector2(tex.get_size())
|
|
var cap := minf(BAR_CAP, src.x * 0.5)
|
|
var draw_cap := cap * (rect.size.y / src.y)
|
|
_canvas.draw_texture_rect_region(tex,
|
|
Rect2(rect.position, Vector2(draw_cap, rect.size.y)),
|
|
Rect2(0.0, 0.0, cap, src.y), tint)
|
|
_canvas.draw_texture_rect_region(tex,
|
|
Rect2(rect.position + Vector2(draw_cap, 0.0),
|
|
Vector2(maxf(rect.size.x - draw_cap * 2.0, 0.0), rect.size.y)),
|
|
Rect2(cap, 0.0, src.x - cap * 2.0, src.y), tint)
|
|
_canvas.draw_texture_rect_region(tex,
|
|
Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0),
|
|
Vector2(draw_cap, rect.size.y)),
|
|
Rect2(src.x - cap, 0.0, cap, src.y), tint)
|
|
|
|
|
|
func flash_hit() -> void:
|
|
_hit_flash = 1.0
|