feat(ui): the first-person HUD is one thing, in the game's own hand
The reticle was five white ColorRects, pasted byte-identically into three
level runtime scripts. The vitals were two stock ProgressBars with a flat
colour override, inlined 1200 lines into the movement controller. The ammo
count — the number a shooter's player looks at most — was drawn by the LEVEL,
in a black rounded panel that shared nothing with the menus, and it had a
special case in it (`elif active_weapon is DoubleBarrelShotgun`) because that
weapon never declared a name or a capacity.
Everything that describes A PLAYER now belongs to ui/player_hud.gd, and a
level owns the level. The immediate symptom that fixed: the screen had two
ammo panels on it at once, in two different styles, overlapping in the corner.
What each piece now says, rather than merely shows:
ui/crosshair.gd one drawn reticle instead of five rectangles, so it can
BLOOM — open with speed, airtime and each shot, snap shut
on ADS. That is the accuracy readout of the whole game and
five ColorRects could not express it. Every stroke is
drawn twice, ink underneath, because a 2 px white line
disappears over pale concrete exactly when aim matters.
The hit confirmation is the same cross at 45 degrees, so
it lands where the eye already is.
ui/vital_bar.gd segmented, so remaining health can be COUNTED rather than
estimated, with a drain ghost that holds the old value for
a beat — the gap between fill and ghost is the size of the
hit, which a bar that merely gets shorter never tells you.
ui/ability_chip.gd dash and grapple as a wipe across a chip rather than a
tinted JPEG with a 12 px number under it. A shape changing
size is readable in peripheral vision; 12 px type is not.
chain meter promoted out of the debug panel. Movement is this game's
first stated pillar and chaining is its skill expression,
so the count is a score, not a diagnostic.
The numerals moved OFF the bars and beside them. Text centred on a two-tone
bar cannot be given a colour that beats both the fill and the trough — that is
the 2.4:1 debug/ui_contrast_check.gd measured on the old HUD — so this fixes it
at the source rather than leaning on an outline to rescue it.
debug/hud_layout_check.gd measures where every element actually lands, which is
how three real bugs were found rather than squinted at: `set_anchors_preset`
moves the anchors and LEAVES THE OFFSETS, so the reticle spanned the viewport
with a size of exactly (0,0) and drew itself in the top-left corner; a
PRESET_CENTER applied after the ring's own `_ready` undid its centring; and a
BOX CONTAINER's own `alignment` is what pushes content to an edge, not a
SHRINK_END flag on the box, whose minimum width depends on children that may be
hidden. The ammo card was hanging off the right edge of the screen because of
the last one.
DoubleBarrelShotgun now declares `weapon_name` and `max_shells` like every
other weapon, and the four inline `2`s are gone.
spawn smoke 0 failures, 11/11 movement tests, contrast 108/108, layout PASS.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
414026f001
commit
c4bcbc7fd1
+186
@@ -0,0 +1,186 @@
|
||||
extends Control
|
||||
class_name VitalBar
|
||||
|
||||
## A health or shield bar in the game's own hand: sheared, ink-edged, segmented,
|
||||
## with a drain ghost behind the fill.
|
||||
##
|
||||
## It replaces a stock ProgressBar with a flat colour override, and each of the
|
||||
## three things it adds answers a question the ProgressBar could not.
|
||||
##
|
||||
## SEGMENTS — how much is left, without reading a number. A continuous bar has to
|
||||
## be measured against its own ends; a bar cut into blocks of 25 can be COUNTED,
|
||||
## and counting is faster than estimating and survives being glimpsed in
|
||||
## peripheral vision during a firefight. This is the same reason HoYoverse's
|
||||
## action UIs chunk their meters rather than drawing one smooth sweep.
|
||||
##
|
||||
## THE DRAIN GHOST — how much was just lost. The fill snaps to the new value
|
||||
## immediately, because the player must never be told they have more health than
|
||||
## they do; a paler ghost holds the old value for a beat and then catches up. The
|
||||
## gap between them is the size of the hit, which is information that does not
|
||||
## exist anywhere on a bar that simply gets shorter.
|
||||
##
|
||||
## THE SHEAR — because everything else in this UI leans. A square meter under
|
||||
## leaning chips reads as a widget from a different game.
|
||||
##
|
||||
## The numeral deliberately lives OUTSIDE this control, beside the bar rather
|
||||
## than centred on it. Text over a two-tone bar cannot be given a colour that
|
||||
## beats both the fill and the trough, which is what debug/ui_contrast_check.gd
|
||||
## measured at 2.4:1 on the old HUD. Moving it off the fill fixes that at the
|
||||
## source instead of relying on an outline to rescue it.
|
||||
|
||||
var value: float = 100.0:
|
||||
set(v):
|
||||
var c := clampf(v, 0.0, max_value)
|
||||
if c < value - 0.01:
|
||||
# Lost some: the ghost stays where it was and the flash fires.
|
||||
_flash = 1.0
|
||||
_lag_hold = LAG_HOLD
|
||||
elif c > value + 0.01:
|
||||
# Gained some: the ghost has nothing to show, so bring it along.
|
||||
_lag = c
|
||||
value = c
|
||||
queue_redraw()
|
||||
|
||||
var max_value: float = 100.0:
|
||||
set(v):
|
||||
max_value = maxf(v, 1.0)
|
||||
_lag = minf(_lag, max_value)
|
||||
queue_redraw()
|
||||
|
||||
## The charged colour of the fill. Health is papaya, shield is cyan.
|
||||
var fill_color: Color = UITheme.PAPAYA:
|
||||
set(v):
|
||||
fill_color = v
|
||||
queue_redraw()
|
||||
|
||||
## One block per this many points. 25 gives a 100-point bar four blocks, which is
|
||||
## the most the eye can count without moving.
|
||||
var per_segment: float = 25.0
|
||||
|
||||
## Below this fraction the bar breathes, so low health is felt rather than read.
|
||||
const LOW_AT := 0.3
|
||||
const LOW_RATE := 5.5
|
||||
|
||||
## How long the ghost holds the old value before draining, and how fast it goes.
|
||||
const LAG_HOLD := 0.35
|
||||
const LAG_RATE := 55.0
|
||||
|
||||
const SHEAR := 6.0
|
||||
const INK_W := 3.0
|
||||
const GAP := 3.0
|
||||
|
||||
var _lag: float = 100.0
|
||||
var _lag_hold: float = 0.0
|
||||
var _flash: float = 0.0
|
||||
var _pulse: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
custom_minimum_size = Vector2(230, 22)
|
||||
_lag = value
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var dirty := false
|
||||
|
||||
if _lag > value:
|
||||
if _lag_hold > 0.0:
|
||||
_lag_hold -= delta
|
||||
else:
|
||||
_lag = maxf(_lag - LAG_RATE * delta, value)
|
||||
dirty = true
|
||||
elif _lag < value:
|
||||
_lag = value
|
||||
dirty = true
|
||||
|
||||
if _flash > 0.0:
|
||||
_flash = maxf(_flash - delta * 4.5, 0.0)
|
||||
dirty = true
|
||||
|
||||
if value / max_value <= LOW_AT and value > 0.0:
|
||||
_pulse += delta * LOW_RATE
|
||||
dirty = true
|
||||
elif _pulse != 0.0:
|
||||
_pulse = 0.0
|
||||
dirty = true
|
||||
|
||||
if dirty:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var w := size.x
|
||||
var h := size.y
|
||||
if w <= 1.0 or h <= 1.0:
|
||||
return
|
||||
|
||||
# Trough: ink fill with a soft inner edge, so an empty bar is still a shape
|
||||
# on the screen rather than a hole in it.
|
||||
_shear_rect(0.0, w, UITheme.INK, h)
|
||||
_shear_outline(0.0, w, UITheme.INK_SOFT, h)
|
||||
|
||||
var frac: float = clampf(value / max_value, 0.0, 1.0)
|
||||
var lag_frac: float = clampf(_lag / max_value, 0.0, 1.0)
|
||||
|
||||
# Ghost first, so the live fill draws over its left end and only the
|
||||
# difference between the two is visible.
|
||||
if lag_frac > frac:
|
||||
var ghost := Color(fill_color.r, fill_color.g, fill_color.b, 0.38)
|
||||
_shear_rect(0.0, w * lag_frac, ghost, h)
|
||||
|
||||
if frac > 0.0:
|
||||
var col := fill_color
|
||||
# Low health breathes toward volt. It never goes fully volt: that colour
|
||||
# means "input landed" everywhere else in the UI and spending it on a
|
||||
# steady state would blunt it.
|
||||
if frac <= LOW_AT:
|
||||
var b: float = 0.5 + 0.5 * sin(_pulse)
|
||||
col = col.lerp(UITheme.VOLT, 0.35 * b)
|
||||
# The instant of damage whites the bar out briefly — the cheapest way to
|
||||
# make a hit register before the number has been read.
|
||||
if _flash > 0.0:
|
||||
col = col.lerp(UITheme.PAPER, _flash * 0.7)
|
||||
_segments(w, h, frac, col)
|
||||
|
||||
# Ink edge last so it sits on top of both fills and reads as a drawn border.
|
||||
_shear_outline(0.0, w, UITheme.INK, h)
|
||||
|
||||
|
||||
## The fill, cut into countable blocks.
|
||||
##
|
||||
## The last block is clipped rather than dropped, so the bar still moves
|
||||
## continuously as damage lands inside a block — the segments are for reading
|
||||
## the amount at a glance, not for quantising it.
|
||||
func _segments(w: float, h: float, frac: float, col: Color) -> void:
|
||||
var count := maxi(int(round(max_value / per_segment)), 1)
|
||||
var seg_w := (w - GAP * (count - 1)) / count
|
||||
var filled := w * frac
|
||||
for i in count:
|
||||
var x0 := i * (seg_w + GAP)
|
||||
if x0 >= filled:
|
||||
break
|
||||
var x1: float = minf(x0 + seg_w, filled)
|
||||
if x1 - x0 < 0.5:
|
||||
continue
|
||||
_shear_rect(x0, x1, col, h)
|
||||
|
||||
|
||||
## A parallelogram from x0 to x1 — the bar's lean, matching the theme's chips.
|
||||
func _shear_rect(x0: float, x1: float, col: Color, h: float) -> void:
|
||||
draw_colored_polygon(PackedVector2Array([
|
||||
Vector2(x0 + SHEAR, 0.0),
|
||||
Vector2(x1 + SHEAR, 0.0),
|
||||
Vector2(x1, h),
|
||||
Vector2(x0, h),
|
||||
]), col)
|
||||
|
||||
|
||||
func _shear_outline(x0: float, x1: float, col: Color, h: float) -> void:
|
||||
draw_polyline(PackedVector2Array([
|
||||
Vector2(x0 + SHEAR, 0.0),
|
||||
Vector2(x1 + SHEAR, 0.0),
|
||||
Vector2(x1, h),
|
||||
Vector2(x0, h),
|
||||
Vector2(x0 + SHEAR, 0.0),
|
||||
]), col, INK_W)
|
||||
Reference in New Issue
Block a user