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
+175
@@ -0,0 +1,175 @@
|
||||
extends Control
|
||||
class_name Crosshair
|
||||
|
||||
## The reticle, drawn rather than assembled out of ColorRects.
|
||||
##
|
||||
## It replaces five white rectangles that were pasted into three different level
|
||||
## runtimes, and it exists as one drawn thing for two reasons.
|
||||
##
|
||||
## The first is that it has to say something. A static cross tells the player
|
||||
## nothing they do not already know; a reticle that OPENS as they sprint, jump and
|
||||
## fire, and snaps shut when they stop or shoulder the weapon, is the accuracy
|
||||
## readout of the whole game and it costs one number per frame. Four separate
|
||||
## ColorRects cannot express that without four separate position updates, which is
|
||||
## why they never did.
|
||||
##
|
||||
## The second is the ink. Every other element in this UI carries a heavy dark edge
|
||||
## — it is the theme's first rule, and the reason white type stays legible over a
|
||||
## sunlit 3D scene. A 2 px white line does not: over pale concrete it disappears
|
||||
## exactly when aim matters. Here every stroke is drawn twice, ink underneath and
|
||||
## wider, so the reticle reads against the map instead of against luck.
|
||||
##
|
||||
## The hit confirmation lives here too, rather than as a separate centred Control
|
||||
## fading on top. It is the same four strokes rotated 45°, which means the
|
||||
## feedback arrives where the player's eye already is and shares the reticle's
|
||||
## outline instead of needing its own.
|
||||
|
||||
## 0 = tight, 1 = fully bloomed. Written each frame by PlayerHUD from speed,
|
||||
## airtime and fire cooldown.
|
||||
var spread: float = 0.0:
|
||||
set(v):
|
||||
var c := clampf(v, 0.0, 1.0)
|
||||
if absf(c - spread) > 0.002:
|
||||
spread = c
|
||||
queue_redraw()
|
||||
else:
|
||||
spread = c
|
||||
|
||||
## 0 = hip, 1 = down the sights. At full ADS the ticks retract entirely and only
|
||||
## the centre dot remains, which is the convention every shooter uses and the
|
||||
## clearest possible statement that the shot is going where the dot is.
|
||||
var ads: float = 0.0:
|
||||
set(v):
|
||||
var c := clampf(v, 0.0, 1.0)
|
||||
if absf(c - ads) > 0.002:
|
||||
ads = c
|
||||
queue_redraw()
|
||||
else:
|
||||
ads = c
|
||||
|
||||
## Hit and kill confirmations, 1 -> 0. Kill is the louder one and it wins.
|
||||
var hit: float = 0.0
|
||||
var kill: float = 0.0
|
||||
|
||||
## Geometry, in pixels at 1080p.
|
||||
const GAP_TIGHT := 5.0
|
||||
const GAP_BLOOM := 30.0
|
||||
const TICK := 9.0
|
||||
const STROKE := 2.0
|
||||
const INK_GROW := 2.0
|
||||
const DOT := 2.2
|
||||
## How far the confirmation strokes sit out, and how long they are.
|
||||
const HIT_GAP := 13.0
|
||||
const HIT_TICK := 8.0
|
||||
|
||||
## Decay rates, per second. The hit pop is quick — it has to land inside the
|
||||
## rhythm of firing, not linger into the next shot.
|
||||
const HIT_DECAY := 3.2
|
||||
const KILL_DECAY := 1.7
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
# FULL RECT, and the drawing centres itself in `size`.
|
||||
#
|
||||
# A zero-sized Control on PRESET_CENTER is the obvious way to do this and it
|
||||
# is wrong: the control draws once when it enters the tree, before the
|
||||
# viewport has told it how big the screen is, so the whole reticle lands in
|
||||
# the top-left corner and stays there until something else happens to make it
|
||||
# redraw. Nothing does, because the reticle only redraws when the bloom
|
||||
# changes. Owning the full rect and measuring the centre every draw cannot
|
||||
# get that wrong.
|
||||
# `set_anchors_AND_OFFSETS_preset`, not `set_anchors_preset`. The latter
|
||||
# moves the anchors and leaves the offsets where they were, which for a
|
||||
# control that has never been laid out means an empty rect: the measured
|
||||
# result was anchors spanning the viewport and a size of exactly (0, 0).
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
resized.connect(queue_redraw)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if hit <= 0.0 and kill <= 0.0:
|
||||
return
|
||||
hit = maxf(hit - delta * HIT_DECAY, 0.0)
|
||||
kill = maxf(kill - delta * KILL_DECAY, 0.0)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## A landed shot. Kills pass `fatal` and get the louder, slower magenta pop.
|
||||
func confirm(fatal: bool = false) -> void:
|
||||
if fatal:
|
||||
kill = 1.0
|
||||
else:
|
||||
hit = 1.0
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
_mid = size * 0.5
|
||||
# ADS retracts the ticks and tightens what is left, so the bloom cannot
|
||||
# fight the sight picture.
|
||||
var open := 1.0 - ads
|
||||
var gap: float = lerpf(GAP_TIGHT, GAP_BLOOM, spread) * lerpf(1.0, 0.45, ads)
|
||||
var tick: float = TICK * open
|
||||
|
||||
if tick > 0.5:
|
||||
# Vertical ticks are drawn slightly shorter than horizontal ones. A
|
||||
# perfectly square cross reads taller than it is because the eye
|
||||
# over-weights vertical extent; trimming the verticals is the standard
|
||||
# correction and it is the difference between a reticle that looks
|
||||
# centred and one that looks slightly high.
|
||||
_stroke(Vector2(-gap - tick, 0), Vector2(-gap, 0), UITheme.PAPER)
|
||||
_stroke(Vector2(gap, 0), Vector2(gap + tick, 0), UITheme.PAPER)
|
||||
var v: float = tick * 0.86
|
||||
_stroke(Vector2(0, -gap - v), Vector2(0, -gap), UITheme.PAPER)
|
||||
_stroke(Vector2(0, gap), Vector2(0, gap + v), UITheme.PAPER)
|
||||
|
||||
# Centre dot: the one element that never moves and never fades. It goes
|
||||
# papaya at the hip and volt down the sights, so shouldering the weapon
|
||||
# changes the reticle's colour as well as its shape.
|
||||
var dot_col: Color = UITheme.PAPAYA.lerp(UITheme.VOLT, ads)
|
||||
var r: float = DOT * lerpf(1.0, 1.25, ads)
|
||||
draw_circle(_mid, r + INK_GROW, UITheme.INK)
|
||||
draw_circle(_mid, r, dot_col)
|
||||
|
||||
# Confirmations, over the top: the same cross rotated 45°.
|
||||
if kill > 0.001:
|
||||
_confirm_arms(kill, UITheme.MAGENTA, 1.35)
|
||||
if hit > 0.001:
|
||||
_confirm_arms(hit, UITheme.VOLT, 1.0)
|
||||
|
||||
|
||||
## Four diagonal strokes, scaled and faded by `amount`.
|
||||
##
|
||||
## They grow slightly as they fade rather than merely fading, which reads as an
|
||||
## impact rather than as a light being switched off — the same expansion-plus-
|
||||
## dissolve that carries an anime impact frame.
|
||||
func _confirm_arms(amount: float, col: Color, scale: float) -> void:
|
||||
var grow: float = 1.0 + (1.0 - amount) * 0.5
|
||||
var gap: float = HIT_GAP * scale * grow
|
||||
var tick: float = HIT_TICK * scale
|
||||
var c := Color(col.r, col.g, col.b, amount)
|
||||
var ink := Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, amount)
|
||||
const DIAGONALS: Array[Vector2] = [Vector2(1, 1), Vector2(1, -1),
|
||||
Vector2(-1, 1), Vector2(-1, -1)]
|
||||
for d in DIAGONALS:
|
||||
var dir := d.normalized()
|
||||
_stroke(dir * gap, dir * (gap + tick), c, ink)
|
||||
|
||||
|
||||
## Screen centre, recomputed at the top of every `_draw`. Every offset below is
|
||||
## relative to it.
|
||||
var _mid: Vector2 = Vector2.ZERO
|
||||
|
||||
|
||||
## One stroke, ink first and wider so the colour sits inside an outline.
|
||||
##
|
||||
## Endpoints are given RELATIVE TO CENTRE and offset here, so no caller has to
|
||||
## remember to add it.
|
||||
##
|
||||
## `draw_line` with a round cap would leave the ink poking out past the ends as a
|
||||
## dark bead; square caps keep the outline flush with the stroke it is edging.
|
||||
func _stroke(a: Vector2, b: Vector2, col: Color,
|
||||
ink: Color = UITheme.INK) -> void:
|
||||
draw_line(_mid + a, _mid + b, ink, STROKE + INK_GROW * 2.0, false)
|
||||
draw_line(_mid + a, _mid + b, col, STROKE, false)
|
||||
Reference in New Issue
Block a user