Files
transcience/src/ui/hud.gd
T
claude a943aa19f6
ci / verify (push) Successful in 47s
Add a second dungeon: the Proving Grounds, a test harness you walk into
Two labelled portals now stand side by side in the hub. The Proving Grounds
runs the same generator, the same rooms, the same enemies and the same
four-phase Warden -- enemies at a fifth health, the boss at 288 instead of
3600, and trash dropping potions 80% of the time instead of 8%. A manual pass
over loot, the inventory, dropping and every boss phase takes a couple of
minutes rather than a quarter of an hour.

It is multipliers over the shared content rather than a parallel copy: a
duplicated Content would drift the first time anything was tuned, and
"identical but easier" would quietly stop being true. And it is a portal
rather than a launch flag, so the two can be compared back to back without
restarting the server -- which is most of the point.

Which dungeon you enter is resolved from the player's server-side position,
and PORTAL_USED carries the answer. There is deliberately no client message
that names a dungeon: one would let any client ask for the generous loot table
and bring the results back to the hub. Instance matching compares dungeon ids
too, so walking into one entrance can never drop you into the other's run on
timing alone.

SimWorld.portals replaces portal_pos/portal_enabled, enter_instance carries
the portal list and the dungeon id (the client needs the latter to scale the
boss bar's ceiling the way the server scaled the boss), and Protocol.VERSION
goes to 7.

Also pins what happens when two players reach for one item on the same tick:
exactly one gets it -- the loop is sequential and the pickup erases the entity
before the next player looks. The tie-break is join order rather than distance,
which is arbitrary rather than designed, so it is recorded as such.

Stale doc fixed while here: MapGen.build() still claimed the client rebuilds
the map from the seed, which has not been true since map streaming landed and
is the opposite of the rule.

check.sh clean, 288 tests, SMOKE PASS (18 assertions, both dungeon kinds
opened over a real socket), all three diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:34:41 +02:00

302 lines
12 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.
const SLOT := 46.0
const SLOT_GAP := 8.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)
_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
_hint.offset_top = -56.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 + 3.0))
if client.my_escaping:
_bar(origin + Vector2(0.0, BAR_H + 8.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 + 8.0 + BAR_H),
"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()
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))
## 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)
var frame_col := Color(0.55, 0.6, 0.72, 0.75) if i == held \
else Color(0.3, 0.33, 0.42, 0.6)
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), Color(0.07, 0.08, 0.12, 0.72))
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), frame_col, false, 1.5)
# 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(4.0, 13.0),
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 11,
Color(0.5, 0.55, 0.68))
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)
_canvas.draw_rect(Rect2(at, Vector2(BAR_W, 5.0)), Color(0.1, 0.12, 0.18))
_canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 5.0)), 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]
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 6.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)
_canvas.draw_rect(Rect2(pos, Vector2(w, BAR_H)), Color(0.12, 0.08, 0.1))
_canvas.draw_rect(Rect2(pos, Vector2(w * frac, BAR_H)), 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))
func _bar(pos: Vector2, frac: float, fill: Color, back: Color) -> void:
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W, BAR_H)), back)
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W * clampf(frac, 0.0, 1.0), BAR_H)), fill)
func flash_hit() -> void:
_hit_flash = 1.0