Files
transcience/src/ui/hud.gd
T
claude b351bc2d55
ci / verify (push) Successful in 48s
Stage 4: upgrades, and a quartermaster to spend them at
Every level banks one choice. Choices queue, and are spent at an NPC in the
hub: walk to it, press E, take one of three weighted options. Seven upgrades,
all data — split shot, glass cannon, spread, sniper, doubleshot, poison,
eraser — and SimWorld gained no per-upgrade branch to run any of them.

The four ambiguities in the brief were settled with the user first, since
each changes what gets written:

  damage      base x (1 + sum additive) x product multiplicative. The flat
              +5% every upgrade carries, spread's -10%, doubleshot's -50%
              and glass cannon's +100% pool; sniper multiplies on top, so
              two snipers is 4x and not +200%.
  glass       half the LEVELLED maximum, multiplying if taken twice, so the
              price does not fade to a rounding error by level 15.
  poison      independent stacks, not a refresh.
  split       +/-45 degrees from the original heading.

Independent poison stacks sound expensive and are not: every dose lasts the
same number of ticks, so doses expire in the order they were added, the
pending expiries are a plain FIFO, and PoisonTrack only ever looks at its
front. O(1) per actor per tick however many are live.

Stats are derived from the upgrade list and never stored, the way level is
derived from experience -- a saved stat cannot disagree with the upgrades
that produced it. Upgrade riders (split charges, poison, erase chance) travel
on the bullet instead, because a shot in flight has to keep what it was fired
with rather than gaining Poison because the shooter just took it.

Two invariants this collided with, both now pinned:

  - bullet speed gained a ceiling. Wall collision samples once per tick, so
    anything over a tile per tick tunnels; two snipers asked for 2480 u/s
    against a 1920 threshold, and a tunnelling bullet looks like a bullet.
  - BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is
    now the longest-travelling bullet in the game. test_interest measured
    the worst case from static content, which upgrades quietly invalidated.

Choosing is intent checked three ways: a choice must be owed, the index must
name one of the three options the SERVER put on the table, and the player
must be standing at the NPC. The offer is rolled once and persisted, so
closing the screen is not a reroll and neither is a crash.

tools/diag_upgrades.tscn covers level -> banked choice -> refused in a
dungeon and refused across the room -> taken at the NPC -> new stats ->
on disk. Bots never walk to the quartermaster, so the smoke test cannot.

Known gap recorded in the roadmap: at PLAYER_BULLET_DAMAGE = 6, the +5% the
first upgrade carries rounds back to 6 and visibly does nothing. It comes out
right in aggregate, but the fix is a balance edit across content.gd and so is
the user's call.

check.sh clean, 357 tests, SMOKE PASS (18 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:34:58 +02:00

320 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()
_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)
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