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
@@ -0,0 +1,128 @@
|
||||
extends Control
|
||||
class_name AbilityChip
|
||||
|
||||
## A dash or grapple readout: a leaning chip that drains while the ability is on
|
||||
## cooldown and snaps back to charged when it is ready again.
|
||||
##
|
||||
## It replaces a 32 px JPEG icon with a text label under it, tinted red or green
|
||||
## by a colour multiply — an approach with three problems. The icon was a photo
|
||||
## in a game drawn entirely in flat ink; the tint said "not ready" but not HOW not
|
||||
## ready; and the number that did say it was rendered at 12 px, which is below
|
||||
## what anyone reads mid-fight.
|
||||
##
|
||||
## What a player actually needs from a cooldown is one bit at a glance (can I go?)
|
||||
## and one magnitude in peripheral vision (how soon?). So the chip answers the bit
|
||||
## with COLOUR — volt when charged, ink when not, the same volt-means-now rule the
|
||||
## rest of the UI runs on — and the magnitude with a WIPE across the chip, which
|
||||
## can be read without focusing on it because it is a shape changing size rather
|
||||
## than a number changing value.
|
||||
##
|
||||
## The ready transition overshoots slightly before settling. A cooldown that ends
|
||||
## by silently going bright is easy to miss while looking somewhere else; one that
|
||||
## pops is not, and it costs a tween.
|
||||
|
||||
## Ability name, drawn on the chip.
|
||||
var label: String = "DASH":
|
||||
set(v):
|
||||
label = v
|
||||
queue_redraw()
|
||||
|
||||
## 0 = fully charged, 1 = just used. Written each frame.
|
||||
var cooldown: float = 0.0:
|
||||
set(v):
|
||||
var c := clampf(v, 0.0, 1.0)
|
||||
var was_ready := cooldown <= 0.001
|
||||
cooldown = c
|
||||
if was_ready and c > 0.001:
|
||||
_pop = 0.0
|
||||
elif not was_ready and c <= 0.001:
|
||||
# Just came back. Fire the overshoot.
|
||||
_pop = 1.0
|
||||
queue_redraw()
|
||||
|
||||
## Set while the ability is actively in use (mid-grapple), which is a third state
|
||||
## and reads as neither charged nor recharging.
|
||||
var active: bool = false:
|
||||
set(v):
|
||||
if v != active:
|
||||
active = v
|
||||
queue_redraw()
|
||||
|
||||
const SHEAR := 7.0
|
||||
const INK_W := 3.0
|
||||
const POP_DECAY := 4.0
|
||||
|
||||
var _pop: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
custom_minimum_size = Vector2(96, 34)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _pop <= 0.0:
|
||||
return
|
||||
_pop = maxf(_pop - delta * POP_DECAY, 0.0)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var w := size.x
|
||||
var h := size.y
|
||||
if w <= 1.0 or h <= 1.0:
|
||||
return
|
||||
|
||||
var ready := cooldown <= 0.001
|
||||
# The overshoot: a brief lift in the fill, decaying to the resting colour.
|
||||
var lift: float = _pop * _pop
|
||||
|
||||
var fill: Color = UITheme.INK
|
||||
var text: Color = UITheme.PAPER_DIM
|
||||
if active:
|
||||
# In use. Cyan, so it cannot be confused with either of the other two.
|
||||
fill = UITheme.CYAN
|
||||
text = UITheme.ink_for(UITheme.CYAN)
|
||||
elif ready:
|
||||
fill = UITheme.VOLT.lerp(UITheme.PAPER, lift * 0.5)
|
||||
text = UITheme.ink_for(UITheme.VOLT)
|
||||
|
||||
_shear(0.0, w, fill, h)
|
||||
|
||||
# The recharge wipe, left to right over the dark chip.
|
||||
if not ready and not active:
|
||||
_shear(0.0, w * (1.0 - cooldown), UITheme.PAPAYA, h)
|
||||
# Anything the wipe has reached is papaya, anything it has not is ink,
|
||||
# and one text colour has to sit on both. Ink loses against ink; paper
|
||||
# wins against both, and the outline below covers the papaya case.
|
||||
text = UITheme.PAPER
|
||||
|
||||
_shear_outline(0.0, w, UITheme.INK, h)
|
||||
|
||||
var font := get_theme_default_font()
|
||||
if font == null:
|
||||
return
|
||||
var fs := 17
|
||||
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs)
|
||||
var at := Vector2((w - text_size.x) * 0.5, (h + text_size.y * 0.62) * 0.5)
|
||||
# Ink outline under the glyphs, same as every Label in the theme, so the
|
||||
# label survives the wipe passing underneath it.
|
||||
draw_string_outline(font, at, label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, 5,
|
||||
UITheme.INK)
|
||||
draw_string(font, at, label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, text)
|
||||
|
||||
|
||||
func _shear(x0: float, x1: float, col: Color, h: float) -> void:
|
||||
if x1 - x0 < 0.5:
|
||||
return
|
||||
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