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)