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)