A UI pass: prompts that tell the truth, and a HUD that does not collide
ci / verify (push) Successful in 53s

Prompts named the default keys whatever was bound. Every action is
rebindable, but the HUD said "WASD move", "LMB fire", "hold F" and "1-4
use", the quartermaster said "E talk" and the upgrade screen "Close (E)".
All of them now go through Settings.short_label(), and a test rebinds
fire, escape, interact and a slot key and checks each prompt follows.

The HUD's top-left was a developer's status line -- name, instance id,
health as text, frame rate -- with the boss bar drawn over its right end
and the level-up notice sharing a row with the escape bar. It is now
name and level, where you are, health with its numbers, experience,
escape and level-ups, stacked by a running cursor so nothing has a fixed
row to collide on. Instance and fps moved behind F1, which is who they
were for. Arrival protection is a centred banner rather than a note
beside the health bar, because it is the one moment the rules change.

A hit used to tint the whole screen red, which in a bullet hell dims the
bullets at exactly the moment you are among them. It is an edge now, and
the same edge pulses below a third of your health. New: a message feed
above the inventory for what the server just confirmed -- items taken,
used and dropped, levels gained -- and party health top-right in a
dungeon, where the hub shows who is online. The pickup prompt now also
names the dungeon under your feet when you stand on its ring, in the
same order the server resolves that key: loot first, then the entrance.

The boss bar used to announce the boss to anyone within the actor
interest radius -- which passes through walls -- so a player in the
entrance room was told the Warden was near. It waits until the fog has
actually lit the boss once, then stays for the fight.

Permadeath was announced only by the roster screen appearing. The client
records its own death from the server's event and the roster says who
fell, at what level, and where. The connect screen lost a name field
that did nothing for a person -- it only named the characters bots
create -- gained a proper panel, remembers the last server, and shows
the protocol number a mismatch refusal will quote.

And screenshot.tscn ran as the player's real account against their real
character store, walking their character into dungeons for pictures --
with health now carried between runs, a stray hit would have stuck. It
has its own store and account now, like every diagnostic, and a seeded
character. The real store was checked: nothing it carries changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-05 10:41:04 +02:00
1 parent 069abdb3fa
commit 19d2295bec
14 files changed
+903 -229

No files matched your search

+457 -179
View File
@@ -1,6 +1,13 @@
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.
##
## Layout, because several things here used to land on top of each other:
## top-left is you (name, where, health, experience, escape, level-ups), stacked
## by a running cursor so nothing has a fixed row to collide on; top-centre is
## the boss; top-right is everyone else; the bottom centre is the inventory
## with the pickup prompt and the message feed stacked above it; the controls
## hint has the bottom-left corner to itself.
const MARGIN := 24.0
const BAR_W := 260.0
@@ -19,16 +26,51 @@ const SLOT_GAP := 8.0
const BAR_CAP := 6.0
## How far above the bottom of the screen the inventory row sits.
const SLOT_BOTTOM := 26.0
const BOSS_BAR_W := 480.0
## The message feed: how long a message stays, and how many at once.
const TOAST_LIFE := 2.6
const TOAST_FADE := 0.8
const TOAST_MAX := 4
## Health below this share of maximum pulses the screen edge.
const LOW_HEALTH := 0.35
## How long the controls hint stays up after arriving in a dungeon. In the hub
## it stays: that is where you are learning the keys, and nothing is shooting.
const HINT_HOLD := 6.0
const HINT_FADE := 1.5
const COL_TEXT := Color(0.86, 0.9, 0.97)
const COL_DIM := Color(0.58, 0.63, 0.74)
const COL_HEALTH := Color(0.35, 0.9, 0.6)
const COL_GOLD := Color(1.0, 0.85, 0.4)
const COL_DANGER := Color(1.0, 0.25, 0.3)
signal respawn_pressed
## What the world view could see this frame, handed over by the game scene so
## the boss bar can wait until the boss has actually been seen. See
## _draw_boss_bar.
var sight: Visibility = null
## Mirrors the F1 overlay. The instance number and frame rate are for whoever
## is debugging, not for whoever is playing, so they live behind it.
var debug_info: bool = false
var _canvas: Control
var _status: Label
var _hint: Label
var _respawn_button: Button
var _hit_flash: float = 0.0
var client: ClientRuntime = null
var _toasts: Array[Dictionary] = []
## Which boss, in which instance, has been seen -- see _draw_boss_bar.
var _boss_seen := ""
## The level last drawn and whose it was, so a level-up can be announced the
## moment it happens and a character switch is not mistaken for one.
var _seen_level: int = -1
var _seen_character := ""
## When the current instance was entered, for fading the controls hint.
var _instance_seen: int = -1
var _instance_since: float = 0.0
func _ready() -> void:
layer = 10
@@ -45,20 +87,6 @@ func _ready() -> void:
_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.
@@ -74,24 +102,58 @@ func _ready() -> void:
_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()
var now := _now()
_toasts = _toasts.filter(func(t: Dictionary) -> bool:
return now - float(t["born"]) < TOAST_LIFE)
if client != null:
if client.instance_id != _instance_seen:
_instance_seen = client.instance_id
_instance_since = now
_watch_level()
_update_respawn_button()
_canvas.queue_redraw()
func _now() -> float:
return float(Time.get_ticks_msec()) / 1000.0
## A short line in the message feed above the inventory. Called for things the
## server just confirmed -- an item taken, used or dropped, a level gained -- so
## the feed is a record of what happened, never of what the client hoped.
func notify(text: String, colour: Color = COL_TEXT) -> void:
_toasts.append({"text": text, "colour": colour, "born": _now()})
while _toasts.size() > TOAST_MAX:
_toasts.pop_front()
func flash_hit() -> void:
_hit_flash = 1.0
## Announce a level the moment the snapshot's experience crosses one. Watched
## rather than signalled because experience rides the snapshot, and the first
## sight of a character -- logging in, swapping -- must not read as a level-up.
func _watch_level() -> void:
var who := client.current_character()
if who.is_empty():
_seen_character = ""
return
var level := Progression.level_for_xp(client.my_total_xp)
if String(who["id"]) != _seen_character:
_seen_character = String(who["id"])
_seen_level = level
return
if level > _seen_level:
var where := "" if client.instance_kind == Protocol.InstanceKind.LOBBY \
else " -- a choice waits at the quartermaster"
notify("LEVEL %d%s" % [level, where], COL_GOLD)
_seen_level = level
func _update_respawn_button() -> void:
var downed := client != null and not client.my_alive
_respawn_button.visible = downed
@@ -105,91 +167,311 @@ func _update_respawn_button() -> void:
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()]
# --- Text -------------------------------------------------------------------
## Text with a one-pixel shadow. The floor art is busy and mid-toned, and plain
## light text over it was readable on the dark parts of a tile and not on the
## light ones.
func _text(at: Vector2, s: String, size: int, colour: Color,
align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT, width: float = -1.0) -> void:
var font := ThemeDB.fallback_font
_canvas.draw_string(font, at + Vector2(1.0, 1.0), s, align, width, size,
Color(0.0, 0.0, 0.0, 0.7 * colour.a))
_canvas.draw_string(font, at, s, align, width, size, colour)
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 _text_width(s: String, size: int) -> float:
return ThemeDB.fallback_font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x
func _centred(y: float, s: String, size: int, colour: Color) -> void:
_text(Vector2(0.0, y), s, size, colour, HORIZONTAL_ALIGNMENT_CENTER, _canvas.size.x)
# --- Key names --------------------------------------------------------------
## What the player has [param action] bound to. Read from Settings every time
## rather than written into the strings: every one of these is rebindable, and
## the hint used to say "LMB fire" whatever fire was actually on.
static func key(action: String) -> String:
return Settings.short_label(action)
## The four movement keys as one word when they are single characters -- WASD,
## ZQSD, ESDF -- and spelled out when they are not, like the arrow keys.
static func move_keys() -> String:
var parts: Array[String] = []
for action in ["move_up", "move_left", "move_down", "move_right"]:
parts.append(key(action))
for p in parts:
if p.length() != 1:
return "/".join(parts)
return "".join(parts)
## The slot keys, as a range when they run 1-2-3-4 and listed when they do not.
static func slot_keys(count: int) -> String:
var parts: Array[String] = []
for i in count:
parts.append(key("use_slot_%d" % (i + 1)))
var plain := true
for i in count:
if parts[i] != str(i + 1):
plain = false
return "1-%d" % count if plain else " ".join(parts)
static func controls_hint(in_hub: bool) -> String:
if in_hub:
return "%s move mouse aim %s fire %s on a ring to enter it %s menu" % [
move_keys(), key("fire"), key("interact"), key("system_menu")]
return "%s move %s fire hold %s to return to the hub %s menu" % [
move_keys(), key("fire"), key("emergency_escape"), key("system_menu")]
static func inventory_hint(count: int) -> String:
return "%s use shift+%s drop %s pick up" % [
slot_keys(count), slot_keys(count), key("interact")]
# --- Drawing ----------------------------------------------------------------
func _draw_hud() -> void:
if client == null:
_text(Vector2(MARGIN, MARGIN + 14.0), "connecting...", 14, COL_DIM)
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))
# Under everything, so the edge of the screen colours without dimming any of
# the text on top of it.
_draw_vignette()
var y := _draw_player_panel()
_draw_level_up_notice(y)
_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_arriving()
_draw_cleared_countdown()
_draw_roster()
_draw_others()
_draw_inventory()
_draw_pickup_prompt()
_draw_level_up_notice()
var above := _draw_pickup_prompt()
_draw_toasts(above)
_draw_controls_hint()
_draw_debug_info()
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))
_centred(_canvas.size.y * 0.5, "DOWN", 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))
## Who you are, where you are, and how you are doing. Returns the y below the
## last thing drawn, so whatever comes next stacks rather than overlaps.
func _draw_player_panel() -> float:
var x := MARGIN
var y := MARGIN
var who := client.current_character()
if not who.is_empty():
var name_text := String(who["name"])
_text(Vector2(x, y + 15.0), name_text, 17, COL_TEXT)
_text(Vector2(x + _text_width(name_text, 17) + 10.0, y + 15.0),
"level %d" % Progression.level_for_xp(client.my_total_xp), 13, COL_DIM)
y += 22.0
# Named rather than just "DUNGEON": there is more than one kind, and knowing
# which one you are standing in is the whole point of having more than one.
var where := "THE HUB"
var tint := COL_DIM
if client.instance_kind != Protocol.InstanceKind.LOBBY:
var def := Dungeons.get_or_default(client.dungeon_id)
where = def.display_name.to_upper()
tint = def.tint
_text(Vector2(x, y + 12.0), where, 12, tint)
y += 20.0
var frac := float(client.my_hp) / float(maxi(client.my_max_hp, 1))
# Red when low, not a blend toward it: green-to-red through the middle is
# brown, which reads as a colour rather than as a warning.
var hp_tint := COL_HEALTH if frac >= LOW_HEALTH else Color(1.0, 0.38, 0.36)
_bar(Vector2(x, y), frac, hp_tint)
_text(Vector2(x + BAR_W + 10.0, y + 13.0),
"%d / %d" % [client.my_hp, client.my_max_hp], 12, hp_tint)
y += BAR_H + 4.0
if _draw_xp_bar(Vector2(x, y)):
y += BAR_H + 4.0
if client.my_escaping:
_bar(Vector2(x, y), client.my_escape, Color(0.5, 0.85, 1.0))
_text(Vector2(x + BAR_W + 10.0, y + 13.0), "ESCAPING", 12,
Color(0.5, 0.85, 1.0))
y += BAR_H + 4.0
return y
## A thin bar under health: progress toward the next level, and the level
## itself. Drawn from the server's numbers, never recomputed locally. Returns
## whether it drew, since a player at the roster screen has no character.
func _draw_xp_bar(at: Vector2) -> bool:
if client.current_character().is_empty():
return false
# 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 := COL_GOLD if capped else Color(0.6, 0.55, 1.0)
_bar(at, progress, tint)
# 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]
_text(at + Vector2(BAR_W + 10.0, 13.0), text, 12, tint)
return true
## 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:
func _draw_level_up_notice(y: float) -> 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" \
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),
_text(Vector2(MARGIN, y + 16.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))
13, Color(COL_GOLD, 0.6 + 0.4 * pulse))
## The boss's health, once you have seen the boss.
##
## The server sends the boss to anyone within the actor interest radius, which
## is deliberately wider than sight and passes through walls -- so the bar used
## to announce "Warden of the Fold" to a player still standing in the entrance
## room, which says exactly where the boss is to someone who has not found it.
## It now waits until the fog has actually lit the boss once, and then stays for
## the rest of the fight: losing the bar every time the boss stepped behind a
## pillar would be worse than the leak.
func _draw_boss_bar() -> void:
var b := client.boss_state()
if b.is_empty() or client.boss_def == null:
return
var seen_key := "%d:%d" % [client.instance_id, int(b["id"])]
if _boss_seen != seen_key:
if sight != null and sight.light_at(b["pos"]) <= 0.05:
return
_boss_seen = seen_key
var pos := Vector2((_canvas.size.x - BOSS_BAR_W) * 0.5, MARGIN + 20.0)
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
var rect := Rect2(pos, Vector2(BOSS_BAR_W, BAR_H))
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inner := Rect2(rect.position + Vector2(3.0, 3.0),
Vector2((BOSS_BAR_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
_text(Vector2(pos.x, pos.y - 6.0), client.boss_def.display_name, 15,
Color(1.0, 0.75, 0.8))
_text(Vector2(pos.x, pos.y - 6.0), phase_name, 12, Color(1.0, 0.6, 0.65, 0.85),
HORIZONTAL_ALIGNMENT_RIGHT, BOSS_BAR_W)
## Arrival protection, as a banner rather than a line tucked beside the health
## bar: it is the one moment the rules are different, and both halves of it --
## you cannot be hurt, and you cannot shoot -- change what you should do.
func _draw_arriving() -> void:
if not client.my_spawn_grace:
return
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.008)
var y := _canvas.size.y * 0.28
_centred(y, "ARRIVING", 24, Color(0.6, 0.9, 1.0, 0.75 + 0.25 * pulse))
_centred(y + 22.0, "invulnerable · weapons cold", 13, Color(0.6, 0.9, 1.0, 0.8))
## 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 y := _canvas.size.y * 0.5 - 60.0
_centred(y, "DUNGEON CLEARED", 24, Color(0.6, 1.0, 0.75))
_centred(y + 22.0, "returning to the hub in %ds" % client.cleared_countdown, 14,
Color(0.6, 1.0, 0.75, 0.85))
## Top right: in the hub, who is online and where; in a dungeon, the party you
## are in and how each of them is doing. Different questions -- in the hub you
## are deciding whether to go in, in a dungeon you are deciding who needs help.
func _draw_others() -> void:
if client.roster.is_empty():
return
if client.instance_kind == Protocol.InstanceKind.LOBBY:
_draw_online()
else:
_draw_party()
func _draw_online() -> void:
var x := _canvas.size.x - 240.0
var y := MARGIN + 12.0
_text(Vector2(x, y), "ONLINE", 13, COL_DIM)
y += 20.0
for entry in client.roster:
var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON
var where := "in a dungeon" if in_dungeon else "in the 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 := int(entry["peer"]) == client.my_peer
_text(Vector2(x, y), "%s%s" % [entry["name"], " (you)" if me else ""], 13, col)
_text(Vector2(x, y), where, 11, Color(col, 0.7), HORIZONTAL_ALIGNMENT_RIGHT, 216.0)
y += 18.0
## Everyone else in this dungeon, with their health. The snapshot only carries
## players inside the interest radius, so a party member further away than that
## is listed without a bar -- the client genuinely does not know.
func _draw_party() -> void:
var near := {}
for p in client.remote_players():
near[int(p["peer"])] = p
var members: Array[Dictionary] = []
for entry in client.roster:
if int(entry["instance"]) == client.instance_id and int(entry["peer"]) != client.my_peer:
members.append(entry)
if members.is_empty():
return
var x := _canvas.size.x - 240.0
var y := MARGIN + 12.0
_text(Vector2(x, y), "PARTY", 13, COL_DIM)
y += 10.0
var w := 140.0
for entry in members:
var peer := int(entry["peer"])
_text(Vector2(x, y + 13.0), String(entry["name"]), 13, COL_TEXT)
var rec: Dictionary = near.get(peer, {})
var bar_at := Vector2(x + 76.0, y + 3.0)
if rec.is_empty():
_text(Vector2(bar_at.x, y + 13.0), "out of range", 11, COL_DIM)
else:
var flags := int(rec["flags"])
var frac := float(rec["hp"]) / float(maxi(int(rec["max_hp"]), 1))
var col: Color = rec["colour"]
if (flags & Protocol.F_ALIVE) == 0:
_text(Vector2(bar_at.x, y + 13.0), "down", 12, COL_DANGER)
else:
var rect := Rect2(bar_at, Vector2(w, 12.0))
_canvas.draw_rect(rect, Color(0.0, 0.0, 0.0, 0.55))
_canvas.draw_rect(Rect2(rect.position + Vector2(2.0, 2.0),
Vector2((w - 4.0) * clampf(frac, 0.0, 1.0), 8.0)),
col if frac >= LOW_HEALTH else COL_DANGER)
if (flags & Protocol.F_LINKDEAD) != 0:
_text(Vector2(bar_at.x + w + 6.0, y + 13.0), "dropped", 11, COL_DIM)
elif (flags & Protocol.F_ESCAPING) != 0:
_text(Vector2(bar_at.x + w + 6.0, y + 13.0), "leaving", 11,
Color(0.5, 0.85, 1.0))
y += 20.0
## Four slots, always on screen. Deliberately not a panel you open: an
@@ -213,126 +495,126 @@ func _draw_inventory() -> void:
# 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.
# The key that uses the slot, because that is the only thing the player
# actually needs to know about it -- and it is whatever they bound.
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM)
key("use_slot_%d" % (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))
_text(Vector2(origin.x, origin.y + SLOT + 15.0), inventory_hint(count), 11,
Color(COL_DIM, 0.85), HORIZONTAL_ALIGNMENT_CENTER, total)
## 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:
## decision. Returns the y the next thing up should stack from.
##
## Loot first, then an entrance, which is the order the server resolves the
## same key in: an item in reach is taken, and only a press that took nothing
## goes through the portal underneath it.
func _draw_pickup_prompt() -> float:
var above := _canvas.size.y - SLOT_BOTTOM - SLOT - 14.0
if not client.my_alive:
return above
var near := client.loot_in_reach()
if near.is_empty() or not client.my_alive:
return
if near.is_empty():
var portal := client.portal_here()
if portal.is_empty() or client.instance_kind != Protocol.InstanceKind.LOBBY:
return above
var def := Dungeons.get_or_default(portal["dungeon"])
_centred(above, "%s enter %s" % [key("interact"), def.display_name], 15, def.tint)
return above - 22.0
var item := Items.by_index(int(near["item"]))
var def := Items.get_def(item)
if def == null:
return
return above
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
if full else "%s take %s" % [key("interact"), 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)
_centred(above, text, 14, tint)
return above - 22.0
## 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():
## The message feed, newest nearest the inventory, each line fading out.
func _draw_toasts(bottom: float) -> void:
var now := _now()
var y := bottom
for i in range(_toasts.size() - 1, -1, -1):
var t: Dictionary = _toasts[i]
var age := now - float(t["born"])
var alpha := clampf((TOAST_LIFE - age) / TOAST_FADE, 0.0, 1.0)
var col: Color = t["colour"]
# A short rise on arrival, so a new line catches the eye without a sound.
var rise := maxf(0.0, 0.25 - age) * 24.0
_centred(y + rise, String(t["text"]), 14, Color(col, col.a * alpha))
y -= 20.0
func _draw_controls_hint() -> void:
if not client.my_alive:
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:
var in_hub := client.instance_kind == Protocol.InstanceKind.LOBBY
var alpha := 1.0
if not in_hub:
var age := _now() - _instance_since
alpha = clampf(1.0 - (age - HINT_HOLD) / HINT_FADE, 0.0, 1.0)
if alpha <= 0.0:
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))
_text(Vector2(MARGIN, _canvas.size.y - 14.0), controls_hint(in_hub), 12,
Color(COL_DIM, 0.9 * alpha))
## 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():
func _draw_debug_info() -> void:
if not debug_info:
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
_text(Vector2(0.0, _canvas.size.y - 14.0),
"instance %d %d fps" % [client.instance_id, Engine.get_frames_per_second()],
12, COL_DIM, HORIZONTAL_ALIGNMENT_RIGHT, _canvas.size.x - MARGIN)
func _draw_boss_bar() -> void:
var b := client.boss_state()
if b.is_empty() or client.boss_def == null:
## Red at the edges of the screen: briefly on every hit, and pulsing while
## health is low.
##
## At the edges, not across the middle. This used to tint the whole screen red
## on a hit, which in a bullet hell dims the bullets at exactly the moment you
## are among them -- the feedback made the next hit more likely.
func _draw_vignette() -> void:
var strength := _hit_flash * 0.55
if client.my_alive:
var frac := float(client.my_hp) / float(maxi(client.my_max_hp, 1))
if frac < LOW_HEALTH:
var low := (LOW_HEALTH - frac) / LOW_HEALTH
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.006)
strength = maxf(strength, (0.2 + 0.35 * low) * (0.65 + 0.35 * pulse))
if strength <= 0.01:
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))
var s := _canvas.size
var depth := minf(s.x, s.y) * 0.16
var edge := Color(COL_DANGER, strength)
var clear := Color(COL_DANGER, 0.0)
var sides := [
[Vector2(0, 0), Vector2(s.x, 0), Vector2(s.x, depth), Vector2(0, depth)],
[Vector2(0, s.y), Vector2(0, s.y - depth), Vector2(s.x, s.y - depth), Vector2(s.x, s.y)],
[Vector2(0, 0), Vector2(depth, 0), Vector2(depth, s.y), Vector2(0, s.y)],
[Vector2(s.x, 0), Vector2(s.x, s.y), Vector2(s.x - depth, s.y), Vector2(s.x - depth, 0)],
]
var cols := [
[edge, edge, clear, clear],
[edge, clear, clear, edge],
[edge, clear, clear, edge],
[edge, edge, clear, clear],
]
for i in 4:
_canvas.draw_polygon(PackedVector2Array(sides[i]), PackedColorArray(cols[i]))
## 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:
func _bar(pos: Vector2, frac: float, fill: Color) -> void:
var rect := Rect2(pos, Vector2(BAR_W, BAR_H))
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inset := 3.0
@@ -366,7 +648,3 @@ func _hslice(tex: Texture2D, rect: Rect2, tint: Color) -> void:
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