shrike/hud.py¶

Part of SHRIKE.

   1"""SHRIKE's heads-up display: foveal arcs, a warming border and diegetic rings.
   2
   3The HUD is deliberately not a corner-panel dashboard. Everything the pilot
   4reads while dodging sits inside the foveal zone around the ship:
   5
   6* **Hull, capacitor, O2 and fuel** are thin arcs ringing the ship, each with a
   7  dim track behind a bright fill, so a glance costs no eye movement. Each is
   8  captioned at a fixed compass point *outside every ring the HUD draws*, and
   9  hull and capacitor carry their number there because they have no cell in the
  10  strip below.
  11* **Every resource the run prices is a number under the ring.** The strip
  12  (:meth:`Hud.resource_lines`) carries scrap, fuel and air, and it is in the
  13  run-one floor because run one quotes five scrap for a patch and fifteen fuel
  14  for a jump. The rule it follows: *anything the run can
  15  charge or credit has a visible number from the moment it is first touched.*
  16  Under it sit the power-state pips (:meth:`Hud.state_pips`), which are what
  17  the wings key and the generator key have to show for themselves.
  18* **The shield pad is drawn on the bearing it covers.** Q and E move a defence
  19  that would otherwise exist only in the collision maths, and a control with no
  20  output reads as an unbound key.
  21* **The signature meter is a gauge and a border.** An arc over the ship's nose
  22  carries the reading and its number; the screen border warms from cold blue to
  23  red behind it, off the same fraction. A border alone does not work: a pilot
  24  can watch the frame redden for seventy-five seconds without learning that it
  25  is a meter, and die to the arrival it was counting down to. A tint is a mood;
  26  a gauge is a reading, and this meter needs both. When it locks, the HUD says so in the imperative
  27  and then draws the countdown and a marker on the approach bearing, because a
  28  killer nobody has ever seen is not a boss, it is a bug.
  29* **The warp spool is a ring closing on the ship.** It shrinks toward the hull
  30  over ``balance.WARP_SPOOL_S`` and visibly stutters every time a hit adds
  31  seconds to the channel. It never simply disappears: a jump whites the screen
  32  out, and an abort or a failure holds the ring in red for
  33  :data:`WARP_OUTCOME_FLASH_S` with the ending written under it, so "the circle
  34  went big and then small and nothing happened" is not a state the HUD has.
  35* **The sector's edge and its compass.** A standing line says the ship has
  36  flown out of the sector (:meth:`Hud.set_boundary_notice`), and while nothing
  37  worth flying to is on screen, edge markers give the bearing and range of the
  38  nearest vein, hulk and depot (:meth:`Hud.set_compass_targets`). A waypoint
  39  label is the one thing on the screen placed by where a rock is rather than by
  40  a layout, so it steps along its edge to a row clear of the whole standing HUD
  41  (:meth:`Hud._label_zones`) and stands its marker down rather than share a row
  42  with one of the transient plates at the foot of the screen.
  43* **Damage direction is edge desaturation**, never a red overlay: the edge the
  44  hit came from drains of colour for a moment. :meth:`Hud.edge_desaturation`
  45  exposes the same per-edge intensities as a hook, so a post-process pass can
  46  drive the real desaturation while the HUD draws the fallback band.
  47* **Dying is a state you can see coming.** Below :data:`LOW_HULL_FRACTION` the
  48  whole border pulses and the klaxon caption keeps coming back; under
  49  ``balance.O2_LOW_WARNING_SECONDS`` of air the remaining seconds are counted
  50  down beside the ship. Both are warnings, not readouts, so onboarding never
  51  subtracts them: a pilot who cannot see the hull can still see it is critical.
  52* **The warp ring says what it is spooling to.** ``WARP SPOOL`` names the
  53  destination and its fuel price while the channel runs, an interruption
  54  flashes the seconds it just cost, and the abort key is written under it.
  55* **Floating text belongs to the world, not to the corner.** A pickup's value
  56  and a first-sighting enemy tag are drawn where the thing is
  57  (:meth:`Hud.float_text`), and a one-line :meth:`Hud.show_toast` carries the
  58  short answers a key press deserves ("select a destination: M"). A label that
  59  names a counter the HUD keeps flies across to it as it fades, so the number
  60  in the corner grows because of something the pilot watched happen. Anything
  61  paid in fractions goes through :meth:`Hud.credit_float`, which banks per
  62  source and writes one label per whole unit, so a beam paying a tenth of a
  63  unit a frame never captions itself ``+0 SCRAP`` beside a climbing counter.
  64* **Everything under the hull has a reserved slot.** The warp chip, the
  65  survival warnings, the run's current instruction and the boundary line each
  66  own a band (``BAND_*_PX``) and never leave it, and the waypoint labels stay
  67  out of the controls strip's band. One shared offset would put four sentences
  68  on one row of pixels, legible as none of them. The transient
  69  answer line is the one row anchored to the screen rather than to the hull,
  70  so it steps around whichever standing lines are up
  71  (:meth:`Hud.message_row`) instead of being written through them.
  72* **Every reading around the hull sits on its own plate.** The gauge captions
  73  and the signature reading are drawn over the playfield, and the playfield is
  74  allowed to be a lantern cone bloomed several stops over white. The burn frame
  75  is the one frame where HULL and SIGNATURE decide whether the pilot
  76  understands their own death, and without a plate it is the one frame where
  77  neither can be read. Nothing the HUD draws over the playfield is allowed to cover them
  78  either: a rising float takes a lane beside a reading rather than through it
  79  (:meth:`Hud._float_lane`), and the standing lines stand down when the caption
  80  track reaches their band (:meth:`Hud.caption_block`).
  81* **The affordance line names the verb in reach.** Whenever something the ship
  82  can act on is close enough to act on, one short line sits above the gauge
  83  ring and says which key does what ("F: DOCK", "HOLD RMB: CRACK THE CORE").
  84  It is pushed every frame by whoever resolved the reach
  85  (:meth:`Hud.set_affordance`) and it is never subtracted: a verb nobody names
  86  is a verb nobody performs.
  87* **The controls strip is always there.** Two rows on a dark plate along the
  88  bottom of the screen list the whole keyboard in the order a pilot needs it.
  89  It is on by default for every run, toggled with the controls key or from the
  90  settings screen, and it is independent of the onboarding lessons, which stop
  91  showing after run three and never come back.
  92* **The warp chip answers "when can I leave?"** It sits where the spool ring
  93  will close and reads one of three states: no destination picked, ready at a
  94  named fuel price, or short of fuel by a stated amount.
  95* **Every audio cue also lands here as a caption on the caption plate**
  96  (:data:`CUE_SUBTITLES`, written by :func:`caption_for`), because a mix that
  97  carries mechanical information must never be hearing-gated. The caption is a
  98  diegetic phrase in small caps on a dark strip (KLAXON, HULL BREACHED,
  99  WHISTLE) and it carries no punctuation at all: punctuation around a word in
 100  the middle of the screen reads as placeholder text that shipped, so the plate
 101  does the work glyphs cannot.
 102* **Arriving somewhere is an event with a name.** A jump lands the hull in a
 103  field of rocks that looks like the last field of rocks, so
 104  :meth:`Hud.show_arrival_card` names the biome and says one line about it, and
 105  :meth:`Hud.set_sector_name` leaves the name standing in the corner for as
 106  long as the sector lasts.
 107* **The control card is onboarding's own channel**, and it is not the caption
 108  queue. One centred panel names the keys of the lesson in hand and stays up
 109  until the player does the thing, so a control the player never found cannot
 110  scroll away under a combat caption. :meth:`Hud.show_control_card` puts one up
 111  and :meth:`Hud.clear_control_card` takes it down; the schedule that decides
 112  which card is current lives in ``shrike.onboarding``. The card is drawn where
 113  :meth:`Hud.control_card_layout` says, which is never over the ship's own
 114  status band (:meth:`Hud.status_band_top`): a fixed top-band panel would sit
 115  on ``SIGNATURE LOCKED`` and on every breach message whenever the aim lead
 116  carries the hull up the screen. It yields in order (up, then its
 117  hint, then its size, then off), and the ship's readings never move for it.
 118
 119Every pixel constant here is authored against a 720-pixel-tall window and
 120scaled at draw time by :attr:`Hud.effective_scale`, which is the window's own
 121height scale (:func:`screen_scale_for`) times the player's UI-scale slider. A
 122window twice as tall therefore draws a HUD twice as large rather than a HUD
 123half as readable.
 124
 125Onboarding subtracts elements rather than adding them: the HUD draws whatever
 126is in :attr:`Hud.visible_elements`, and ``flow.Onboarding`` narrows that set for
 127the first three runs. :data:`ALL_HUD_ELEMENTS` is the vocabulary and
 128:data:`MINIMAL_HUD_ELEMENTS` the run-one floor.
 129
 130Sources are discovered, not injected. The HUD scans the run's singletons and
 131the ship's subtree for the signals in :data:`_WANTED_SIGNALS` and connects to
 132whatever exists, re-scanning while anything is still missing. A HUD in a scene
 133with no power system simply draws empty arcs instead of raising.
 134"""
 135
 136from __future__ import annotations
 137
 138import math
 139import weakref
 140from typing import NamedTuple
 141
 142import numpy as np
 143
 144from simvx.core import Anchor, Control, Input, Node, PointerFilter, Signal, Vec3
 145
 146from . import balance
 147from .runtime import CameraRig, Groups, Services, SignalNames, heading_to_direction
 148
 149# ============================================================================
 150# Local presentation constants
 151#
 152# balance.py owns tuning that changes how the game plays; these govern how the
 153# HUD looks, which is this module's own business. They are all in pixels at a
 154# UI scale of 1.0 or in seconds.
 155# ============================================================================
 156
 157#: Radius of the innermost gauge arc, measured from the ship on screen.
 158ARC_RADIUS_PX = 58.0
 159#: Radial gap between the gauge ring and the interact fill inside it.
 160INTERACT_FILL_RADIUS_PX = 34.0
 161ARC_THICKNESS_PX = 5.0
 162#: Degrees of arc per drawn segment; small enough that the chord reads as curved.
 163ARC_SEGMENT_DEGREES = 6.0
 164
 165#: Captions outside the rings, on the compass point of the arc they name. Four
 166#: unlabelled arcs are four colours a pilot has to have been told about; four
 167#: labelled ones are a gauge cluster.
 168#:
 169#: They are drawn *clear of every ring*, not on the ring's own radius. Centring
 170#: a label on the compass point would put the side captions half over the arcs
 171#: and half over the hull, with "O2" written across the ship and "FUEL" with
 172#: its first two letters behind the fuel arc. The side captions hang outward
 173#: from the rings and the top and bottom ones sit above and below them, so
 174#: nothing the HUD draws around the hull is written on anything else the HUD
 175#: draws around the hull.
 176ARC_LABEL_GAP_PX = 10.0
 177#: The gauge captions name the four numbers that keep the pilot alive, so they
 178#: are drawn at full alpha and at a size a glance can resolve: dimmer or
 179#: smaller, they read as four illegible smudges against a bright nebula.
 180ARC_LABEL_FONT_SCALE = 0.9
 181#: Wide enough for the longest caption and its numeral ("HULL 100").
 182ARC_LABEL_BOX_PX = 104.0
 183ARC_LABEL_LINE_PX = 16.0
 184#: The plate every reading around the hull is written on.
 185#:
 186#: These numbers are drawn over the playfield, and the playfield is allowed to
 187#: be a bloomed white lantern shaft: on the burn frame the hull arc, the HULL
 188#: number and the SIGNATURE reading all sit inside the cone, and unbacked none
 189#: of them is readable on the one frame in the game where reading them matters
 190#: most. The plate is what puts the ship's own
 191#: numbers in front of whatever the sector is doing, and it costs one rect per
 192#: caption.
 193READING_PLATE_PADDING_X_PX = 5.0
 194READING_PLATE_PADDING_Y_PX = 2.0
 195#: Clearance between a ring caption and whatever is written beyond it.
 196ARC_LABEL_CLEARANCE_PX = 4.0
 197#: How hull and capacitor write their numbers. O2 and fuel carry theirs in the
 198#: resource strip, so their captions stay bare: two copies of one number is one
 199#: copy too many, and these two have no other copy.
 200ARC_LABEL_WITH_VALUE = "{caption} {value:.0f}"
 201
 202#: The shield arc, drawn just outside the gauge ring on the bearing it covers.
 203#: Q and E move the pad, and a pad that answered the key only in the collision
 204#: maths would be a control with no output.
 205SHIELD_ARC_GAP_PX = 9.0
 206SHIELD_ARC_THICKNESS_PX = 6.0
 207
 208#: The power-state pips: solar wings, generator and afterburner, as three small
 209#: lamps on the second row of the ship-state block. Each names the key that
 210#: moves it, so pressing X or Z always changes something on screen.
 211STATE_PIP_LINE_PX = 17.0
 212STATE_PIP_CELL_PX = 116.0
 213STATE_PIP_FONT_SCALE = 0.85
 214#: How each pip reads, ``(on, off)``. The afterburner has no off text: an
 215#: engine that is not burning is the resting state and does not need a lamp.
 216PIP_WINGS = ("X WINGS OUT", "X WINGS IN")
 217PIP_GENERATOR = ("Z GEN RUNNING", "Z GEN OFF")
 218PIP_AFTERBURNER = ("SHIFT BURNING", "SHIFT BURN")
 219
 220#: The warp ring starts this far out and closes onto the gauge ring.
 221WARP_RING_START_RADIUS_PX = 170.0
 222WARP_STUTTER_S = 0.45
 223WARP_STUTTER_AMPLITUDE_PX = 9.0
 224WARP_STUTTER_HZ = 22.0
 225
 226#: The label under the closing ring, and the gap between it and the ring.
 227WARP_LABEL = "WARP SPOOL"
 228#: What the abort line says. The action, not the key: a remapped drive still
 229#: aborts on whatever the pilot bound it to, and this is the name they saw.
 230WARP_ABORT_HINT = "tap R again to abort"
 231#: The price the ring quotes beside the destination. The caller may hand down
 232#: its own wording (the warp chip itemises a surcharge, and a ring quoting a
 233#: bare total beside a chip that itemises reads as two different prices); when
 234#: it does not, the ring spells the number it was given.
 235WARP_LABEL_PRICE = " for {price} fuel"
 236WARP_LABEL_GAP_PX = 16.0
 237WARP_LABEL_LINE_PX = 20.0
 238WARP_LABEL_FONT_SCALE = 1.1
 239#: How long the interruption line stays up after a hit lengthens the channel.
 240WARP_INTERRUPT_FLASH_S = 1.4
 241WARP_INTERRUPT_FONT_SCALE = 1.3
 242#: What an interruption is spelled out as. "INTERRUPTED" on its own would tell
 243#: the pilot that something happened and nothing about what or why; this names
 244#: the cost in seconds and the cause in the same breath.
 245WARP_INTERRUPT_PREFIX = "SPOOL INTERRUPTED"
 246WARP_INTERRUPT_CAUSE = "taking fire"
 247
 248#: The warp chip: one line in the context band under the ship, up whenever no
 249#: spool is running, answering "can I leave, and what does it cost?".
 250WARP_STATUS_FONT_SCALE = 1.0
 251#: How far the chip's plate is padded past its own type, sideways only. A
 252#: refused spool holds its red ring at the outer radius, and the outer radius
 253#: crosses this band, and without a plate the ring runs straight through
 254#: "need 15, have 5", which is the shortfall a stranded pilot has most need of.
 255#: The plate is laid down after the ring and before the type, so the ring
 256#: passes behind the chip rather than through it. It takes no vertical padding
 257#: because the band's row already carries the type's leading, and a plate
 258#: taller than its band walks into the resource strip's plate above it.
 259WARP_STATUS_PLATE_PADDING_X_PX = 10.0
 260
 261#: Border thickness at signature zero and at :data:`balance.SIGNATURE_MAX`.
 262SIGNATURE_BORDER_MIN_PX = 2.0
 263SIGNATURE_BORDER_MAX_PX = 18.0
 264
 265#: The signature gauge proper: an outer arc over the ship's nose, with the
 266#: meter's own number written above it. A border alone is not enough, because
 267#: a screen tint is a mood and not a reading: a pilot can watch the frame turn
 268#: red for seventy-five seconds without ever learning that the red is a meter,
 269#: that it is filling, or how full it is. The border stays, because a
 270#: peripheral warm is the right thing to feel; this is the thing to read.
 271SIGNATURE_ARC_GAP_PX = 20.0
 272SIGNATURE_ARC_SPAN_DEGREES = 156.0
 273SIGNATURE_ARC_THICKNESS_PX = 4.0
 274
 275#: Where the rings around the hull stop. The shield pad follows the aim and can
 276#: be at any bearing, so it sets the outer edge everywhere; the signature gauge
 277#: only ever covers the nose, so it sets a taller one above the ship alone. The
 278#: gauge captions are placed off these two, which is what keeps them off the
 279#: arcs however wide the rings get.
 280RING_OUTER_PX = ARC_RADIUS_PX + SHIELD_ARC_GAP_PX + SHIELD_ARC_THICKNESS_PX * 0.5
 281SIGNATURE_RING_OUTER_PX = ARC_RADIUS_PX + SIGNATURE_ARC_GAP_PX + SIGNATURE_ARC_THICKNESS_PX * 0.5
 282#: The inner edge of a ring caption: how far from the hull the type begins.
 283ARC_LABEL_RADIUS_PX = RING_OUTER_PX + ARC_LABEL_GAP_PX
 284ARC_LABEL_TOP_RADIUS_PX = SIGNATURE_RING_OUTER_PX + ARC_LABEL_GAP_PX
 285
 286#: The signature reading is written above the hull caption rather than at a
 287#: fixed distance from its own arc, so the two never share a row.
 288SIGNATURE_LABEL_GAP_PX = (
 289    ARC_LABEL_TOP_RADIUS_PX + ARC_LABEL_LINE_PX + ARC_LABEL_CLEARANCE_PX - ARC_RADIUS_PX - SIGNATURE_ARC_GAP_PX
 290)
 291SIGNATURE_LABEL_LINE_PX = 18.0
 292SIGNATURE_LABEL_FONT_SCALE = 0.95
 293SIGNATURE_LABEL = "SIGNATURE {value:.0f}/{maximum:.0f}"
 294#: How far above the ship the whole signature gauge reaches, label included.
 295#: Whatever sits above the ring measures from here rather than from the ring,
 296#: or it lands on the reading.
 297SIGNATURE_GAUGE_TOP_PX = ARC_RADIUS_PX + SIGNATURE_ARC_GAP_PX + SIGNATURE_LABEL_GAP_PX + SIGNATURE_LABEL_LINE_PX
 298
 299#: What the lock says. It is an instruction, not a status: by the time it is up
 300#: the only question the pilot has is what to do about it. The stamp is the
 301#: headline and the standing line is the answer, because a stamp that fades has
 302#: told a first-time pilot nothing.
 303#:
 304#: The answer is the warp drive and only the warp drive. Silent running is what
 305#: keeps the meter off the ceiling, and it does nothing once the ceiling has
 306#: been hit: ``SignatureMeter.locked`` latches for the whole sector and the
 307#: ladder is already running. Offering it here would be an instruction that
 308#: does not work, which is worse than no instruction at all.
 309SIGNATURE_LOCKED_STAMP = "SIGNATURE LOCKED"
 310SIGNATURE_LOCKED_INSTRUCTION = "SIGNATURE LOCKED: the Shrike is inbound. Warp out (R) before it lands"
 311
 312#: The inbound Shrike's countdown and the marker on its approach vector. The
 313#: telegraph ladder is generous with seconds, and a sound alone would spend all
 314#: of them; this is the same warning written down.
 315HUNTER_COUNTDOWN = "SHRIKE INBOUND: {seconds:.0f} s"
 316HUNTER_MARKER_RADIUS_PX = 11.0
 317HUNTER_MARKER_EDGE_MARGIN_PX = 54.0
 318HUNTER_MARKER_FONT_SCALE = 1.0
 319#: How far along the approach bearing the marker is planted. Past the edge of
 320#: the visible field on purpose, so the marker rides the screen edge and reads
 321#: as a bearing rather than drifting over the play area as a thing that is
 322#: already there.
 323HUNTER_MARKER_RANGE_UNITS = 60.0
 324
 325#: Seconds the lost chunk holds at full length before it starts draining, and
 326#: seconds it then takes to drain onto the new level. Both are fixed times
 327#: rather than a rate across the whole ring: a rate would give the heaviest
 328#: blow in the game the *shortest* animation, because a chunk a third of the
 329#: ring long would be a third of the way through the drain before it was drawn.
 330#: A blow reads as a blow at any size, and the size is in how much of the ring
 331#: lights up rather than in how long it takes to go.
 332HULL_CHIP_HOLD_S = 0.35
 333HULL_CHIP_DECAY_S = 0.35
 334#: How much thicker the lost chunk is drawn than the arc it came off. Colour
 335#: alone cannot carry it: every Shrike arrival happens at a full signature
 336#: meter, which puts a saturated red arc twenty pixels outside the hull arc and
 337#: a red border behind both, and a red chunk is a small sliver inside all of
 338#: that. A chunk that stands proud of the ring reads as a loss on a red screen
 339#: and reads for a pilot who cannot separate the two reds at all.
 340HULL_CHIP_THICKNESS_MULT = 2.2
 341
 342#: How long one edge stays desaturated after a hit from that bearing.
 343DAMAGE_EDGE_DECAY_S = 1.1
 344#: Damage, in hull points, that saturates the edge hook at 1.0.
 345DAMAGE_EDGE_FULL_HIT = 25.0
 346DAMAGE_EDGE_BAND_PX = 26.0
 347#: Weight the smallest hit still raises its edge to. A scratch from behind is
 348#: exactly the hit a pilot needs to be told the bearing of, and a band drawn at
 349#: a tenth of an alpha is a band nobody sees.
 350DAMAGE_EDGE_MIN_WEIGHT = 0.4
 351
 352#: Window height every pixel constant in this module is authored against, and
 353#: the height at which :func:`screen_scale_for` returns 1.0.
 354UI_SCALE_REFERENCE_HEIGHT_PX = 720.0
 355#: Clamps on the height-derived half of the UI scale. The floor keeps a small
 356#: window legible, the ceiling stops a 4K screen drawing a HUD that eats the
 357#: playfield.
 358UI_SCALE_FROM_SCREEN_MIN = 0.75
 359UI_SCALE_FROM_SCREEN_MAX = 2.0
 360#: Height of one text unit in pixels: ``text(scale=s)`` draws glyphs about
 361#: ``TEXT_EM_PX * s`` tall. Used to state text sizes as a share of the screen.
 362TEXT_EM_PX = 16.0
 363
 364SUBTITLE_HOLD_S = 2.6
 365SUBTITLE_MAX_LINES = 3
 366SUBTITLE_LINE_HEIGHT_PX = 26.0
 367SUBTITLE_BOTTOM_MARGIN_PX = 74.0
 368#: Captions are read at a glance from the middle of a dodge, so the floor is a
 369#: share of the screen rather than a pixel count: at the reference height this
 370#: scale draws them at :data:`SUBTITLE_MIN_HEIGHT_FRACTION` of the screen, and
 371#: the screen half of the UI scale holds that share at any other height.
 372SUBTITLE_FONT_SCALE = 1.2
 373SUBTITLE_MIN_HEIGHT_FRACTION = 0.02
 374#: The plate the caption stack is written on. Bare text floating over the star
 375#: field read as debug output rather than as a subtitle track; a caption needs
 376#: the same dark strip a film gives one before the words mean "this is a sound".
 377SUBTITLE_PLATE_PADDING_X_PX = 18.0
 378SUBTITLE_PLATE_PADDING_Y_PX = 6.0
 379#: Rough advance width of one glyph as a share of its em, used to size plates
 380#: and to wrap captions. The renderer's font is close enough to monospaced at
 381#: HUD sizes that a plate measured this way never crops a line.
 382GLYPH_ADVANCE_FRACTION = 0.56
 383
 384#: The control card. Onboarding's channel: one panel, big type, no queue.
 385CONTROL_CARD_FONT_SCALE = 2.0
 386CONTROL_CARD_HINT_FONT_SCALE = 1.1
 387#: Where the panel's top edge sits, as a fraction of the screen's height. High
 388#: enough to clear the ship and its gauge ring at the centre of the screen.
 389CONTROL_CARD_TOP_FRACTION = 0.11
 390CONTROL_CARD_WIDTH_PX = 640.0
 391CONTROL_CARD_PADDING_PX = 10.0
 392CONTROL_CARD_LINE_HEIGHT_PX = 36.0
 393CONTROL_CARD_HINT_LINE_HEIGHT_PX = 24.0
 394#: How far a long lesson line may be shrunk to stay inside its own plate. The
 395#: card is authored at :data:`CONTROL_CARD_FONT_SCALE`, but the lesson lines are
 396#: sentences of varying length, and unshrunk the long ones run off both ends.
 397CONTROL_CARD_MIN_FONT_SCALE = 0.9
 398#: How far above the screen's top edge the card may be pushed when it has to
 399#: yield, and the gap it keeps from whatever it is yielding to.
 400CONTROL_CARD_MIN_TOP_PX = 8.0
 401CONTROL_CARD_CLEARANCE_PX = 10.0
 402#: The card's yielded form: title only, in smaller type on a tighter plate. A
 403#: pilot flying with the mouse high puts the ship near the top of the screen,
 404#: and the full card then has nowhere to be that is not on top of the ship's
 405#: own status band. The lesson shrinks; the ship's readings do not move.
 406CONTROL_CARD_COMPACT_FONT_SCALE = 1.35
 407CONTROL_CARD_COMPACT_LINE_PX = 26.0
 408CONTROL_CARD_COMPACT_PADDING_PX = 8.0
 409#: The card's hold meter: a real bar across the foot of the panel, drawn as a
 410#: dim track with a bright fill, never typed characters in the hint line: a
 411#: row of hashes inside square brackets in the middle of a tutorial panel is
 412#: what unfinished software looks like.
 413CONTROL_CARD_BAR_HEIGHT_PX = 6.0
 414CONTROL_CARD_BAR_GAP_PX = 6.0
 415#: The typed form, still read off a hint so it can be turned into the drawn one.
 416TYPED_BAR_OPEN = "["
 417TYPED_BAR_CLOSE = "]"
 418TYPED_BAR_FULL = "#"
 419TYPED_BAR_EMPTY = "."
 420TYPED_BAR_GLYPHS = TYPED_BAR_FULL + TYPED_BAR_EMPTY
 421
 422#: The sector arrival card: the name of the place and one line about it, held
 423#: long enough to read from the middle of a warp-in and then gone. It is not a
 424#: control card and never queues behind one; it owns the band under the card.
 425ARRIVAL_CARD_HOLD_S = 2.5
 426ARRIVAL_CARD_FADE_S = 0.45
 427ARRIVAL_CARD_TOP_FRACTION = 0.24
 428ARRIVAL_CARD_FONT_SCALE = 2.2
 429ARRIVAL_CARD_FLAVOUR_FONT_SCALE = 1.15
 430ARRIVAL_CARD_LINE_PX = 40.0
 431ARRIVAL_CARD_FLAVOUR_LINE_PX = 24.0
 432
 433#: The standing sector name, top right, so "where am I" survives the card that
 434#: announced it. Sits opposite the scrap and notoriety stack.
 435SECTOR_NAME_FONT_SCALE = 1.05
 436SECTOR_NAME_LINE_PX = 20.0
 437#: The box the name is right-aligned inside. Named because a waypoint label has
 438#: to know how much of the top right corner the name is actually holding.
 439SECTOR_NAME_BOX_PX = 320.0
 440
 441STAMP_HOLD_S = 1.6
 442STAMP_FADE_S = 0.5
 443STAMP_FONT_SCALE = 2.4
 444#: Where the stamp lands, as fractions of the screen height. Named rather than
 445#: inlined because the top band has to know what it must not draw over.
 446STAMP_TOP_FRACTION = 0.32
 447STAMP_HEIGHT_FRACTION = 0.12
 448
 449READOUT_MARGIN_PX = 22.0
 450READOUT_FONT_SCALE = 1.0
 451READOUT_LINE_HEIGHT_PX = 20.0
 452
 453#: The developer frame readout, behind :data:`DEBUG_FRAME_STATS_ACTION`. A
 454#: rolling window rather than the live dt: one frame's wall clock on a laptop is
 455#: mostly scheduler noise, and the question the readout exists to answer is
 456#: whether this build is hotter than the last one. The worst frame in the window
 457#: is printed beside the mean, because a stutter is what a pilot actually feels
 458#: and a mean hides it.
 459DEBUG_FRAME_STATS_ACTION = "debug_frame_stats"
 460FRAME_STATS_WINDOW = 90
 461FRAME_STATS_FONT_SCALE = 0.85
 462FRAME_STATS_LINE_PX = 16.0
 463FRAME_STATS_WIDTH_PX = 300.0
 464#: How many GPU phases the readout prints, worst first. A driver with timestamp
 465#: pools reports every pass; the list is long and the hot end is the only part
 466#: worth reading off a screen.
 467FRAME_STATS_GPU_ROWS = 6
 468
 469#: The toast line: one short answer to a key press, above the caption stack.
 470TOAST_HOLD_S = 3.2
 471TOAST_FADE_S = 0.4
 472TOAST_FONT_SCALE = 1.4
 473TOAST_LINE_HEIGHT_PX = 30.0
 474#: Pixels between the toast line and the top of the caption stack.
 475TOAST_GAP_PX = 12.0
 476#: Clearance the message line keeps from every standing line it steps around.
 477#:
 478#: The answer to a key press is anchored to the screen and the ship's standing
 479#: lines are anchored to the hull, so the two rows cross whenever the pilot
 480#: flies at the height that puts them together, and a refusal written straight
 481#: through OBJECTIVE reads as neither. The message keeps its row unless a
 482#: standing line is in it, and then it takes the nearest free row rather than
 483#: sharing one (:meth:`Hud.message_row`).
 484MESSAGE_ROW_GAP_PX = 10.0
 485#: How a second answer is folded into the line rather than replacing it.
 486#:
 487#: Two systems can answer one key press in the same frame: the tutorial says
 488#: what the script wants first, the run says why the key refused. Both land,
 489#: in one sentence: if whichever spoke last were the whole answer, a pilot
 490#: pressing Z in the first minute would read "FIRST: FLY: W A S D" and file the
 491#: generator key as dead.
 492TOAST_WITH_NOTE = "{text} ({note})"
 493#: Seconds a note stays open to being joined by a later answer to the same
 494#: press. Long enough to cross a frame, short enough that the next key press is
 495#: answered on its own.
 496TOAST_NOTE_JOIN_S = 0.5
 497
 498#: World-anchored floating text: a pickup's value, a first-sighting tag.
 499FLOAT_TEXT_HOLD_S = 1.6
 500FLOAT_TEXT_FADE_S = 0.45
 501FLOAT_TEXT_RISE_PX = 46.0
 502FLOAT_TEXT_FONT_SCALE = 1.1
 503#: Ceiling on how many float labels are alive at once. A shoal dying into a
 504#: scoop pass must not be able to paper the screen.
 505FLOAT_TEXT_MAX = 8
 506#: A pickup label that credits a counter flies to it rather than drifting off
 507#: into space. "+12 SCRAP" rising over the wreck it came out of and fading
 508#: never points at the SCRAP readout, which then stays a number in a corner
 509#: that nobody connects to anything they did. The flight eases
 510#: in, so the label is still legible where it landed before it goes.
 511FLOAT_TEXT_FLIGHT_EASE = 2.4
 512#: Which counters a float can be addressed to. The ids are the resource words
 513#: the labels themselves use, lowercased, so a label that names a counted
 514#: resource finds its counter without the caller having to say so twice.
 515FLOAT_TARGET_SCRAP = "scrap"
 516FLOAT_TARGETS: frozenset[str] = frozenset({FLOAT_TARGET_SCRAP})
 517#: How a banked credit is written once a whole unit of it has landed.
 518CREDIT_FLOAT_FORMAT = "+{amount:.0f} {label}"
 519
 520#: Hull fraction below which the border pulses and the klaxon keeps returning.
 521LOW_HULL_FRACTION = 0.30
 522#: Seconds between repeats of the low-hull klaxon while the hull stays there.
 523LOW_HULL_KLAXON_S = 5.0
 524#: Pulse rate of the low-hull border, in cycles per second, and its alpha band.
 525LOW_HULL_PULSE_HZ = 1.6
 526LOW_HULL_PULSE_MIN_ALPHA = 0.18
 527LOW_HULL_PULSE_MAX_ALPHA = 0.55
 528LOW_HULL_BAND_PX = 30.0
 529#: The dark gutter between the signature border and the low-hull pulse, and
 530#: how far inboard that puts the pulse. Both warnings are red rings on the same
 531#: four edges, and drawn concentric from the same edge they read as one band: a
 532#: pilot at low hull would not see the signature border collapse on arrival,
 533#: which is the whole point of having jumped. The pulse steps inboard past the
 534#: signature band's widest, leaving the outermost pixels to the meter alone, so
 535#: what a jump does to the screen is visible whatever the hull is doing. In
 536#: unscaled pixels because the band it steps around is: see
 537#: :meth:`Hud._draw_signature_border`.
 538LOW_HULL_GUTTER_PX = 8.0
 539LOW_HULL_INSET_PX = SIGNATURE_BORDER_MAX_PX + LOW_HULL_GUTTER_PX
 540#: The warning lines under the gauge ring, and their type size.
 541WARNING_FONT_SCALE = 1.25
 542WARNING_LINE_HEIGHT_PX = 24.0
 543LOW_HULL_WARNING = "HULL CRITICAL"
 544
 545# ---------------------------------------------------------------------------
 546# Reserved layout slots under the ship
 547#
 548# Each kind of line the HUD writes below the hull owns a band and never leaves
 549# it: measured from one shared offset, the warp chip, the boundary line and the
 550# survival warnings would land on one row and overprint each other into an
 551# unreadable smear. A band holds its place whether or not its
 552# neighbours have anything to say: a slot that is empty stays empty, because a
 553# line that moves when its neighbour appears is a line the pilot has to find
 554# again every time.
 555#
 556# Offsets are pixels below the ship's centre at UI scale 1, from the top of the
 557# band. Bands are sized by the lines they can hold, so widening one is a matter
 558# of raising its line count and letting the next band fall where it falls.
 559# ---------------------------------------------------------------------------
 560
 561#: The resource strip: every number the run can charge or credit, in one row
 562#: under the gauge ring. It is the first band because it is the one a pilot
 563#: glances at mid-manoeuvre, and it is never subtracted: the game quotes prices
 564#: in scrap, fuel and air from the first minute of the first run, and a price
 565#: quoted against a number nobody can see is a price nobody can pay.
 566#: It starts below the capacitor caption, which is the lowest thing the ring
 567#: cluster writes, rather than at a hand-set offset that the caption could land
 568#: on, and its own plate's padding is part of the offset at both ends: the
 569#: plate is what the eye reads as the edge of the strip, so a neighbour that
 570#: clears the type but not the plate still reads as touching it.
 571RESOURCE_LINE_PX = 22.0
 572#: Width of one resource cell. Fixed, so a number growing a digit never shoves
 573#: its neighbours sideways.
 574RESOURCE_CELL_PX = 116.0
 575RESOURCE_FONT_SCALE = 1.05
 576#: Margin the strip's plate keeps around the widest of its two rows.
 577RESOURCE_PLATE_PADDING_PX = 6.0
 578BAND_RESOURCES_PX = ARC_LABEL_RADIUS_PX + ARC_LABEL_LINE_PX + ARC_LABEL_CLEARANCE_PX + RESOURCE_PLATE_PADDING_PX
 579
 580#: The context line: what the drive can do right now. One line.
 581BAND_CONTEXT_PX = BAND_RESOURCES_PX + RESOURCE_LINE_PX + STATE_PIP_LINE_PX + RESOURCE_PLATE_PADDING_PX
 582BAND_CONTEXT_LINES = 1
 583#: The survival warnings: hull, air, and the air's own advice. Three lines.
 584BAND_WARNING_PX = BAND_CONTEXT_PX + BAND_CONTEXT_LINES * WARNING_LINE_HEIGHT_PX
 585BAND_WARNING_LINES = 3
 586#: The objective line: what the run is asking for, the lock's instruction most
 587#: of all. Two lines, because the lock's instruction is a long sentence.
 588BAND_OBJECTIVE_PX = BAND_WARNING_PX + BAND_WARNING_LINES * WARNING_LINE_HEIGHT_PX
 589BAND_OBJECTIVE_LINES = 2
 590#: The boundary line: where the sector stops. One line, furthest out, because
 591#: it is the least urgent thing down here.
 592BAND_BOUNDARY_PX = BAND_OBJECTIVE_PX + BAND_OBJECTIVE_LINES * WARNING_LINE_HEIGHT_PX
 593BAND_BOUNDARY_LINES = 1
 594#: How far the whole ladder reaches under the hull. The bands are pinned to the
 595#: ship and the ship is not pinned to anything: thrust makes the camera lag and
 596#: the hull rides down the screen, carrying the bottom of this ladder toward
 597#: the controls strip along the foot of the frame.
 598BAND_LADDER_PX = BAND_BOUNDARY_PX + BAND_BOUNDARY_LINES * WARNING_LINE_HEIGHT_PX
 599#: The two lowest bands ride up together when the hull is low enough that the
 600#: ladder would otherwise reach the bottom furniture, and this is as far as they
 601#: can ever go: the foot of the resource strip's plate, which is up for the
 602#: whole run. What they actually get is whatever of it the bands between are
 603#: not using this frame (:meth:`Hud._lower_band_ceiling`).
 604BAND_LOWER_LIFT_MAX_PX = BAND_OBJECTIVE_PX - BAND_CONTEXT_PX
 605
 606#: Half-width of the band the lines are laid out in, so a long instruction has
 607#: somewhere to go rather than being clipped into a shorter sentence.
 608BAND_HALF_WIDTH_PX = 380.0
 609
 610#: What to do about the air, in the same breath as the countdown. A number
 611#: counting down with no acquisition beside it is a death sentence with a
 612#: clock; these are the two places oxygen actually comes from.
 613LOW_O2_HINT = "LOW O2: canisters at depots; ice fields refill (M: chart)"
 614
 615#: The same for the other clock. An empty tank does not suffocate anybody, it
 616#: strands them, and a pilot who learns that at the moment the drive refuses
 617#: has already spent the scrap that would have bought the way out. So the tank
 618#: warns while there is still something to do about it, and names the one
 619#: place fuel comes from in the same breath.
 620#:
 621#: The advice is named on its own because the drive's own refusal carries it
 622#: too, and once the tank will not cover a jump both lines are on screen a
 623#: hand's width apart. Two spellings of one instruction read as two errands.
 624LOW_FUEL_SOURCE = "depots sell fuel cells"
 625LOW_FUEL_HINT = f"LOW FUEL: {LOW_FUEL_SOURCE}"
 626#: Jumps left in the tank at or under which the hint stands up, measured at
 627#: ``balance.WARP_FUEL_BASE`` because that is the fare the chart quotes for a
 628#: hop to the next column. It is the depot screen's own threshold
 629#: (``flow.DEPOT_LOW_FUEL_JUMPS``), and test_hud pins the two together: a bay
 630#: that says the tank is low and a HUD that does not is two answers to one
 631#: question.
 632LOW_FUEL_JUMPS = 2.0
 633
 634#: The standing cue for a pilot flying out of the sector, pushed per frame by
 635#: whoever owns the boundary, and cleared this long after the pushes stop.
 636BOUNDARY_NOTICE_HOLD_S = 0.25
 637
 638#: Screen-edge compass markers for the nearest thing of each kind, pushed per
 639#: frame the same way. They are suppressed the moment any of them is on screen:
 640#: a marker over something the pilot can already see is noise.
 641COMPASS_HOLD_S = 0.25
 642COMPASS_RADIUS_PX = 7.0
 643COMPASS_EDGE_MARGIN_PX = 40.0
 644COMPASS_FONT_SCALE = 0.9
 645#: How far inside the screen a target has to land to count as visible.
 646COMPASS_ON_SCREEN_MARGIN_PX = 24.0
 647#: One short word per kind, and a silhouette to go with it. A vein is a
 648#: "VEIN" and never "ORE": the number beside a waypoint is a range, and a
 649#: waypoint labelled with the name of a resource reads as a second currency.
 650COMPASS_LABELS: dict[str, str] = {"deposit": "VEIN", "wreck": "HULK", "depot": "DEPOT"}
 651COMPASS_SEGMENTS: dict[str, int] = {"deposit": 3, "wreck": 4, "depot": 8}
 652#: The unit on every waypoint range. A bare number next to a word is read as a
 653#: quantity of that word; "41 u" is read as a distance, which is what it is.
 654RANGE_UNIT_SUFFIX = "u"
 655#: Minimum vertical separation between two waypoint labels before the later one
 656#: is pushed clear of the earlier.
 657MARKER_LABEL_SEPARATION_PX = 22.0
 658#: Half the width of the rect a waypoint label is centred in, and the wider one
 659#: the Shrike countdown uses.
 660MARKER_LABEL_HALF_WIDTH_PX = 90.0
 661HUNTER_LABEL_HALF_WIDTH_PX = 130.0
 662#: How far inside the screen's side edges a marker label's rect is kept. The
 663#: marker itself is clamped by :meth:`Hud._marker_point`, but a label rect
 664#: centred on it would hang half off the edge ("SHRIKE INBOUND: 60 s" would
 665#: start at x=-33 in a 1280-wide frame), so the rect slides inboard instead.
 666LABEL_EDGE_INSET_PX = 6.0
 667#: How far inside the bottom edge a marker is allowed to pin, whatever edge
 668#: margin its own kind uses. The caption strip and the controls strip own the
 669#: bottom of the screen between them, and a range written through either of
 670#: them is two unreadable lines rather than one reading. The marker's own
 671#: label hangs below it, so the reservation carries the label's row as well.
 672MARKER_LABEL_GAP_PX = 16.0
 673MARKER_BOTTOM_MARGIN_PX = (
 674    SUBTITLE_BOTTOM_MARGIN_PX
 675    + SUBTITLE_LINE_HEIGHT_PX * SUBTITLE_MAX_LINES
 676    + MARKER_LABEL_GAP_PX
 677    + WARNING_LINE_HEIGHT_PX
 678)
 679#: Clearance a waypoint label keeps from every piece of standing HUD it steps
 680#: around (:meth:`Hud._label_zones`). A label butted flush against the edge of
 681#: the sector name or of a caption plate is still two readings the eye has to
 682#: separate; a few pixels of dark between them is what makes them two lines.
 683MARKER_ZONE_CLEARANCE_PX = 5.0
 684#: How far a waypoint label may step from the row under its own ring before it
 685#: stops being that ring's label. The stack under the hull is wide and deep, so
 686#: "the nearest free row" can be most of a screen away, and a range floating at
 687#: the top of the frame over a ring at the bottom of it is worse than no range
 688#: at all. Past this the marker stands down for the frame instead.
 689MARKER_LABEL_MAX_TRAVEL_PX = 120.0
 690
 691#: The spool's endings. A ring that simply disappears is the whole of "the
 692#: circle goes big then small and nothing happens", so an ending that is not a
 693#: jump holds the ring for a beat in red with its name under it, and a jump
 694#: whites out the screen the way arriving somewhere should.
 695WARP_OUTCOME_FLASH_S = 1.2
 696WARP_ABORTED_LABEL = "SPOOL ABORTED"
 697WARP_JUMP_FLASH_S = 0.45
 698WARP_JUMP_FLASH_ALPHA = 0.85
 699
 700#: The depot beacon: a ring at the depot's bearing with the range under it.
 701BEACON_RADIUS_PX = 9.0
 702BEACON_EDGE_MARGIN_PX = 48.0
 703BEACON_FONT_SCALE = 1.0
 704#: How close the hull has to be for the beacon to become the dock prompt.
 705#: Pinned by test to ``trading.DOCK_RADIUS``, which is the radius the bay
 706#: actually docks at: a prompt that appears outside the bay is a key that does
 707#: nothing, and one that appears inside it and is written somewhere else is a
 708#: key nobody finds: with "F: DOCK" as one small line at the top of the screen
 709#: under a tutorial card, a pilot can cross the ring three times without
 710#: docking.
 711DOCK_PROMPT_RANGE_UNITS = 14.0
 712#: What the ring says when nothing else has claimed the verb line.
 713DOCK_PROMPT = "F: DOCK"
 714#: The word that marks a pushed affordance as being about the dock, so the
 715#: ring can carry the run's own wording ("DOCKED: hold F to sell") instead of
 716#: a fixed line that is wrong the moment the bay opens.
 717DOCK_PROMPT_KEYWORD = "DOCK"
 718#: The ring's armed form: filled, larger, and in the affordance's own colour,
 719#: so being inside the bay is a state the ring shows rather than a fact the
 720#: pilot has to infer from a range that stopped going down.
 721BEACON_ARMED_RADIUS_MULT = 1.7
 722BEACON_ARMED_FONT_SCALE = 1.25
 723
 724#: An interact fill is pushed per frame while the button is held; it clears
 725#: this long after the last push so a released button does not leave a ghost.
 726INTERACT_FILL_HOLD_S = 0.12
 727
 728#: The affordance line: pushed per frame by whoever owns the reach, and cleared
 729#: this long after the pushes stop, so a verb that has just left range goes with
 730#: it instead of hanging over empty space.
 731AFFORDANCE_HOLD_S = 0.20
 732#: How far above the signature gauge the affordance line sits, and its type.
 733#: Measured from the top of the gauge rather than from the ring, so the verb
 734#: in reach never lands on the meter's reading.
 735AFFORDANCE_GAP_PX = 22.0
 736AFFORDANCE_LINE_PX = 24.0
 737AFFORDANCE_FONT_SCALE = 1.3
 738
 739#: The persistent controls bar along the bottom edge.
 740CONTROLS_BAR_MARGIN_PX = 12.0
 741CONTROLS_BAR_LINE_PX = 20.0
 742CONTROLS_BAR_FONT_SCALE = 0.95
 743#: The default bar, in two rows. The first is written in the order a pilot
 744#: needs the keys rather than the order the action map lists them: fly, aim,
 745#: shoot, harvest, act, leave, look. The second covers every remaining bound
 746#: key, because a verb the strip does not name is a verb the player does not
 747#: have, and silent running is the answer to half of what kills them.
 748CONTROLS_BAR_TEXT = "WASD FLY   MOUSE AIM   LMB FIRE   RMB MINE   F INTERACT   R WARP   M CHART   ESC PAUSE   H HIDE"
 749CONTROLS_BAR_SECOND_ROW = "C SILENT   SHIFT BURN   Q/E SHIELD   Z GENERATOR   X WINGS   G JETTISON   TAB SCOOP"
 750#: The strip's own plate. Dim letters over a starfield turn the bar
 751#: into a smear of motes with words behind it; the plate is what makes the row
 752#: legible over anything the sector happens to be drawing under it.
 753CONTROLS_BAR_PLATE_PADDING_PX = 5.0
 754#: How much of the bottom edge the strip owns. Waypoint labels are kept above
 755#: it: a range written through a key list is two unreadable lines, not one.
 756CONTROLS_BAR_BAND_PX = CONTROLS_BAR_MARGIN_PX + 2.0 * CONTROLS_BAR_LINE_PX + 2.0 * CONTROLS_BAR_PLATE_PADDING_PX
 757
 758#: Seconds between rescans while any wanted signal still has no emitter.
 759SOURCE_RESCAN_S = 0.5
 760#: Seconds between re-reads of the hull's magazines. A depot refit is the one
 761#: thing that changes the ammunition aboard without a shot being fired.
 762AMMO_POLL_S = 0.5
 763
 764# Palette. Low-albedo environments and emissive gameplay colours mean the HUD
 765# can stay dim: these are read against near-black, not against daylight.
 766#: The dim rail behind every gauge fill. Nearly opaque rather than a wash: the
 767#: track is what tells a bright frame where the ring is, and at about half alpha
 768#: it dissolves into a lit lantern cone along with the fill it is backing.
 769COLOUR_TRACK = (0.10, 0.12, 0.16, 0.88)
 770COLOUR_HULL = (0.86, 0.89, 0.95, 0.92)
 771COLOUR_CAPACITOR = (0.42, 0.84, 1.00, 0.92)
 772COLOUR_O2 = (0.55, 1.00, 0.78, 0.92)
 773COLOUR_FUEL = (1.00, 0.72, 0.34, 0.92)
 774COLOUR_SIGNATURE_COLD = (0.20, 0.48, 0.72, 0.35)
 775COLOUR_SIGNATURE_HOT = (0.92, 0.13, 0.10, 0.90)
 776COLOUR_WARP_RING = (0.62, 0.82, 1.00, 0.88)
 777COLOUR_INTERACT = (1.00, 0.94, 0.72, 0.90)
 778COLOUR_STAMP = (1.00, 0.96, 0.92, 1.00)
 779COLOUR_SUBTITLE = (0.92, 0.94, 1.00, 0.90)
 780COLOUR_READOUT = (0.78, 0.82, 0.90, 0.85)
 781#: The developer readout and the plate under it. Deliberately not a game colour:
 782#: nothing in the fiction is this green, so a screenshot with it on can never be
 783#: mistaken for a build that shipped with a debug overlay lit.
 784COLOUR_FRAME_STATS = (0.62, 1.00, 0.72, 0.95)
 785COLOUR_FRAME_STATS_PLATE = (0.02, 0.05, 0.03, 0.72)
 786COLOUR_DESATURATION = (0.52, 0.53, 0.56, 0.60)
 787#: The PWR arc while a lantern blackout holds the bus at zero: a dead gauge in
 788#: grey, not a live one that happens to read empty.
 789COLOUR_BLACKOUT = (0.45, 0.47, 0.52, 0.80)
 790COLOUR_TOAST = (1.00, 0.92, 0.68, 0.96)
 791COLOUR_FLOAT_TEXT = (0.98, 0.90, 0.62, 0.95)
 792COLOUR_ENEMY_TAG = (1.00, 0.62, 0.86, 0.95)
 793COLOUR_WARNING = (1.00, 0.42, 0.34, 0.95)
 794#: The chunk a blow just took off the hull arc. Deliberately not
 795#: :data:`COLOUR_WARNING`: see :data:`HULL_CHIP_THICKNESS_MULT` for why a red
 796#: sliver is invisible on the one screen it has to be read on. White-hot
 797#: separates it from the signature arc, the border and the hull arc at once.
 798COLOUR_HULL_CHIP = (1.00, 0.98, 0.92, 1.00)
 799COLOUR_LOW_HULL_PULSE = (0.90, 0.16, 0.14, 1.00)
 800COLOUR_BEACON = (0.62, 1.00, 0.82, 0.90)
 801#: The beacon once the hull is inside the bay: the affordance colour, so the
 802#: ring and the verb line read as one thing.
 803COLOUR_BEACON_ARMED = (0.72, 1.00, 0.88, 0.98)
 804COLOUR_COMPASS = (0.72, 0.86, 1.00, 0.82)
 805COLOUR_HINT = (0.86, 0.90, 1.00, 0.85)
 806COLOUR_WARP_FAILED = (1.00, 0.32, 0.26, 0.95)
 807COLOUR_JUMP_FLASH = (1.00, 1.00, 1.00, 1.00)
 808COLOUR_AFFORDANCE = (0.72, 1.00, 0.88, 0.98)
 809COLOUR_CONTROLS_BAR = (0.62, 0.66, 0.76, 0.72)
 810COLOUR_WARP_STATUS = (0.62, 0.82, 1.00, 0.80)
 811COLOUR_WARP_STATUS_BLOCKED = (1.00, 0.62, 0.40, 0.85)
 812COLOUR_CONTROL_CARD_PANEL = (0.05, 0.07, 0.12, 0.82)
 813COLOUR_CONTROL_CARD_EDGE = (0.42, 0.84, 1.00, 0.55)
 814COLOUR_CONTROL_CARD_TEXT = (1.00, 0.97, 0.90, 0.98)
 815COLOUR_CONTROL_CARD_HINT = (0.72, 0.80, 0.92, 0.88)
 816COLOUR_CONTROL_CARD_BAR = (0.42, 0.84, 1.00, 0.95)
 817COLOUR_CONTROL_CARD_BAR_TRACK = (0.16, 0.20, 0.28, 0.85)
 818COLOUR_CONTROLS_BAR_PLATE = (0.03, 0.04, 0.07, 0.72)
 819COLOUR_OBJECTIVE = (1.00, 0.86, 0.52, 0.96)
 820COLOUR_HUNTER_MARKER = (1.00, 0.38, 0.30, 0.95)
 821COLOUR_SUBTITLE_PLATE = (0.02, 0.03, 0.06, 0.80)
 822COLOUR_SCRAP = (0.98, 0.90, 0.62, 0.95)
 823COLOUR_ARRIVAL_CARD = (0.86, 0.96, 1.00, 1.00)
 824COLOUR_ARRIVAL_FLAVOUR = (0.72, 0.82, 0.94, 0.95)
 825COLOUR_SHIELD = (0.52, 0.92, 1.00, 0.95)
 826COLOUR_SHIELD_BROKEN = (1.00, 0.46, 0.36, 0.90)
 827COLOUR_STATE_PIP_ON = (0.62, 1.00, 0.78, 0.95)
 828COLOUR_STATE_PIP_OFF = (0.42, 0.46, 0.54, 0.75)
 829COLOUR_AMMO = (1.00, 0.84, 0.62, 0.95)
 830#: The plate under the resource strip and its pips. Faint, because these numbers
 831#: sit over the playfield and the plate is there to stop a bright nebula or a
 832#: passing wreck washing the row out, not to box it in.
 833COLOUR_RESOURCE_PLATE = (0.02, 0.03, 0.06, 0.55)
 834#: The plate under each reading around the hull. Darker and more opaque than
 835#: the strip's, because the thing it has to survive is not a nebula: it is a
 836#: lantern cone drawn several stops over white across the middle of the frame.
 837COLOUR_READING_PLATE = (0.02, 0.03, 0.06, 0.78)
 838#: The plate under the drive's chip. Heavier again than a reading's, because
 839#: what it has to keep off those numbers is not a backdrop but a line: the
 840#: failed spool's ring holds its radius across this band for a beat.
 841COLOUR_WARP_STATUS_PLATE = (0.02, 0.03, 0.06, 0.92)
 842
 843
 844def screen_scale_for(height: float) -> float:
 845    """The height-derived half of the UI scale for a window *height* tall.
 846
 847    Linear in the window's height against :data:`UI_SCALE_REFERENCE_HEIGHT_PX`
 848    and clamped to :data:`UI_SCALE_FROM_SCREEN_MIN` ..
 849    :data:`UI_SCALE_FROM_SCREEN_MAX`, so text keeps its share of the screen
 850    instead of shrinking into it.
 851    """
 852    scale = float(height) / UI_SCALE_REFERENCE_HEIGHT_PX
 853    return min(UI_SCALE_FROM_SCREEN_MAX, max(UI_SCALE_FROM_SCREEN_MIN, scale))
 854
 855
 856# ============================================================================
 857# Element vocabulary and the subtraction schedule
 858# ============================================================================
 859
 860
 861class HudElements:
 862    """Ids for every subtractable HUD element.
 863
 864    ``flow.Onboarding.visible_hud_elements`` returns a subset of these and the
 865    run scene hands it to :meth:`Hud.set_visible_elements`. Ids are strings so
 866    a saved assist profile can round-trip them.
 867    """
 868
 869    HULL = "hull"
 870    CAPACITOR = "capacitor"
 871    O2 = "o2"
 872    FUEL = "fuel"
 873    #: The numeric strip under the ring, and the power-state pips beside it.
 874    RESOURCES = "resources"
 875    STATE_PIPS = "state_pips"
 876    SHIELD = "shield"
 877    SECTOR = "sector"
 878    ARRIVAL_CARD = "arrival_card"
 879    SIGNATURE = "signature"
 880    AMMO = "ammo"
 881    SCRAP = "scrap"
 882    NOTORIETY = "notoriety"
 883    GENERATOR = "generator"
 884    SILENT_RUNNING = "silent_running"
 885    WARP_SPOOL = "warp_spool"
 886    INTERACT_FILL = "interact_fill"
 887    DAMAGE_DIRECTION = "damage_direction"
 888    SUBTITLES = "subtitles"
 889    STAMP = "stamp"
 890    CONTROL_CARD = "control_card"
 891    TOAST = "toast"
 892    FLOAT_TEXT = "float_text"
 893    WARNINGS = "warnings"
 894    BEACON = "beacon"
 895    AFFORDANCE = "affordance"
 896    CONTROLS_BAR = "controls_bar"
 897    WARP_STATUS = "warp_status"
 898
 899
 900ALL_HUD_ELEMENTS: frozenset[str] = frozenset(
 901    value for name, value in vars(HudElements).items() if not name.startswith("_") and isinstance(value, str)
 902)
 903
 904#: The run-one floor: the core gauges, plus the diegetic and accessibility
 905#: elements that are never subtracted. The spool ring, the interact fill and the
 906#: damage edge are part of the fiction rather than a readout, and subtitles and
 907#: stamps carry information the mix would otherwise gate on hearing, so
 908#: onboarding hides none of them. The control card is in the floor for the same
 909#: reason in reverse: run one is the run that needs it, and so are the survival
 910#: warnings, the toast line, world-anchored labels and the depot beacon: they
 911#: answer a question the pilot has just asked, or tell them they are about to
 912#: die, and neither is a readout to be earned. The affordance line, the controls
 913#: bar and the warp chip are in the floor for the same reason: they name the
 914#: verbs, and a game that hides its verbs from a first-time pilot has subtracted
 915#: the wrong thing.
 916#:
 917#: The resource numbers are in the floor because of one rule: **anything the
 918#: run can charge or credit carries a visible number from the first moment it
 919#: is touched.** Run one quotes five scrap for a patch, fifteen fuel for a jump
 920#: and ten air for a canister, so subtracting any of those readouts would leave
 921#: a price charged against a number nobody can see. Only ammunition and
 922#: notoriety may still wait, because run one neither spends nor earns either.
 923MINIMAL_HUD_ELEMENTS: frozenset[str] = frozenset(
 924    {
 925        HudElements.HULL,
 926        HudElements.CAPACITOR,
 927        HudElements.O2,
 928        HudElements.FUEL,
 929        HudElements.SCRAP,
 930        HudElements.RESOURCES,
 931        HudElements.STATE_PIPS,
 932        HudElements.SHIELD,
 933        HudElements.SECTOR,
 934        HudElements.ARRIVAL_CARD,
 935        HudElements.GENERATOR,
 936        HudElements.SIGNATURE,
 937        HudElements.WARP_SPOOL,
 938        HudElements.INTERACT_FILL,
 939        HudElements.DAMAGE_DIRECTION,
 940        HudElements.SUBTITLES,
 941        HudElements.STAMP,
 942        HudElements.CONTROL_CARD,
 943        HudElements.TOAST,
 944        HudElements.FLOAT_TEXT,
 945        HudElements.WARNINGS,
 946        HudElements.BEACON,
 947        HudElements.AFFORDANCE,
 948        HudElements.CONTROLS_BAR,
 949        HudElements.WARP_STATUS,
 950    }
 951)
 952
 953#: The resources whose numbers the strip carries, as
 954#: ``(element_id, label, colour)``. Every one of them is a price the game
 955#: quotes out loud somewhere in run one.
 956RESOURCE_CELLS: tuple[tuple[str, str, tuple[float, float, float, float]], ...] = (
 957    (HudElements.SCRAP, "SCRAP", COLOUR_SCRAP),
 958    (HudElements.FUEL, "FUEL", COLOUR_FUEL),
 959    (HudElements.O2, "O2", COLOUR_O2),
 960)
 961
 962#: The ammunition cell, which joins the strip only once the hull has a ballistic
 963#: weapon or a box aboard. It counts loaded rounds and stowed boxes together,
 964#: because "can I keep shooting" is one question. A hull with a gun and nothing
 965#: to feed it reads ``AMMO 0``, which is the reading that matters most.
 966AMMO_CELL = "AMMO {rounds:.0f}"
 967
 968
 969# ============================================================================
 970# Subtitle glyphs
 971# ============================================================================
 972
 973#: Every audio cue, as ``cue_id -> caption``. ``audio.AudioDirector`` plays
 974#: these ids and the HUD captions them; the two must agree, so the ids here are
 975#: the registry and a new cue is added to both in one change. The wordings match
 976#: ``audio.CUES`` exactly for every shared id.
 977#:
 978#: A caption is a diegetic phrase in small caps naming what was heard: KLAXON,
 979#: HULL BREACHED, WHISTLE. It is never the cue's id, never a developer shorthand
 980#: and never punctuated: a line such as ``!! klaxon``, ``[klaxon]`` or
 981#: ``* klaxon *`` in the middle of the screen reads as placeholder text that
 982#: shipped, because punctuation around a word is what unfinished software looks
 983#: like. The dark plate under the stack (:data:`COLOUR_SUBTITLE_PLATE`) is the
 984#: whole of the convention: a caption track is a strip of prose on a plate.
 985CUE_SUBTITLES: dict[str, str] = {
 986    "generator_hum": "GENERATOR HUM",
 987    "hull_creak": "HULL CREAKS",
 988    "silent_running": "HULL CREAKS ONLY",
 989    "klaxon": "KLAXON",
 990    "sonar_ping": "SONAR PING",
 991    "groan": "DISTANT GROAN",
 992    "heartbeat": "HEARTBEAT",
 993    "kill": "KILL CHIME",
 994    "kill_chime": "KILL CHIME",
 995    "weapon_fire": "SHOT",
 996    "breathing": "LABOURED BREATHING",
 997    "spool_stutter": "SPOOL STUTTERS",
 998    "spawn_chevron": "HOSTILES INBOUND",
 999    "mortar_whistle": "WHISTLE",
1000    "signature_locked": "SIGNATURE LOCKED",
1001    "tear_in": "IT TEARS IN",
1002    "lantern_burn": "LANTERN BURN",
1003    "lantern_charge": "LANTERN CHARGING",
1004    "energy_denied": "CAPACITOR EMPTY",
1005    "breach": "HULL BREACHED",
1006}
1007
1008#: How a positional cue names the side it came from, in the order of
1009#: :data:`_EDGES` (left, right, top, bottom). Words rather than arrows: an arrow
1010#: welded to a caption is one more piece of punctuation to be read as scaffolding.
1011CAPTION_DIRECTIONS = ("LEFT", "RIGHT", "ABOVE", "BELOW")
1012#: How a direction is joined to its caption.
1013CAPTION_DIRECTION_FORMAT = "{caption}, {direction}"
1014
1015
1016def caption_for(cue: str) -> str:
1017    """The caption line for audio cue *cue*.
1018
1019    An id with no entry in :data:`CUE_SUBTITLES` is captioned from the id
1020    itself rather than dropped: a missing caption is an accessibility defect
1021    and a clumsy one is a cosmetic defect, and they are not the same size. It
1022    is still written in the caption track's own voice, so an id that slips
1023    through reads as a sound rather than as an instruction.
1024    """
1025    caption = CUE_SUBTITLES.get(cue)
1026    return caption if caption is not None else str(cue).replace("_", " ").upper()
1027
1028
1029def _caption_base(line: str) -> str:
1030    """*line* without its side suffix, for de-duplicating one sound's captions."""
1031    for direction in CAPTION_DIRECTIONS:
1032        suffix = CAPTION_DIRECTION_FORMAT.format(caption="", direction=direction)
1033        if line.endswith(suffix):
1034            return line[: -len(suffix)]
1035    return line
1036
1037
1038# ============================================================================
1039# Geometry helpers
1040# ============================================================================
1041
1042#: ``element_id -> (centre_degrees, span_degrees, colour, caption, compass)``.
1043#: Screen angles run clockwise from +X because screen Y points down, so 90 is
1044#: below the ship. *compass* is the fixed point the caption is pinned to, which
1045#: is always the arc's own bearing: the caption is placed outside every ring
1046#: there, not on the arc, so four coloured strokes read as four named gauges
1047#: rather than as four strokes with words written through them.
1048_ARC_SPECS: tuple[tuple[str, float, float, tuple[float, float, float, float], str, str], ...] = (
1049    (HudElements.CAPACITOR, 90.0, 84.0, COLOUR_CAPACITOR, "PWR", "bottom"),
1050    (HudElements.HULL, 270.0, 84.0, COLOUR_HULL, "HULL", "top"),
1051    (HudElements.O2, 180.0, 58.0, COLOUR_O2, "O2", "left"),
1052    (HudElements.FUEL, 0.0, 58.0, COLOUR_FUEL, "FUEL", "right"),
1053)
1054
1055#: Which gauges write their number beside their caption. Hull and capacitor have
1056#: no cell in the resource strip, and a bar with no number is a reading nobody
1057#: can quote back: "how much hull have I got" would have no answer anywhere on
1058#: the screen.
1059_ARC_VALUE_ELEMENTS: frozenset[str] = frozenset({HudElements.HULL, HudElements.CAPACITOR})
1060
1061_EDGES = ("left", "right", "top", "bottom")
1062
1063#: Every signal the HUD listens for, mapped to the method that handles it.
1064#: Discovery stops rescanning once each of these has an emitter.
1065_SIGNAL_HANDLERS: dict[str, str] = {
1066    SignalNames.HULL_CHANGED: "_on_hull_changed",
1067    SignalNames.BREACH_OPENED: "_on_breach_opened",
1068    SignalNames.BREACH_PATCHED: "_on_breach_patched",
1069    SignalNames.CAPACITOR_CHANGED: "_on_capacitor_changed",
1070    SignalNames.ENERGY_DENIED: "_on_energy_denied",
1071    SignalNames.O2_CHANGED: "_on_o2_changed",
1072    SignalNames.FUEL_CHANGED: "_on_fuel_changed",
1073    SignalNames.GENERATOR_CHANGED: "_on_generator_changed",
1074    SignalNames.SILENT_RUNNING_CHANGED: "_on_silent_running_changed",
1075    SignalNames.SOLAR_STATE_CHANGED: "_on_solar_state_changed",
1076    SignalNames.AFTERBURNER_CHANGED: "_on_afterburner_changed",
1077    SignalNames.SIGNATURE_CHANGED: "_on_signature_changed",
1078    SignalNames.SIGNATURE_LOCKED: "_on_signature_locked",
1079    SignalNames.AMMO_CHANGED: "_on_ammo_changed",
1080    SignalNames.SCRAP_CHANGED: "_on_scrap_changed",
1081    SignalNames.NOTORIETY_CHANGED: "_on_notoriety_changed",
1082    SignalNames.PLAYER_DAMAGED: "_on_player_damaged",
1083    SignalNames.ENEMY_KILLED: "_on_enemy_killed",
1084    SignalNames.WAVE_SPAWNED: "_on_wave_spawned",
1085    SignalNames.HUNTER_TELEGRAPH: "_on_hunter_telegraph",
1086    SignalNames.WARP_SPOOL_STARTED: "_on_warp_spool_started",
1087    SignalNames.WARP_SPOOL_INTERRUPTED: "_on_warp_spool_interrupted",
1088    SignalNames.WARP_SPOOL_CANCELLED: "_on_warp_spool_cancelled",
1089    SignalNames.WARP_COMPLETED: "_on_warp_completed",
1090    # The audio director's own announcement of every cue it plays, however the
1091    # play was reached. It is what keeps a cue played directly by a system
1092    # (the hunter's burn klaxon) on the caption track.
1093    SignalNames.AUDIO_CUE: "_on_audio_cue",
1094}
1095
1096_WANTED_SIGNALS: frozenset[str] = frozenset(_SIGNAL_HANDLERS)
1097
1098
1099class CardLayout(NamedTuple):
1100    """Where a top-band panel is drawn, and how much of it survived the fit."""
1101
1102    x: float
1103    y: float
1104    width: float
1105    height: float
1106    padding: float
1107    line: float
1108    font_scale: float
1109    second_line: float
1110    second_font_scale: float
1111    #: Height reserved under the text for the drawn hold bar, 0 when there is none.
1112    bar: float = 0.0
1113
1114    @property
1115    def bottom(self) -> float:
1116        return self.y + self.height
1117
1118    @property
1119    def shows_second_line(self) -> bool:
1120        return self.second_line > 0.0
1121
1122
1123def _clamp01(value: float) -> float:
1124    return 0.0 if value < 0.0 else 1.0 if value > 1.0 else value
1125
1126
1127def _fraction(current: float, maximum: float) -> float:
1128    return 0.0 if maximum <= 0.0 else _clamp01(current / maximum)
1129
1130
1131def _lerp_colour(a, b, t: float) -> tuple[float, float, float, float]:
1132    """Blend two RGBA tuples, landing exactly on an endpoint at 0 and 1."""
1133    if t <= 0.0:
1134        return tuple(a)
1135    if t >= 1.0:
1136        return tuple(b)
1137    return tuple(a[i] + (b[i] - a[i]) * t for i in range(4))
1138
1139
1140def _with_alpha(colour, alpha: float) -> tuple[float, float, float, float]:
1141    return (colour[0], colour[1], colour[2], colour[3] * _clamp01(alpha))
1142
1143
1144def _opaque(colour) -> tuple[float, float, float, float]:
1145    """*colour* at full alpha, for the elements that are read rather than felt."""
1146    return (colour[0], colour[1], colour[2], 1.0)
1147
1148
1149def _read_typed_bar(hint: str) -> tuple[float | None, str]:
1150    """Split a typed progress bar off *hint*, as ``(fraction, remaining_text)``.
1151
1152    A hint may report a hold as :data:`TYPED_BAR_OPEN` around a run of fill and
1153    empty glyphs. On the screen that is a line of punctuation in the middle of a
1154    tutorial panel, which reads as debug output; as a fraction it is exactly the
1155    bar the card should be drawing. Callers pass
1156    :meth:`Hud.show_control_card`'s ``progress`` argument instead, and this
1157    guard means a caller that does not still gets the drawn version and the
1158    typed form never reaches the glass.
1159    """
1160    start = hint.find(TYPED_BAR_OPEN)
1161    end = hint.find(TYPED_BAR_CLOSE, start + 1) if start >= 0 else -1
1162    if start < 0 or end < 0:
1163        return (None, hint)
1164    body = hint[start + 1 : end]
1165    if not body or set(body) - set(TYPED_BAR_GLYPHS):
1166        return (None, hint)
1167    filled = sum(1 for glyph in body if glyph == TYPED_BAR_FULL)
1168    remainder = f"{hint[:start]}{hint[end + 1 :]}".strip()
1169    return (filled / len(body), remainder)
1170
1171
1172def _credits_nothing(text: str) -> bool:
1173    """True for a label like ``"+0 SCRAP"``: a credit that rounds away to nothing.
1174
1175    Such a label is never drawn. It is not a small credit, it is a wrong one:
1176    the counter beside it climbs by the fraction the label rounded off, so the
1177    pilot is shown a zero and a number that moved, in the same breath.
1178    """
1179    head = str(text).split(maxsplit=1)
1180    if not head or not head[0].startswith("+"):
1181        return False
1182    try:
1183        return float(head[0][1:]) == 0.0
1184    except ValueError:
1185        return False
1186
1187
1188def _plate_width(text: str, font_scale: float, scale: float, padding_px: float, limit: float) -> float:
1189    """Width of a dark plate wide enough to hold *text*, capped at *limit*."""
1190    width = len(str(text)) * TEXT_EM_PX * GLYPH_ADVANCE_FRACTION * font_scale * scale
1191    return min(limit, width + 2.0 * padding_px * scale)
1192
1193
1194def _gpu_phase_rows() -> tuple[tuple[str, float], ...]:
1195    """The hottest GPU passes the running engine is publishing, worst first.
1196
1197    Read off ``App.last_telemetry``, which the windowed loop refreshes every
1198    frame. Empty on every backend that has no timestamp pools, on a headless
1199    run, and before the first frame has resolved a query.
1200    """
1201    try:
1202        from simvx.graphics import App
1203    except Exception:
1204        return ()
1205    app = App.current()
1206    telemetry = getattr(app, "last_telemetry", None) if app is not None else None
1207    phases = telemetry.get("gpu_phase_times") if telemetry else None
1208    if not phases:
1209        return ()
1210    rows = sorted(((str(name), float(value)) for name, value in phases.items()), key=lambda row: -row[1])
1211    return tuple(rows[:FRAME_STATS_GPU_ROWS])
1212
1213
1214def _draw_arc(
1215    renderer, cx: float, cy: float, radius: float, start_deg: float, sweep_deg: float, thickness: float, colour
1216) -> None:
1217    """Stroke an arc as a short polyline of thick segments."""
1218    if abs(sweep_deg) < 1e-3 or radius <= 0.0:
1219        return
1220    steps = max(2, int(abs(sweep_deg) / ARC_SEGMENT_DEGREES) + 1)
1221    step = math.radians(sweep_deg) / steps
1222    angle = math.radians(start_deg)
1223    px, py = cx + math.cos(angle) * radius, cy + math.sin(angle) * radius
1224    for _ in range(steps):
1225        angle += step
1226        nx, ny = cx + math.cos(angle) * radius, cy + math.sin(angle) * radius
1227        renderer.line((px, py), (nx, ny), stroke=colour, width=thickness)
1228        px, py = nx, ny
1229
1230
1231# ============================================================================
1232# Hud
1233# ============================================================================
1234
1235
1236class Hud(Control):
1237    """The run's heads-up display; registered as ``Services.HUD``.
1238
1239    Anchored full-rect and never positioned absolutely, so it survives a window
1240    resize and a UI scale change without relayout code. It emits nothing: every
1241    value it shows arrives on a signal it discovered for itself.
1242    """
1243
1244    dynamic = True
1245
1246    def __init__(self, **kwargs):
1247        super().__init__(**kwargs)
1248        # A full-screen HUD must never eat a click meant for the guns.
1249        self.pointer_filter = PointerFilter.IGNORE
1250
1251        # Accessibility: the player's UI-scale slider, multiplied by the scale
1252        # the window's own height asks for. Only the slider is settable.
1253        self._settings_scale: float = 1.0
1254        self._screen_scale: float = 1.0
1255
1256        self._visible_elements: frozenset[str] = ALL_HUD_ELEMENTS
1257
1258        self._gauges: dict[str, float] = {
1259            HudElements.HULL: 1.0,
1260            HudElements.CAPACITOR: 1.0,
1261            HudElements.O2: 1.0,
1262            HudElements.FUEL: 1.0,
1263        }
1264        #: Absolute readings behind the arcs. The arcs carry the fraction and
1265        #: the strip carries the number, and both come off the one signal.
1266        self._levels: dict[str, float] = {
1267            HudElements.HULL: 0.0,
1268            HudElements.CAPACITOR: 0.0,
1269            HudElements.O2: 0.0,
1270            HudElements.FUEL: 0.0,
1271            HudElements.SCRAP: 0.0,
1272        }
1273        self._signature = 0.0
1274        #: Whether the meter has locked and the arrival it bought is still to
1275        #: come. Cleared by the tear-in and by leaving the sector, which are
1276        #: the two things that make the instruction stale.
1277        self._signature_locked = False
1278        self._notoriety = 0
1279        self._open_breaches = 0
1280        self._generator_running = False
1281        self._silent_running = False
1282        self._wings_state = ""
1283        self._afterburner = False
1284        self._ammo: dict[str, int] = {}
1285
1286        self._sector_name = ""
1287        self._arrival_name = ""
1288        self._arrival_flavour = ""
1289        self._arrival_remaining = 0.0
1290
1291        self._warp_active = False
1292        self._warp_elapsed = 0.0
1293        self._warp_total = balance.WARP_SPOOL_S
1294        self._warp_stutter = 0.0
1295        self._warp_target = ""
1296        self._warp_fuel = 0.0
1297        self._warp_price_text = ""
1298        self._warp_interrupt_text = ""
1299        self._warp_interrupt_remaining = 0.0
1300
1301        self._edge_damage: dict[str, float] = dict.fromkeys(_EDGES, 0.0)
1302
1303        #: The hull fraction before the last blow, while the lost chunk is
1304        #: still draining off the arc. Equal to the gauge once it has settled.
1305        self._hull_chip = 1.0
1306        #: Seconds the chunk still holds at full length, and the fraction of
1307        #: the ring per second it gives back once the hold runs out. The rate
1308        #: is set per blow so the drain takes HULL_CHIP_DECAY_S whatever the
1309        #: blow cost.
1310        self._hull_chip_hold = 0.0
1311        self._hull_chip_rate = 0.0
1312
1313        #: Seconds of air left at the drain rate reported with the last reading.
1314        self._o2_seconds = float("inf")
1315        #: Ordinary jumps the tank still covers, at the base fare. Infinite
1316        #: until a reading lands, so a HUD nobody has told is never "low".
1317        self._fuel_jumps = float("inf")
1318        self._low_hull = False
1319        self._low_hull_klaxon_in = 0.0
1320
1321        self._beacon_position: Vec3 | None = None
1322        self._beacon_label = ""
1323
1324        self._boundary_notice = ""
1325        self._boundary_remaining = 0.0
1326        self._objective = ""
1327        self._compass: tuple[tuple[Vec3, str], ...] = ()
1328        self._compass_remaining = 0.0
1329
1330        self._warp_outcome_text = ""
1331        self._warp_outcome_remaining = 0.0
1332        self._warp_outcome_fraction = 0.0
1333        self._warp_jump_flash = 0.0
1334
1335        self._subtitles: list[list] = []  # [caption, remaining_seconds]
1336        self._toast_text = ""
1337        self._toast_remaining = 0.0
1338        #: The second clause of the answer line and how long a later answer may
1339        #: still join it. See :meth:`set_toast_note`.
1340        self._toast_note = ""
1341        self._toast_note_join = 0.0
1342        #: Rows waypoint labels have already taken this frame, as
1343        #: ``(row, left, right)``. Shared by the beacon, the compass and the
1344        #: Shrike countdown, so no one of them writes "DEPOT 8 u" straight
1345        #: through another's "VEIN 7 u".
1346        self._marker_rows: list[tuple[float, float, float]] = []
1347        #: [text, remaining, total, colour, node_ref, position, toward]
1348        self._floats: list[list] = []
1349        #: Sub-unit credits waiting for a whole one, per source. See
1350        #: :meth:`credit_float`.
1351        self._credit_banks: dict[str, float] = {}
1352        self._stamp_text = ""
1353        self._stamp_remaining = 0.0
1354
1355        self._card_text = ""
1356        self._card_hint = ""
1357        self._card_verb = ""
1358        self._card_progress: float | None = None
1359
1360        self._interact_fraction = 0.0
1361        self._interact_remaining = 0.0
1362
1363        self._affordance_text = ""
1364        self._affordance_remaining = 0.0
1365
1366        self._controls_bar_text = CONTROLS_BAR_TEXT
1367        self._controls_bar_second_row = CONTROLS_BAR_SECOND_ROW
1368        self._controls_bar_visible = True
1369
1370        self._warp_status = ""
1371        self._warp_status_blocked = False
1372
1373        self._elapsed = 0.0
1374        self._rescan_in = 0.0
1375        self._ammo_poll_in = AMMO_POLL_S
1376        # Which signals each emitter is already wired for. Keyed weakly and by
1377        # the node itself rather than by ``id()``: enemies and hunters are
1378        # freed mid-run, and a recycled address would otherwise look like an
1379        # emitter the HUD had already connected to.
1380        self._connected: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
1381        self._connected_names: set[str] = set()
1382        self._ship = None
1383        self._rig: CameraRig | None = None
1384        #: The developer frame readout: off until :data:`DEBUG_FRAME_STATS_ACTION`,
1385        #: then a rolling window of wall-clock frame times and whatever per-pass
1386        #: GPU timings the driver is publishing.
1387        self._frame_stats = False
1388        self._frame_times: list[float] = []
1389        self._gpu_phases: tuple[tuple[str, float], ...] = ()
1390        #: The inbound hunter, while one is inbound. Looked up from the hunter
1391        #: group rather than injected, and only while the meter is locked, so a
1392        #: run with no hunter in it pays nothing for the lookup.
1393        self._hunter = None
1394
1395    # ------------------------------------------------------------------ setup
1396
1397    def on_ready(self):
1398        self.anchor = Anchor.FULL_RECT
1399        self._refresh_screen_scale()
1400        tree = self.tree
1401        if tree is not None:
1402            tree.screen_resized.connect(self._on_screen_resized)
1403        self._connect_sources()
1404
1405    def on_exit_tree(self):
1406        tree = self.tree
1407        if tree is not None:
1408            tree.screen_resized.disconnect(self._on_screen_resized)
1409
1410    # --------------------------------------------------------------- scaling
1411
1412    @property
1413    def ui_scale(self) -> float:
1414        """The player's UI-scale slider. The window's height scales on top."""
1415        return self._settings_scale
1416
1417    @ui_scale.setter
1418    def ui_scale(self, value: float) -> None:
1419        self._settings_scale = max(0.0, float(value))
1420
1421    @property
1422    def screen_scale(self) -> float:
1423        """The height-derived half of the scale, from :func:`screen_scale_for`."""
1424        return self._screen_scale
1425
1426    @property
1427    def effective_scale(self) -> float:
1428        """What the HUD actually draws at: window height times the slider."""
1429        return self._screen_scale * self._settings_scale
1430
1431    def _on_screen_resized(self, size) -> None:
1432        del size
1433        self._refresh_screen_scale()
1434
1435    def _refresh_screen_scale(self) -> None:
1436        """Re-derive the height half of the UI scale from the live window."""
1437        tree = self.tree
1438        if tree is None:
1439            return
1440        self._screen_scale = screen_scale_for(float(tree.screen_size[1]))
1441
1442    # ------------------------------------------------------- public interface
1443
1444    def flash_stamp(self, text: str) -> None:
1445        """Stamp *text* across the centre of the screen for a beat."""
1446        self._stamp_text = str(text)
1447        self._stamp_remaining = STAMP_HOLD_S + STAMP_FADE_S
1448
1449    def show_interact_fill(self, fraction: float) -> None:
1450        """Draw the interact hold as a radial fill on the ship.
1451
1452        Push this every frame the button is held; it clears shortly after the
1453        pushes stop. A *fraction* of zero or less clears it at once.
1454        """
1455        fraction = float(fraction)
1456        if fraction <= 0.0:
1457            self._interact_fraction = 0.0
1458            self._interact_remaining = 0.0
1459            return
1460        self._interact_fraction = _clamp01(fraction)
1461        self._interact_remaining = INTERACT_FILL_HOLD_S
1462
1463    def set_affordance(self, text: str) -> None:
1464        """Name the verb currently in reach, or clear the line with ``""``.
1465
1466        Push this every frame something is in range; it clears itself
1467        :data:`AFFORDANCE_HOLD_S` after the pushes stop, so a resolver that
1468        simply stops calling does not leave a stale prompt over empty space.
1469        The line is one short imperative naming the key and the verb, and only
1470        ever one: two prompts at once is a menu, and this is a reticle.
1471        """
1472        text = str(text)
1473        if not text:
1474            self._affordance_text = ""
1475            self._affordance_remaining = 0.0
1476            return
1477        self._affordance_text = text
1478        self._affordance_remaining = AFFORDANCE_HOLD_S
1479
1480    @property
1481    def affordance(self) -> str:
1482        """The verb line currently up, empty when nothing is in reach."""
1483        return self._affordance_text if self._affordance_remaining > 0.0 else ""
1484
1485    def set_controls_bar(self, text: str, second_row: str | None = None) -> None:
1486        """Replace the persistent controls strip along the bottom edge.
1487
1488        *second_row* replaces the strip's lower row; left out, the row already
1489        there is kept, so a caller rewriting the primary verbs does not have to
1490        restate the whole keyboard to hold on to the rest of it.
1491        """
1492        self._controls_bar_text = str(text)
1493        if second_row is not None:
1494            self._controls_bar_second_row = str(second_row)
1495
1496    @property
1497    def controls_bar(self) -> str:
1498        """The strip's first row as it is drawn, empty while it is hidden."""
1499        return self._controls_bar_text if self._controls_bar_visible else ""
1500
1501    @property
1502    def controls_bar_second_row(self) -> str:
1503        """The strip's second row as it is drawn, empty while it is hidden."""
1504        return self._controls_bar_second_row if self._controls_bar_visible else ""
1505
1506    @property
1507    def controls_bar_visible(self) -> bool:
1508        """Whether the bar is showing. On by default, for every run."""
1509        return self._controls_bar_visible
1510
1511    @controls_bar_visible.setter
1512    def controls_bar_visible(self, value: bool) -> None:
1513        self._controls_bar_visible = bool(value)
1514
1515    def toggle_controls_bar(self) -> bool:
1516        """Flip the bar and return its new state."""
1517        self._controls_bar_visible = not self._controls_bar_visible
1518        return self._controls_bar_visible
1519
1520    # ------------------------------------------------- the developer readout
1521
1522    def toggle_frame_stats(self) -> bool:
1523        """Flip the developer frame readout and return its new state."""
1524        self._frame_stats = not self._frame_stats
1525        if not self._frame_stats:
1526            self._frame_times.clear()
1527            self._gpu_phases = ()
1528        return self._frame_stats
1529
1530    @property
1531    def frame_stats_visible(self) -> bool:
1532        return self._frame_stats
1533
1534    def frame_stats_lines(self) -> tuple[str, ...]:
1535        """What the readout is saying: the CPU window, then the hottest passes.
1536
1537        A tuple rather than a drawing, so a test can assert on the numbers and
1538        so the same lines can be printed to a terminal from a headless probe.
1539        """
1540        if not self._frame_times:
1541            return ()
1542        mean = sum(self._frame_times) / len(self._frame_times)
1543        worst = max(self._frame_times)
1544        fps = 1.0 / mean if mean > 0.0 else 0.0
1545        lines = [
1546            f"FRAME {mean * 1000.0:6.2f} ms  ({fps:5.1f} fps)",
1547            f"WORST {worst * 1000.0:6.2f} ms  over {len(self._frame_times)} frames",
1548        ]
1549        if self._gpu_phases:
1550            lines.append("GPU PASSES (ms)")
1551            lines.extend(f"  {name[:22]:<22} {value:6.3f}" for name, value in self._gpu_phases)
1552        else:
1553            lines.append("GPU PASSES: no timestamp pool")
1554        return tuple(lines)
1555
1556    def _tick_frame_stats(self, dt: float) -> None:
1557        """Sample the frame clock, and the driver's pass timings where they exist.
1558
1559        Every access in :func:`_gpu_phase_rows` is guarded, so a backend with no
1560        timestamp pool prints nothing rather than failing.
1561        """
1562        if Input.just_pressed(DEBUG_FRAME_STATS_ACTION):
1563            self.toggle_frame_stats()
1564        if not self._frame_stats:
1565            return
1566        if dt > 0.0:
1567            self._frame_times.append(float(dt))
1568            if len(self._frame_times) > FRAME_STATS_WINDOW:
1569                del self._frame_times[: len(self._frame_times) - FRAME_STATS_WINDOW]
1570        self._gpu_phases = _gpu_phase_rows()
1571
1572    def set_warp_status(self, text: str, *, blocked: bool = False) -> None:
1573        """Say whether the drive can leave, and at what price.
1574
1575        Drawn where the spool ring will close, and only while no spool is
1576        running: once the ring is up it is answering the same question itself.
1577        *blocked* colours the line as a refusal ("not enough fuel") rather than
1578        as an offer.
1579        """
1580        self._warp_status = str(text)
1581        self._warp_status_blocked = bool(blocked)
1582
1583    @property
1584    def warp_status(self) -> str:
1585        """The warp chip's line, empty when the drive has nothing to say."""
1586        return self._warp_status
1587
1588    def show_subtitle(self, cue: str, *, direction: Vec3 | None = None) -> None:
1589        """Caption an audio *cue* by its id, through :func:`caption_for`.
1590
1591        Pass *direction* (the bearing from the ship to the sound) for a spatial
1592        cue and the caption names the side it came from, the way a caption track
1593        writes an off-screen sound.
1594        """
1595        line = caption_for(cue)
1596        if direction is not None:
1597            line = CAPTION_DIRECTION_FORMAT.format(
1598                caption=line, direction=CAPTION_DIRECTIONS[self._edge_index(direction)]
1599            )
1600        self._push_subtitle(line)
1601
1602    def _push_subtitle(self, line: str) -> None:
1603        """Add *line* to the caption stack, one line per sound.
1604
1605        A cue often reaches the track twice, once from the signal the HUD
1606        consumes itself and once from the audio director's announcement, and
1607        the two spellings may differ only by a side suffix. Lines are
1608        de-duplicated on the caption without its suffix, keeping whichever
1609        spelling names a side, so one klaxon is always one line.
1610        """
1611        base = _caption_base(line)
1612        for entry in self._subtitles:
1613            if _caption_base(entry[0]) == base:
1614                if len(line) > len(entry[0]):
1615                    entry[0] = line
1616                entry[1] = SUBTITLE_HOLD_S
1617                return
1618        self._subtitles.append([line, SUBTITLE_HOLD_S])
1619        del self._subtitles[:-SUBTITLE_MAX_LINES]
1620
1621    def show_toast(self, text: str) -> None:
1622        """Put one short line up, replacing whichever toast was showing.
1623
1624        The toast is the answer to something the player just did: a key that
1625        needs a destination first, a depot one jump out. It sits above the
1626        caption stack in its own type so a combat caption cannot bury it, and
1627        it goes away on its own after :data:`TOAST_HOLD_S`.
1628
1629        A note set within :data:`TOAST_NOTE_JOIN_S` survives the replacement and
1630        is folded into the new line, because a note and a toast that arrive in
1631        the same frame are two halves of one answer to one key press.
1632        """
1633        if self._toast_note_join <= 0.0:
1634            self._toast_note = ""
1635        self._toast_text = str(text)
1636        self._toast_remaining = TOAST_HOLD_S + TOAST_FADE_S
1637
1638    def set_toast_note(self, text: str) -> None:
1639        """Add a second clause to the answer line instead of replacing it.
1640
1641        The tutorial's "what the script wants first" and the run's own refusal
1642        are both answers to the same press, and they are raised by different
1643        nodes in an order neither of them controls. If whichever spoke last were
1644        the whole line, a first-run pilot who pressed the generator key would
1645        read ``FIRST: FLY: W A S D`` and never learn there was no generator
1646        fitted. A note joins whatever the line says, in either order, and stands
1647        alone when nothing else answered.
1648        """
1649        text = str(text).strip()
1650        if not text:
1651            return
1652        self._toast_note = text
1653        self._toast_note_join = TOAST_NOTE_JOIN_S
1654        if self._toast_remaining <= 0.0:
1655            self._toast_text = text
1656            self._toast_remaining = TOAST_HOLD_S + TOAST_FADE_S
1657
1658    @property
1659    def toast(self) -> str:
1660        """The toast currently up, note folded in, empty when none is."""
1661        if self._toast_remaining <= 0.0:
1662            return ""
1663        note = self._toast_note
1664        if not note or note == self._toast_text or note in self._toast_text:
1665            return self._toast_text
1666        return TOAST_WITH_NOTE.format(text=self._toast_text, note=note)
1667
1668    def float_text(
1669        self,
1670        text: str,
1671        *,
1672        position: Vec3 | None = None,
1673        node: Node | None = None,
1674        hold_s: float = FLOAT_TEXT_HOLD_S,
1675        colour=COLOUR_FLOAT_TEXT,
1676        toward: str | None = None,
1677        plate: bool = False,
1678    ) -> None:
1679        """Float *text* up from a point in the world and fade it out.
1680
1681        Anchor it to a *node* to have the label follow something that moves
1682        (an enemy wearing its first-sighting tag), or to a fixed *position*
1683        for something that does not (a pickup's value at the point it was
1684        scooped). A label with neither anchor is dropped rather than drawn in
1685        a corner: world text that is not in the world says nothing.
1686
1687        A label whose credit rounds to zero is dropped rather than drawn (see
1688        :func:`_credits_nothing`); use :meth:`credit_float` for anything paid in
1689        fractions, which is every beam and every vent.
1690
1691        *toward* names a counter from :data:`FLOAT_TARGETS` and makes the label
1692        fly to it instead of drifting: a credit the pilot can watch land is the
1693        whole of how a corner readout becomes a consequence. Left as ``None``
1694        it is inferred from the label's own words, so ``"+12 SCRAP"`` finds the
1695        scrap counter without the caller having to say so twice; pass ``""`` to
1696        turn the flight off for a label that credits nothing.
1697
1698        Set *plate* for a label that has to survive its own effect. Unbacked
1699        text is fine over the near-black arena and useless inside a flare: the
1700        Shrike's burn writes its price into the middle of the white cone that
1701        has just delivered it, and the caption plate is the one treatment on
1702        this HUD that reads through a flood of light.
1703        """
1704        if node is None and position is None:
1705            return
1706        if _credits_nothing(text):
1707            return
1708        reference = weakref.ref(node) if node is not None else None
1709        anchor = Vec3(position) if position is not None else None
1710        hold = max(0.05, float(hold_s))
1711        target = self._infer_float_target(text) if toward is None else str(toward)
1712        self._floats.append([str(text), hold, hold, tuple(colour), reference, anchor, target, bool(plate)])
1713        del self._floats[:-FLOAT_TEXT_MAX]
1714
1715    def credit_float(
1716        self,
1717        source: str,
1718        amount: float,
1719        label: str,
1720        *,
1721        node: Node | None = None,
1722        position: Vec3 | None = None,
1723        colour=COLOUR_FLOAT_TEXT,
1724        toward: str | None = None,
1725    ) -> bool:
1726        """Bank *amount* against *source* and float a label per whole unit earned.
1727
1728        A mining beam pays a fraction of a unit per frame and a vent pays for as
1729        long as the ship sits in it, so a label per tick is a column of
1730        ``+0 SCRAP`` beside a counter that is visibly climbing: the number is
1731        right and the label calls it a lie. Credits bank per source and
1732        only the whole part is ever written, so the labels sum to exactly what
1733        the counter took and not one of them says nothing.
1734
1735        The remainder is carried, never dropped, which is what makes ten ticks
1736        of a tenth of a unit one honest ``+1``. Returns True when a label went up.
1737        """
1738        bank = self._credit_banks.get(source, 0.0) + max(0.0, float(amount))
1739        whole = math.floor(bank)
1740        self._credit_banks[source] = bank - whole
1741        if whole < 1.0:
1742            return False
1743        self.float_text(
1744            CREDIT_FLOAT_FORMAT.format(amount=whole, label=label),
1745            node=node,
1746            position=position,
1747            colour=colour,
1748            toward=toward,
1749        )
1750        return True
1751
1752    def credit_bank(self, source: str) -> float:
1753        """The fraction of a unit *source* is carrying toward its next label."""
1754        return self._credit_banks.get(source, 0.0)
1755
1756    @staticmethod
1757    def _infer_float_target(text: str) -> str:
1758        """The counter a label credits, read off the label, or ``""``."""
1759        for word in str(text).lower().replace(",", " ").split():
1760            if word in FLOAT_TARGETS:
1761                return word
1762        return ""
1763
1764    @property
1765    def float_texts(self) -> tuple[str, ...]:
1766        """The world-anchored labels currently alive, oldest first."""
1767        return tuple(entry[0] for entry in self._floats)
1768
1769    def set_depot_beacon(self, position: Vec3 | None, label: str = "DEPOT") -> None:
1770        """Mark *position* with an edge beacon and a live range, or clear it.
1771
1772        The sector's depot is the only thing a scrap hold can be spent on, so
1773        it gets the one persistent world marker the HUD draws. Pass ``None``
1774        for a sector that has no depot.
1775        """
1776        self._beacon_position = Vec3(position) if position is not None else None
1777        self._beacon_label = str(label)
1778
1779    @property
1780    def depot_beacon(self) -> Vec3 | None:
1781        """Where the beacon is pointing, or None when there is nothing to mark."""
1782        return self._beacon_position
1783
1784    @property
1785    def beacon_range(self) -> float:
1786        """Plane distance from the hull to the beacon, ``inf`` when there is none."""
1787        target = self._beacon_position
1788        ship = self._ship
1789        if target is None or ship is None:
1790            return float("inf")
1791        offset = Vec3(target) - Vec3(ship.world_position)
1792        return math.hypot(float(offset.x), float(offset.z))
1793
1794    @property
1795    def beacon_in_range(self) -> bool:
1796        """Whether the hull is inside the bay the beacon marks.
1797
1798        Readable state, because it is what the ring's own look is driven off:
1799        a marker that changes when the ship crosses a line the pilot cannot see
1800        is the only thing on the screen that says the line is there.
1801        """
1802        return self.beacon_range <= DOCK_PROMPT_RANGE_UNITS
1803
1804    @property
1805    def dock_prompt(self) -> str:
1806        """The verb written on the ring, or ``""`` when the hull is not in the bay.
1807
1808        The run's own affordance whenever it is talking about the dock, so
1809        "DOCKED: hold F to sell" reaches the ring the same way "F: DOCK" does,
1810        and the fixed prompt otherwise: a pilot inside a bay that says nothing
1811        is a pilot who flies back out of it.
1812        """
1813        if not self.beacon_in_range:
1814            return ""
1815        affordance = self.affordance
1816        if affordance and DOCK_PROMPT_KEYWORD in affordance.upper():
1817            return affordance
1818        return DOCK_PROMPT
1819
1820    def set_boundary_notice(self, text: str) -> None:
1821        """Say that the ship is outside the sector, for as long as it is.
1822
1823        Pushed every frame by whoever owns the boundary and cleared
1824        :data:`BOUNDARY_NOTICE_HOLD_S` after the pushes stop, so turning back
1825        takes the line down without anyone having to remember to.
1826        """
1827        text = str(text)
1828        if not text:
1829            self._boundary_notice = ""
1830            self._boundary_remaining = 0.0
1831            return
1832        self._boundary_notice = text
1833        self._boundary_remaining = BOUNDARY_NOTICE_HOLD_S
1834
1835    @property
1836    def boundary_notice(self) -> str:
1837        """The standing boundary line, empty when the ship is inside."""
1838        return self._boundary_notice if self._boundary_remaining > 0.0 else ""
1839
1840    def set_objective(self, text: str) -> None:
1841        """Set the standing line saying what the run wants of this sector.
1842
1843        A standing line, not a toast: it stays up until the next call replaces
1844        it or ``""`` clears it, because "what am I here for" is a question a
1845        pilot asks minutes after the answer was first given. It shares the
1846        objective band with the lock's instruction, which outranks it: once the
1847        Shrike is inbound there is only one objective.
1848        """
1849        self._objective = str(text)
1850
1851    @property
1852    def objective(self) -> str:
1853        """The standing objective line, empty when nothing has been asked."""
1854        return self._objective
1855
1856    def set_compass_targets(self, targets) -> None:
1857        """Offer the screen-edge compass the things worth flying to.
1858
1859        *targets* is a sequence of ``(world_position, kind)`` with kinds from
1860        :data:`COMPASS_LABELS`. The HUD decides whether to draw them, because
1861        only it knows what is on screen: markers appear exactly when none of
1862        them is visible, which is the moment the sector looks empty.
1863        """
1864        self._compass = tuple((Vec3(position), str(kind)) for position, kind in targets)
1865        self._compass_remaining = COMPASS_HOLD_S if self._compass else 0.0
1866
1867    @property
1868    def compass_targets(self) -> tuple[tuple[Vec3, str], ...]:
1869        """The targets the compass is currently holding, empty when none is."""
1870        return self._compass if self._compass_remaining > 0.0 else ()
1871
1872    def set_warp_target(self, name: str, fuel: float = 0.0, *, price_text: str = "") -> None:
1873        """Name the destination the running spool is buying, and its price.
1874
1875        The ring is a countdown either way; this is what turns it into a
1876        decision the pilot can read. Clearing the name (empty *name*) leaves
1877        the ring unlabelled, which is what an unattributed spool deserves.
1878
1879        *price_text* is the caller's own spelling of the price, for a caller
1880        that has already itemised it ("22 (15 +7 HUNTED)"). Without it the
1881        ring spells *fuel* as a bare number, which is right for a caller that
1882        only has one. The two must not disagree: a ring quoting a total beside
1883        a status chip quoting the itemisation reads as two separate charges,
1884        so anything that itemises anywhere itemises here too.
1885        """
1886        self._warp_target = str(name)
1887        self._warp_fuel = max(0.0, float(fuel))
1888        self._warp_price_text = str(price_text)
1889
1890    @property
1891    def warp_target(self) -> str:
1892        """The destination the spool is labelled with, empty when unlabelled."""
1893        return self._warp_target
1894
1895    @property
1896    def warp_price(self) -> str:
1897        """The price the ring is quoting, as the ring spells it.
1898
1899        Empty while the spool has no destination or no price, so a caller can
1900        tell "free" from "unpriced" without re-deriving either.
1901        """
1902        if not self._warp_target:
1903            return ""
1904        if self._warp_price_text:
1905            return self._warp_price_text
1906        return f"{self._warp_fuel:.0f}" if self._warp_fuel > 0.0 else ""
1907
1908    def fail_warp_spool(self, reason: str) -> None:
1909        """Say why a channel could not finish, on the ring and on the toast.
1910
1911        The ship calls this after standing the drive down, so the red flash
1912        already up gains the reason that caused it. An abort the pilot asked
1913        for keeps the plain :data:`WARP_ABORTED_LABEL`; this is for the two
1914        endings they did not choose.
1915        """
1916        reason = str(reason)
1917        if not reason:
1918            return
1919        self._warp_outcome_text = reason
1920        self._warp_outcome_remaining = WARP_OUTCOME_FLASH_S
1921        self.show_toast(reason)
1922
1923    @property
1924    def warp_outcome_text(self) -> str:
1925        """The ending currently flashing on the ring, empty between spools."""
1926        return self._warp_outcome_text if self._warp_outcome_remaining > 0.0 else ""
1927
1928    @property
1929    def warp_jump_flash(self) -> float:
1930        """Seconds of white-out left from the last jump, 0 when none is running."""
1931        return self._warp_jump_flash
1932
1933    def _flash_warp_outcome(self, text: str) -> None:
1934        """Hold the ring for a beat on *text*, at the radius it had reached."""
1935        self._warp_outcome_fraction = self.warp_spool_fraction
1936        self._warp_outcome_text = str(text)
1937        self._warp_outcome_remaining = WARP_OUTCOME_FLASH_S
1938
1939    def end_warp_spool(self) -> None:
1940        """Take the spool ring down for a channel that ended without a jump.
1941
1942        ``WARP_COMPLETED`` and ``WARP_SPOOL_CANCELLED`` both land here on their
1943        own; this is the direct entry for a refuser that stands the drive down
1944        before the ring has anything to answer to. It clears the ring and not
1945        the ending flashing on it: those are two different sentences, and the
1946        second one is the one the pilot needed.
1947        """
1948        self._warp_active = False
1949        self._warp_elapsed = 0.0
1950        self._warp_stutter = 0.0
1951        self._warp_target = ""
1952        self._warp_fuel = 0.0
1953        self._warp_price_text = ""
1954        self._warp_interrupt_remaining = 0.0
1955
1956    @property
1957    def low_hull(self) -> bool:
1958        """Whether the hull is under :data:`LOW_HULL_FRACTION`."""
1959        return self._gauges[HudElements.HULL] < LOW_HULL_FRACTION
1960
1961    @property
1962    def low_o2(self) -> bool:
1963        """Whether the air is under ``balance.O2_LOW_WARNING_SECONDS``."""
1964        return self._o2_seconds <= balance.O2_LOW_WARNING_SECONDS
1965
1966    @property
1967    def low_fuel(self) -> bool:
1968        """Whether the tank covers :data:`LOW_FUEL_JUMPS` ordinary jumps or fewer.
1969
1970        Counted in jumps rather than in units, the way the bay counts them:
1971        what decides this is how many more times the drive will fire, and a
1972        full tank on a short-ranged hull is still a short range. A HUD that
1973        has not been told the fuel yet is not low on it, so the count starts
1974        at infinity and the first ``FUEL_CHANGED`` writes the truth.
1975        """
1976        return self._fuel_jumps <= LOW_FUEL_JUMPS
1977
1978    def show_control_card(self, text: str, *, verb: str = "", hint: str = "", progress: float | None = None) -> None:
1979        """Put one control card up, replacing whichever card was showing.
1980
1981        *text* is the line that names the keys and *hint* an optional smaller
1982        line under it. *verb* is bookkeeping for the caller: it records what
1983        this card is waiting for, and the HUD hands it back on
1984        :attr:`control_card_verb` so a schedule can ask what is on screen
1985        without keeping a second copy of it. The card never expires on its own;
1986        it is a lesson, not a caption.
1987
1988        *progress* fills a drawn bar across the foot of the card, from 0 to 1,
1989        and ``None`` leaves the card without one. A hold that reports itself as
1990        ``[####....]`` in the hint tells the pilot the game is printing debug
1991        text at them; a bar is what a hold looks like. A hint that carries a
1992        typed bar is read for its fraction and stripped, so a caller that does
1993        not pass the argument still gets the drawn version.
1994        """
1995        hint = str(hint)
1996        if progress is None:
1997            progress, hint = _read_typed_bar(hint)
1998        self._card_text = str(text)
1999        self._card_hint = hint
2000        self._card_verb = str(verb)
2001        self._card_progress = None if progress is None else _clamp01(float(progress))
2002
2003    def clear_control_card(self) -> None:
2004        """Take the control card down."""
2005        self._card_text = ""
2006        self._card_hint = ""
2007        self._card_verb = ""
2008        self._card_progress = None
2009
2010    @property
2011    def control_card_progress(self) -> float | None:
2012        """The card's hold fraction, or None when the card has no bar."""
2013        return self._card_progress
2014
2015    @property
2016    def control_card(self) -> str:
2017        """The card currently up, empty when none is."""
2018        return self._card_text
2019
2020    @property
2021    def control_card_hint(self) -> str:
2022        """The card's second line, empty when it has none."""
2023        return self._card_hint
2024
2025    @property
2026    def control_card_verb(self) -> str:
2027        """What the card on screen is waiting for, empty when none is up."""
2028        return self._card_verb
2029
2030    # ------------------------------------------------------------ the sector
2031
2032    def reset_for_sector(self) -> None:
2033        """Wipe everything the last sector was still saying.
2034
2035        Every transient on the glass is timed rather than scoped, so without
2036        this a jump would carry the old sector's whole answer layer across:
2037        its stamp, its answer line, its captions, its floating credits, the
2038        edges reddened by a blow taken light-years away, a boundary notice
2039        for an edge that no longer exists, the ending flashed on a ring that
2040        has already fired, and a half-finished interact fill. None of them
2041        outlive the place they were raised in, so the arrival clears them all
2042        at once rather than each of them separately deciding it should.
2043
2044        Not cleared: :attr:`warp_jump_flash`, which is this jump's own
2045        white-out and the thing covering the camera cut, and the standing
2046        readings, which belong to the ship rather than to the sector. The
2047        caller announces the new sector *after* this, so the arrival card and
2048        the new objective are raised onto clean glass.
2049        """
2050        self._stamp_text = ""
2051        self._stamp_remaining = 0.0
2052        self._toast_text = ""
2053        self._toast_remaining = 0.0
2054        self._toast_note = ""
2055        self._toast_note_join = 0.0
2056        self._subtitles.clear()
2057        self._floats.clear()
2058        for edge in self._edge_damage:
2059            self._edge_damage[edge] = 0.0
2060        self._boundary_notice = ""
2061        self._boundary_remaining = 0.0
2062        self._warp_outcome_text = ""
2063        self._warp_outcome_remaining = 0.0
2064        self._warp_outcome_fraction = 0.0
2065        self._warp_interrupt_text = ""
2066        self._warp_interrupt_remaining = 0.0
2067        self._interact_fraction = 0.0
2068        self._interact_remaining = 0.0
2069        self.clear_control_card()
2070
2071    def set_sector_name(self, name: str) -> None:
2072        """Name the place the ship is in. Stands until the next sector."""
2073        self._sector_name = str(name)
2074
2075    @property
2076    def sector_name(self) -> str:
2077        """The standing sector name, empty when none has been set."""
2078        return self._sector_name
2079
2080    def show_arrival_card(self, name: str, flavour: str = "") -> None:
2081        """Announce an arrival: the name of the place and one line about it.
2082
2083        Held for :data:`ARRIVAL_CARD_HOLD_S` and then gone, which is why the
2084        name is also latched onto :meth:`set_sector_name`: a title card
2085        answers "where am I" once, and the corner answers it for the rest of
2086        the sector. Without a name, a jump lands the ship in a field of rocks
2087        indistinguishable from the last one, and nothing about a warp reads as
2088        travel.
2089        """
2090        self._arrival_name = str(name)
2091        self._arrival_flavour = str(flavour)
2092        self._arrival_remaining = ARRIVAL_CARD_HOLD_S if self._arrival_name else 0.0
2093        if self._arrival_name:
2094            self.set_sector_name(self._arrival_name)
2095
2096    @property
2097    def arrival_card(self) -> str:
2098        """The arrival card's headline while it is up, else empty."""
2099        return self._arrival_name if self._arrival_remaining > 0.0 else ""
2100
2101    @property
2102    def arrival_flavour(self) -> str:
2103        """The arrival card's second line while it is up, else empty."""
2104        return self._arrival_flavour if self._arrival_remaining > 0.0 else ""
2105
2106    # --------------------------------------------------------- ship readings
2107
2108    def resource_lines(self) -> tuple[str, ...]:
2109        """The resource strip's cells, in the order they are drawn.
2110
2111        One cell per resource the run can charge or credit and that this HUD
2112        is drawing. The pause overlay prints the same tuple, so the numbers
2113        the pilot reads mid-flight and the numbers on the paused panel can
2114        never be two different readings of the same tanks.
2115        """
2116        return tuple(text for text, _colour in self._resource_cells())
2117
2118    @property
2119    def ammo_total(self) -> int:
2120        """Rounds aboard: every magazine plus every stowed box, or 0 for none."""
2121        return sum(self._ammo.values())
2122
2123    def _resource_cells(self) -> list[tuple[str, tuple[float, float, float, float]]]:
2124        """The strip's cells as ``(text, colour)``, empty when it is subtracted."""
2125        if not self.is_element_visible(HudElements.RESOURCES):
2126            return []
2127        cells = [
2128            (f"{label} {self._levels[element]:.0f}", _opaque(colour))
2129            for element, label, colour in RESOURCE_CELLS
2130            if self.is_element_visible(element)
2131        ]
2132        if self._ammo and self.is_element_visible(HudElements.AMMO):
2133            cells.append((AMMO_CELL.format(rounds=self.ammo_total), _opaque(COLOUR_AMMO)))
2134        return cells
2135
2136    def state_pips(self) -> tuple[tuple[str, bool], ...]:
2137        """The power-state pips as ``(text, lit)``, in the order they are drawn.
2138
2139        Wings and generator always have a pip, lit or not: a key whose only
2140        output is a lamp that is off still answered the press. The afterburner
2141        pip is only there while it burns, because thrust is its own answer.
2142        """
2143        if not self.is_element_visible(HudElements.STATE_PIPS):
2144            return ()
2145        wings_out = self._wings_state in ("deployed", "deploying")
2146        running = self._generator_running
2147        burning = self._afterburner
2148        return (
2149            (PIP_WINGS[0] if wings_out else PIP_WINGS[1], wings_out),
2150            (PIP_GENERATOR[0] if running else PIP_GENERATOR[1], running),
2151            (PIP_AFTERBURNER[0] if burning else PIP_AFTERBURNER[1], burning),
2152        )
2153
2154    @property
2155    def shield_bearing(self) -> float | None:
2156        """Screen angle of the shield arc's centre in degrees, or None.
2157
2158        Screen angles run clockwise from +X because screen Y points down, and
2159        the flight plane maps straight onto the screen axes, so a world heading
2160        is its own screen angle negated.
2161        """
2162        shield = getattr(self._ship, "shield", None)
2163        if shield is None or not self.is_element_visible(HudElements.SHIELD):
2164            return None
2165        return -math.degrees(float(shield.absolute_centre()))
2166
2167    def set_visible_elements(self, elements) -> None:
2168        """Restrict the HUD to *elements* (ids from :class:`HudElements`).
2169
2170        Onboarding calls this once per run; passing :data:`ALL_HUD_ELEMENTS`
2171        restores the full display.
2172        """
2173        unknown = set(elements) - ALL_HUD_ELEMENTS
2174        if unknown:
2175            raise ValueError(f"unknown HUD element ids: {sorted(unknown)}")
2176        self._visible_elements = frozenset(elements)
2177
2178    def is_element_visible(self, element: str) -> bool:
2179        """Whether *element* is currently drawn."""
2180        return element in self._visible_elements
2181
2182    @property
2183    def visible_elements(self) -> frozenset[str]:
2184        """The element ids this HUD is currently drawing."""
2185        return self._visible_elements
2186
2187    def edge_desaturation(self) -> dict[str, float]:
2188        """Per-edge desaturation weights in 0..1, for the post-process hook.
2189
2190        Keys are ``"left"``, ``"right"``, ``"top"`` and ``"bottom"``. A hit
2191        raises the edge nearest its bearing and the value decays over
2192        :data:`DAMAGE_EDGE_DECAY_S`.
2193        """
2194        return dict(self._edge_damage)
2195
2196    # Read-only state, for tests and for anything that mirrors the HUD.
2197
2198    @property
2199    def hull_fraction(self) -> float:
2200        return self._gauges[HudElements.HULL]
2201
2202    @property
2203    def hull_chip(self) -> float:
2204        """Where the draining damage chunk still reaches on the hull arc.
2205
2206        Above :attr:`hull_fraction` while a just-lost chunk is animating off
2207        the ring, equal to it once the arc has settled.
2208        """
2209        return max(self._hull_chip, self._gauges[HudElements.HULL])
2210
2211    @property
2212    def capacitor_fraction(self) -> float:
2213        return self._gauges[HudElements.CAPACITOR]
2214
2215    @property
2216    def capacitor_blacked_out(self) -> bool:
2217        """True while a lantern blackout is holding the power bus at zero.
2218
2219        Read off the power system's own state, so the PWR arc greys for the
2220        held second rather than drawing a live gauge that happens to be empty.
2221        """
2222        power = self.tree.singletons.get(Services.POWER) if self.tree is not None else None
2223        return bool(getattr(power, "blacked_out", False))
2224
2225    @property
2226    def o2_fraction(self) -> float:
2227        return self._gauges[HudElements.O2]
2228
2229    @property
2230    def fuel_fraction(self) -> float:
2231        return self._gauges[HudElements.FUEL]
2232
2233    @property
2234    def signature_fraction(self) -> float:
2235        """Signature as a fraction of :data:`balance.SIGNATURE_MAX`."""
2236        return _fraction(self._signature, balance.SIGNATURE_MAX)
2237
2238    @property
2239    def signature_value(self) -> float:
2240        """The meter's own reading, in the units the gauge writes."""
2241        return self._signature
2242
2243    @property
2244    def signature_locked(self) -> bool:
2245        """Whether an arrival the meter bought is still inbound."""
2246        return self._signature_locked
2247
2248    @property
2249    def hunter_countdown(self) -> float:
2250        """Seconds until the tear-in, or 0 when nothing is inbound."""
2251        approach = self._hunter_approach()
2252        return approach[0] if approach is not None else 0.0
2253
2254    def _hunter_approach(self) -> tuple[float, float] | None:
2255        """The inbound hunter's ``(seconds, bearing)``, or None when none is.
2256
2257        The hunter does not exist until the meter locks and is freed when it
2258        leaves, so this refreshes the reference from the hunter group rather
2259        than holding one. It is only ever called while the lock is up.
2260        """
2261        hunter = self._hunter
2262        approach = getattr(hunter, "approach", None) if hunter is not None else None
2263        if approach is not None:
2264            return approach
2265        tree = self.tree
2266        if tree is None:
2267            return None
2268        for node in tree.group(Groups.HUNTER):
2269            approach = getattr(node, "approach", None)
2270            if approach is not None:
2271                self._hunter = node
2272                return approach
2273        self._hunter = None
2274        return None
2275
2276    @property
2277    def warp_spooling(self) -> bool:
2278        """Whether the ring is up, whether or not it has closed any distance."""
2279        return self._warp_active
2280
2281    @property
2282    def warp_spool_fraction(self) -> float:
2283        """How far the spool ring has closed, 0 when no spool is running."""
2284        if not self._warp_active:
2285            return 0.0
2286        return _fraction(self._warp_elapsed, self._warp_total)
2287
2288    @property
2289    def warp_stutter(self) -> float:
2290        """Decaying 0..1 stutter weight, raised by every spool interruption."""
2291        return self._warp_stutter
2292
2293    @property
2294    def subtitles(self) -> tuple[str, ...]:
2295        """The captions currently on screen, oldest first."""
2296        return tuple(entry[0] for entry in self._subtitles)
2297
2298    @property
2299    def stamp_text(self) -> str:
2300        """The stamp currently on screen, empty when none is."""
2301        return self._stamp_text if self._stamp_remaining > 0.0 else ""
2302
2303    @property
2304    def interact_fraction(self) -> float:
2305        """The interact hold fill, 0 when nothing is being held."""
2306        return self._interact_fraction
2307
2308    # ------------------------------------------------------------ per-frame
2309
2310    def on_update(self, dt: float):
2311        self._elapsed += dt
2312        self._tick_frame_stats(dt)
2313
2314        for entry in self._subtitles:
2315            entry[1] -= dt
2316        self._subtitles = [entry for entry in self._subtitles if entry[1] > 0.0]
2317
2318        for entry in self._floats:
2319            entry[1] -= dt
2320        self._floats = [entry for entry in self._floats if entry[1] > 0.0]
2321
2322        if self._toast_note_join > 0.0:
2323            self._toast_note_join = max(0.0, self._toast_note_join - dt)
2324        if self._toast_remaining > 0.0:
2325            self._toast_remaining = max(0.0, self._toast_remaining - dt)
2326            if self._toast_remaining <= 0.0:
2327                self._toast_note = ""
2328
2329        if self._stamp_remaining > 0.0:
2330            self._stamp_remaining = max(0.0, self._stamp_remaining - dt)
2331
2332        if self._arrival_remaining > 0.0:
2333            self._arrival_remaining = max(0.0, self._arrival_remaining - dt)
2334
2335        self._tick_low_hull(dt)
2336
2337        if self._interact_remaining > 0.0:
2338            self._interact_remaining -= dt
2339            if self._interact_remaining <= 0.0:
2340                self._interact_fraction = 0.0
2341
2342        if self._affordance_remaining > 0.0:
2343            self._affordance_remaining -= dt
2344            if self._affordance_remaining <= 0.0:
2345                self._affordance_text = ""
2346
2347        if self._boundary_remaining > 0.0:
2348            self._boundary_remaining -= dt
2349            if self._boundary_remaining <= 0.0:
2350                self._boundary_notice = ""
2351
2352        if self._compass_remaining > 0.0:
2353            self._compass_remaining -= dt
2354            if self._compass_remaining <= 0.0:
2355                self._compass = ()
2356
2357        if self._warp_outcome_remaining > 0.0:
2358            self._warp_outcome_remaining = max(0.0, self._warp_outcome_remaining - dt)
2359            if self._warp_outcome_remaining <= 0.0:
2360                self._warp_outcome_text = ""
2361
2362        if self._warp_jump_flash > 0.0:
2363            self._warp_jump_flash = max(0.0, self._warp_jump_flash - dt)
2364
2365        if self._warp_active:
2366            self._warp_elapsed += dt
2367        if self._warp_stutter > 0.0:
2368            self._warp_stutter = max(0.0, self._warp_stutter - dt / WARP_STUTTER_S)
2369        if self._warp_interrupt_remaining > 0.0:
2370            self._warp_interrupt_remaining = max(0.0, self._warp_interrupt_remaining - dt)
2371
2372        if any(self._edge_damage.values()):
2373            decay = dt / DAMAGE_EDGE_DECAY_S
2374            for edge in _EDGES:
2375                self._edge_damage[edge] = max(0.0, self._edge_damage[edge] - decay)
2376
2377        if self._hull_chip > self._gauges[HudElements.HULL]:
2378            if self._hull_chip_hold > 0.0:
2379                self._hull_chip_hold = max(0.0, self._hull_chip_hold - dt)
2380            else:
2381                rate = self._hull_chip_rate or (1.0 / HULL_CHIP_DECAY_S)
2382                self._hull_chip = max(self._gauges[HudElements.HULL], self._hull_chip - rate * dt)
2383
2384        if self._connected_names != _WANTED_SIGNALS:
2385            self._rescan_in -= dt
2386            if self._rescan_in <= 0.0:
2387                self._connect_sources()
2388
2389        # A refit changes what is aboard without firing a shot, so the
2390        # magazines are re-read on a slow poll rather than only on the signal.
2391        self._ammo_poll_in -= dt
2392        if self._ammo_poll_in <= 0.0:
2393            self._ammo_poll_in = AMMO_POLL_S
2394            self._poll_ammo()
2395
2396    def _tick_low_hull(self, dt: float) -> None:
2397        """Keep the klaxon coming back while the hull is in the red.
2398
2399        A single caption at the moment the hull crossed the line is a warning
2400        the player can miss under fire, so the low-hull state re-raises it
2401        every :data:`LOW_HULL_KLAXON_S` for as long as it holds. Climbing back
2402        out of the red arms it again, which is the only way a repair reads.
2403        """
2404        low = self.low_hull
2405        if not low:
2406            self._low_hull = False
2407            self._low_hull_klaxon_in = 0.0
2408            return
2409        if not self._low_hull:
2410            self._low_hull = True
2411            self._low_hull_klaxon_in = LOW_HULL_KLAXON_S
2412            self.show_subtitle("klaxon")
2413            return
2414        self._low_hull_klaxon_in -= dt
2415        if self._low_hull_klaxon_in <= 0.0:
2416            self._low_hull_klaxon_in = LOW_HULL_KLAXON_S
2417            self.show_subtitle("klaxon")
2418
2419    # ------------------------------------------------------------- discovery
2420
2421    def _scan_roots(self) -> list[Node]:
2422        tree = self.tree
2423        if tree is None:
2424            return []
2425        roots: list[Node] = [node for node in tree.singletons.values() if node is not self]
2426        for group in (Groups.SHIP, Groups.HUNTER, Groups.DEPOTS):
2427            roots.extend(tree.group(group))
2428        return roots
2429
2430    def _connect_sources(self) -> None:
2431        """Connect to every wanted signal that now has an emitter.
2432
2433        Modules mount in whatever order the run scene builds them, and the
2434        hunter does not exist until the signature locks, so this runs again
2435        every :data:`SOURCE_RESCAN_S` until nothing is missing.
2436        """
2437        self._rescan_in = SOURCE_RESCAN_S
2438        seen: set[int] = set()
2439        for root in self._scan_roots():
2440            self._connect_subtree(root, seen)
2441        ships = self.tree.group(Groups.SHIP) if self.tree is not None else []
2442        self._ship = ships[0] if ships else None
2443        if self._rig is None and self.tree is not None and self.tree.root is not None:
2444            self._rig = self.tree.root.find(CameraRig)
2445        self._seed_readings()
2446
2447    def _seed_readings(self) -> None:
2448        """Read the tanks and the hold once, rather than waiting for a change.
2449
2450        Every gauge here is fed by a change signal, and a full tank that has
2451        not changed yet emits nothing: unseeded, the strip would open every run
2452        reading ``FUEL 0`` beside a tank with a hundred in it until the first
2453        burn.
2454        """
2455        singletons = self.tree.singletons if self.tree is not None else {}
2456        power = singletons.get(Services.POWER)
2457        for current, maximum, handler in (
2458            ("o2", "o2_max", self._on_o2_changed),
2459            ("fuel", "fuel_max", self._on_fuel_changed),
2460            ("capacitor", "capacitor_max", self._on_capacitor_changed),
2461        ):
2462            value = getattr(power, current, None)
2463            ceiling = getattr(power, maximum, None)
2464            if value is not None and ceiling is not None:
2465                handler(float(value), float(ceiling))
2466        hull = getattr(self._ship, "hull", None)
2467        hull_max = getattr(self._ship, "hull_max", None)
2468        if hull is not None and hull_max is not None:
2469            self._on_hull_changed(float(hull), float(hull_max))
2470        scrap = getattr(singletons.get(Services.ECONOMY), "scrap", None)
2471        if scrap is not None:
2472            self._on_scrap_changed(float(scrap))
2473        self._poll_ammo()
2474        for node in self._emitters_of(self._ship, SignalNames.SOLAR_STATE_CHANGED):
2475            self._on_solar_state_changed(getattr(node, "state", ""))
2476        for node in self._emitters_of(self._ship, SignalNames.GENERATOR_CHANGED):
2477            self._on_generator_changed(getattr(node, "running", False))
2478
2479    def _poll_ammo(self) -> None:
2480        """Read the hull's magazines and its stowed boxes into the strip.
2481
2482        ``AMMO_CHANGED`` fires when a magazine moves, which never happens for a
2483        gun that has been bought and not yet fired, so a refit would otherwise
2484        leave the readout empty beside a loaded weapon. The magazines are read
2485        off the hardpoints instead and the signal keeps the number live between
2486        polls. A hull with no ballistic weapon and no box reads nothing at all:
2487        the cell exists only once there is ammunition to spend.
2488        """
2489        rounds: dict[str, int] = {}
2490        for node in self._magazines(self._ship):
2491            rounds[str(node.weapon_id)] = int(node.rounds)
2492        racks = [
2493            rack for rack in self._emitters_of(self._ship, SignalNames.AMMO_CHANGED) if hasattr(rack, "ammo_boxes")
2494        ]
2495        for rack in racks:
2496            for weapon_id in list(rounds):
2497                rounds[weapon_id] += int(rack.ammo_boxes(weapon_id)) * int(balance.AMMO_BOX_ROUNDS)
2498        # A rack that can be read is the authority, empty answer included: that
2499        # is how a weapon sold at a depot takes its count off the strip.
2500        if racks or rounds:
2501            self._ammo = rounds
2502
2503    @staticmethod
2504    def _magazines(root):
2505        """Every ballistic weapon under *root*: a node with rounds and an id."""
2506        if root is None:
2507            return
2508        stack = [root]
2509        while stack:
2510            node = stack.pop()
2511            if isinstance(getattr(node, "rounds", None), int) and getattr(node, "weapon_id", None):
2512                yield node
2513            stack.extend(node.children)
2514
2515    @staticmethod
2516    def _emitters_of(root, signal_name: str):
2517        """Every node under *root* that emits *signal_name*, root included."""
2518        if root is None:
2519            return
2520        stack = [root]
2521        while stack:
2522            node = stack.pop()
2523            if isinstance(getattr(node, signal_name, None), Signal):
2524                yield node
2525            stack.extend(node.children)
2526
2527    def _connect_subtree(self, node: Node, seen: set[int]) -> None:
2528        if id(node) in seen:
2529            return
2530        seen.add(id(node))
2531        done = self._connected.get(node)
2532        if done is None:
2533            done = set()
2534            self._connected[node] = done
2535        for name, method in _SIGNAL_HANDLERS.items():
2536            if name in done:
2537                continue
2538            signal = getattr(node, name, None)
2539            if isinstance(signal, Signal):
2540                signal.connect(getattr(self, method))
2541                done.add(name)
2542                self._connected_names.add(name)
2543        for child in node.children:
2544            self._connect_subtree(child, seen)
2545
2546    # -------------------------------------------------------------- handlers
2547
2548    def _on_hull_changed(self, current: float, maximum: float) -> None:
2549        fraction = _fraction(current, maximum)
2550        previous = self._gauges[HudElements.HULL]
2551        if fraction < previous:
2552            # A blow: hold the chunk that was just lost lit, then drain it over
2553            # a fixed time. Re-arming the hold on a second blow is deliberate:
2554            # two hits in quick succession are one wound to look at.
2555            self._hull_chip = max(self._hull_chip, previous)
2556            self._hull_chip_hold = HULL_CHIP_HOLD_S
2557            self._hull_chip_rate = max(self._hull_chip - fraction, 1e-6) / HULL_CHIP_DECAY_S
2558        elif fraction > previous:
2559            # A repair (or the seed): a rising reading has no chunk to mourn.
2560            self._hull_chip = fraction
2561            self._hull_chip_hold = 0.0
2562            self._hull_chip_rate = 0.0
2563        # An unchanged reading is a re-announcement, not a repair, and it must
2564        # leave the chunk alone. The ship re-states its hull a few frames after
2565        # a blow (the breach bookkeeping does it), and clearing on "not lower"
2566        # would delete the chunk three frames into an animation authored to
2567        # last most of a second. On the one hit that matters, the Shrike's
2568        # lantern, the ring would snap to its new length while the screen was
2569        # still white, and the arc would read as never having moved at all.
2570        self._gauges[HudElements.HULL] = fraction
2571        self._levels[HudElements.HULL] = float(current)
2572
2573    def _on_breach_opened(self, open_breaches: int) -> None:
2574        self._open_breaches = int(open_breaches)
2575        self.show_subtitle("breach")
2576
2577    def _on_breach_patched(self, open_breaches: int) -> None:
2578        self._open_breaches = int(open_breaches)
2579
2580    def _on_capacitor_changed(self, current: float, maximum: float) -> None:
2581        self._gauges[HudElements.CAPACITOR] = _fraction(current, maximum)
2582        self._levels[HudElements.CAPACITOR] = float(current)
2583
2584    def _on_energy_denied(self, consumer: str) -> None:
2585        del consumer
2586        self.show_subtitle("energy_denied")
2587
2588    def _on_o2_changed(self, current: float, maximum: float) -> None:
2589        self._gauges[HudElements.O2] = _fraction(current, maximum)
2590        self._levels[HudElements.O2] = float(current)
2591        drain = balance.O2_DRAIN_PER_S + balance.O2_DRAIN_PER_BREACH_PER_S * self._open_breaches
2592        self._o2_seconds = float(current) / drain if drain > 0.0 else float("inf")
2593        if self.low_o2:
2594            self.show_subtitle("breathing")
2595
2596    def _on_fuel_changed(self, current: float, maximum: float) -> None:
2597        self._gauges[HudElements.FUEL] = _fraction(current, maximum)
2598        self._levels[HudElements.FUEL] = float(current)
2599        fare = float(balance.WARP_FUEL_BASE)
2600        self._fuel_jumps = float(current) / fare if fare > 0.0 else float("inf")
2601
2602    def _on_generator_changed(self, running: bool) -> None:
2603        self._generator_running = bool(running)
2604        if running:
2605            self.show_subtitle("generator_hum")
2606
2607    def _on_solar_state_changed(self, state: str) -> None:
2608        self._wings_state = str(state)
2609
2610    def _on_afterburner_changed(self, active: bool) -> None:
2611        self._afterburner = bool(active)
2612
2613    def _on_silent_running_changed(self, active: bool) -> None:
2614        self._silent_running = bool(active)
2615        if active:
2616            self.show_subtitle("silent_running")
2617
2618    def _on_signature_changed(self, value: float) -> None:
2619        self._signature = float(value)
2620
2621    def _on_signature_locked(self) -> None:
2622        # The stamp is the headline and the objective band carries the answer.
2623        # A toast as well would be a third copy of one sentence on one screen.
2624        self._signature_locked = True
2625        self.flash_stamp(SIGNATURE_LOCKED_STAMP)
2626        self.show_subtitle("klaxon")
2627
2628    def _on_ammo_changed(self, weapon_id: str, rounds: int) -> None:
2629        self._ammo[str(weapon_id)] = int(rounds)
2630
2631    def _on_scrap_changed(self, total: float) -> None:
2632        self._levels[HudElements.SCRAP] = float(total)
2633
2634    def _on_notoriety_changed(self, value: int, reason: str) -> None:
2635        del reason
2636        self._notoriety = int(value)
2637
2638    def _on_player_damaged(self, amount: float, direction: Vec3) -> None:
2639        edge = _EDGES[self._edge_index(direction)]
2640        weight = max(DAMAGE_EDGE_MIN_WEIGHT, _clamp01(float(amount) / DAMAGE_EDGE_FULL_HIT))
2641        self._edge_damage[edge] = max(self._edge_damage[edge], weight)
2642
2643    def _on_audio_cue(self, cue: str, caption: str) -> None:
2644        """Caption a cue straight off the audio director's own announcement.
2645
2646        Every played cue lands here whoever played it, so a system that talks
2647        to the director directly (the hunter's burn klaxon) reaches the
2648        caption track without also having to know the HUD. The wording is
2649        audio's, side suffix included; :meth:`_push_subtitle` folds it with
2650        any caption the HUD raised for the same sound from a signal.
2651        """
2652        del cue
2653        self._push_subtitle(str(caption))
2654
2655    def _on_enemy_killed(self, archetype: str, position: Vec3, elite: bool) -> None:
2656        del archetype, position, elite
2657        self.show_subtitle("kill_chime")
2658
2659    def _on_wave_spawned(self, count: int) -> None:
2660        del count
2661        self.show_subtitle("spawn_chevron")
2662
2663    def _on_hunter_telegraph(self, stage: str) -> None:
2664        if stage == "t60":
2665            self.show_subtitle("groan")
2666        elif stage == "t30":
2667            self.show_subtitle("klaxon")
2668        elif stage == "t0":
2669            # The countdown has run out and the beast is on screen: the
2670            # instruction it was carrying has been overtaken by events.
2671            self._signature_locked = False
2672            self.flash_stamp("ARRIVAL")
2673
2674    def _on_warp_spool_started(self, emergency: bool) -> None:
2675        del emergency
2676        self._warp_active = True
2677        self._warp_elapsed = 0.0
2678        self._warp_total = balance.WARP_SPOOL_S
2679        self._warp_stutter = 0.0
2680        self._warp_target = ""
2681        self._warp_fuel = 0.0
2682        self._warp_price_text = ""
2683        self._warp_interrupt_remaining = 0.0
2684
2685    def _on_warp_spool_interrupted(self, added_seconds: float) -> None:
2686        added = float(added_seconds)
2687        self._warp_total += added
2688        self._warp_stutter = 1.0
2689        # Named cost and named cause: "INTERRUPTED" alone teaches nothing, and
2690        # the one thing the pilot needs is that being shot is what lengthens it.
2691        self._warp_interrupt_text = f"{WARP_INTERRUPT_PREFIX} +{added:.1f} s ({WARP_INTERRUPT_CAUSE})"
2692        self._warp_interrupt_remaining = WARP_INTERRUPT_FLASH_S
2693        self.show_subtitle("spool_stutter")
2694
2695    def _on_warp_spool_cancelled(self) -> None:
2696        self._flash_warp_outcome(WARP_ABORTED_LABEL)
2697        self.end_warp_spool()
2698
2699    def _on_warp_completed(self, emergency: bool) -> None:
2700        del emergency
2701        self._warp_jump_flash = WARP_JUMP_FLASH_S
2702        # Leaving is one of the two answers the lock's instruction offers, so
2703        # arriving somewhere else is the moment it stops being true.
2704        self._signature_locked = False
2705        self._hunter = None
2706        self.end_warp_spool()
2707
2708    # ------------------------------------------------------------- geometry
2709
2710    @staticmethod
2711    def _edge_index(direction: Vec3) -> int:
2712        """Screen edge nearest *direction*, as an index into :data:`_EDGES`.
2713
2714        *direction* is the bearing from the ship toward the threat, the same
2715        convention ``ShieldArc.covers`` takes. On screen +X is right and -Z is
2716        up, so the plane components map straight onto screen axes.
2717        """
2718        dx = float(direction.x)
2719        dy = float(direction.z)  # world +Z is screen down
2720        if abs(dx) >= abs(dy):
2721            return 1 if dx > 0.0 else 0  # right / left
2722        return 2 if dy < 0.0 else 3  # top / bottom
2723
2724    def _project(self, world: Vec3, rect: tuple[float, float, float, float]) -> tuple[float, float] | None:
2725        """Where *world* lands on screen, or None when it is behind the camera."""
2726        x, y, w, h = rect
2727        rig = self._rig
2728        if rig is None or rig.camera is None or h <= 0.0:
2729            return None
2730        camera = rig.camera
2731        vp = camera.projection_matrix(w / h) @ camera.view_matrix
2732        clip = vp @ np.array([float(world.x), float(world.y), float(world.z), 1.0], dtype=np.float64)
2733        if clip[3] <= 1e-6:
2734            return None
2735        return (x + (clip[0] / clip[3] * 0.5 + 0.5) * w, y + (clip[1] / clip[3] * 0.5 + 0.5) * h)
2736
2737    def _ship_screen_position(self, rect: tuple[float, float, float, float]) -> tuple[float, float]:
2738        """Where the ship sits on screen, falling back to the rect centre."""
2739        x, y, w, h = rect
2740        centre = (x + w * 0.5, y + h * 0.5)
2741        ship = self._ship
2742        if ship is None:
2743            return centre
2744        return self._project(Vec3(ship.world_position), rect) or centre
2745
2746    # ----------------------------------------------------------------- draw
2747
2748    def on_draw(self, renderer):
2749        rect = self.get_global_rect()
2750        cx, cy = self._ship_screen_position(rect)
2751        scale = self.effective_scale
2752        self._marker_rows = []
2753
2754        self._draw_signature_border(renderer, rect)
2755        self._draw_damage_edges(renderer, rect, scale)
2756        self._draw_low_hull_pulse(renderer, rect, scale)
2757        self._draw_reading_plates(renderer, cx, cy, scale)
2758        self._draw_gauge_arcs(renderer, cx, cy, scale)
2759        self._draw_shield_arc(renderer, cx, cy, scale)
2760        self._draw_resource_strip(renderer, cx, cy, scale)
2761        self._draw_signature_gauge(renderer, cx, cy, scale)
2762        self._draw_warp_ring(renderer, rect, cx, cy, scale)
2763        self._draw_warp_outcome(renderer, rect, cx, cy, scale)
2764        self._draw_warp_status(renderer, cx, cy, scale)
2765        self._draw_interact_fill(renderer, cx, cy, scale)
2766        self._draw_affordance(renderer, cx, cy, scale)
2767        self._draw_hunter_marker(renderer, rect, scale)
2768        self._draw_beacon(renderer, rect, scale)
2769        self._draw_compass(renderer, rect, scale)
2770        self._draw_float_texts(renderer, rect, scale)
2771        self._draw_warnings(renderer, rect, cx, cy, scale)
2772        self._draw_controls_bar(renderer, rect, scale)
2773        self._draw_readouts(renderer, rect, scale)
2774        self._draw_sector_name(renderer, rect, scale)
2775        self._draw_control_card(renderer, rect, scale)
2776        self._draw_arrival_card(renderer, rect, scale)
2777        self._draw_toast(renderer, rect, scale)
2778        self._draw_subtitles(renderer, rect, scale)
2779        self._draw_stamp(renderer, rect, scale)
2780        self._draw_jump_flash(renderer, rect)
2781        self._draw_frame_stats(renderer, rect, scale)
2782
2783    def _draw_signature_border(self, renderer, rect) -> None:
2784        if not self.is_element_visible(HudElements.SIGNATURE):
2785            return
2786        x, y, w, h = rect
2787        heat = self.signature_fraction
2788        colour = _lerp_colour(COLOUR_SIGNATURE_COLD, COLOUR_SIGNATURE_HOT, heat)
2789        thickness = SIGNATURE_BORDER_MIN_PX + (SIGNATURE_BORDER_MAX_PX - SIGNATURE_BORDER_MIN_PX) * heat
2790        renderer.rect((x, y), (w, thickness), fill=colour)
2791        renderer.rect((x, y + h - thickness), (w, thickness), fill=colour)
2792        renderer.rect((x, y), (thickness, h), fill=colour)
2793        renderer.rect((x + w - thickness, y), (thickness, h), fill=colour)
2794
2795    def _draw_signature_gauge(self, renderer, cx: float, cy: float, scale: float) -> None:
2796        """The signature meter as a gauge you can read, over the ship's nose.
2797
2798        The border is the same reading felt rather than read, and the two are
2799        driven by the one fraction so they can never disagree. This one carries
2800        the number, because "how close am I" is a question with an answer and
2801        the border can only ever say "closer than before".
2802        """
2803        if not self.is_element_visible(HudElements.SIGNATURE):
2804            return
2805        heat = self.signature_fraction
2806        colour = _lerp_colour(COLOUR_SIGNATURE_COLD, COLOUR_SIGNATURE_HOT, heat)
2807        radius = (ARC_RADIUS_PX + SIGNATURE_ARC_GAP_PX) * scale
2808        thickness = SIGNATURE_ARC_THICKNESS_PX * scale
2809        start = 270.0 - SIGNATURE_ARC_SPAN_DEGREES * 0.5
2810        _draw_arc(renderer, cx, cy, radius, start, SIGNATURE_ARC_SPAN_DEGREES, thickness, COLOUR_TRACK)
2811        filled = SIGNATURE_ARC_SPAN_DEGREES * heat
2812        if filled > 0.0:
2813            _draw_arc(renderer, cx, cy, radius, start, filled, thickness, _opaque(colour))
2814        line = SIGNATURE_LABEL_LINE_PX * scale
2815        renderer.text(
2816            SIGNATURE_LABEL.format(value=self._signature, maximum=balance.SIGNATURE_MAX),
2817            fill=_opaque(_lerp_colour(COLOUR_READOUT, COLOUR_SIGNATURE_HOT, heat)),
2818            scale=SIGNATURE_LABEL_FONT_SCALE * scale,
2819            align="centre",
2820            valign="centre",
2821            rect=(cx - 160.0 * scale, cy - radius - SIGNATURE_LABEL_GAP_PX * scale - line, 320.0 * scale, line),
2822        )
2823
2824    def _draw_hunter_marker(self, renderer, rect, scale: float) -> None:
2825        """Point at where the Shrike is coming from, and count it down.
2826
2827        Without it, the ladder between the lock and the tear-in has exactly one
2828        visible channel, a border that is already red, and a pilot can spend the
2829        whole warning not knowing there is one. The marker rides the approach
2830        bearing and the label carries the seconds, so the warning is a bearing
2831        and a clock rather than a mood.
2832        """
2833        if not self._signature_locked or not self.is_element_visible(HudElements.SIGNATURE):
2834            return
2835        approach = self._hunter_approach()
2836        if approach is None:
2837            return
2838        seconds, bearing = approach
2839        ship = self._ship
2840        if ship is None:
2841            return
2842        direction = heading_to_direction(bearing)
2843        world = Vec3(ship.world_position) + Vec3(direction) * HUNTER_MARKER_RANGE_UNITS
2844        px, py = self._marker_point(world, rect, HUNTER_MARKER_EDGE_MARGIN_PX * scale, scale)
2845        renderer.circle((px, py), HUNTER_MARKER_RADIUS_PX * scale, stroke=COLOUR_HUNTER_MARKER, segments=3)
2846        half = HUNTER_LABEL_HALF_WIDTH_PX * scale
2847        wanted = py + MARKER_LABEL_GAP_PX * scale
2848        # Required: the countdown is the one marker that never stands down. A
2849        # pilot who cannot see the clock cannot spend it.
2850        row = self._marker_label_y(wanted, px, half, self._marker_rows, rect, scale, required=True)
2851        renderer.text(
2852            HUNTER_COUNTDOWN.format(seconds=max(0.0, seconds)),
2853            fill=COLOUR_HUNTER_MARKER,
2854            scale=HUNTER_MARKER_FONT_SCALE * scale,
2855            align="centre",
2856            valign="centre",
2857            rect=(
2858                self._marker_label_left(px, half, rect, scale),
2859                row,
2860                2.0 * half,
2861                WARNING_LINE_HEIGHT_PX * scale,
2862            ),
2863        )
2864
2865    def _draw_damage_edges(self, renderer, rect, scale: float) -> None:
2866        if not self.is_element_visible(HudElements.DAMAGE_DIRECTION):
2867            return
2868        x, y, w, h = rect
2869        band = DAMAGE_EDGE_BAND_PX * scale
2870        for edge, weight in self._edge_damage.items():
2871            if weight <= 0.0:
2872                continue
2873            colour = _with_alpha(COLOUR_DESATURATION, weight)
2874            if edge == "left":
2875                renderer.rect((x, y), (band, h), fill=colour)
2876            elif edge == "right":
2877                renderer.rect((x + w - band, y), (band, h), fill=colour)
2878            elif edge == "top":
2879                renderer.rect((x, y), (w, band), fill=colour)
2880            else:
2881                renderer.rect((x, y + h - band), (w, band), fill=colour)
2882
2883    def _draw_low_hull_pulse(self, renderer, rect, scale: float) -> None:
2884        """Pulse the whole border while the hull is under the low mark.
2885
2886        The signature border is a different animal: it warms slowly and never
2887        blinks. This one breathes, on all four edges at once, which is the one
2888        thing on the screen that means the next hit may be the last.
2889
2890        It is also drawn :data:`LOW_HULL_INSET_PX` inboard rather than off the
2891        screen edge, so the two rings are a pair rather than a smear. The
2892        outermost pixels belong to the signature meter alone, which is what
2893        lets a jump be seen: arriving collapses that band to a cold hairline,
2894        and drawn at the edge, a low-hull ring would hide the change under a
2895        wider, brighter, almost identically red ring on top of it.
2896        """
2897        if not self.low_hull or not self.is_element_visible(HudElements.WARNINGS):
2898            return
2899        x, y, w, h = rect
2900        inset = LOW_HULL_INSET_PX
2901        x, y, w, h = x + inset, y + inset, w - inset * 2.0, h - inset * 2.0
2902        band = min(LOW_HULL_BAND_PX * scale, w * 0.5, h * 0.5)
2903        if band <= 0.0:
2904            return
2905        wave = 0.5 + 0.5 * math.sin(self._elapsed * LOW_HULL_PULSE_HZ * math.tau)
2906        alpha = LOW_HULL_PULSE_MIN_ALPHA + (LOW_HULL_PULSE_MAX_ALPHA - LOW_HULL_PULSE_MIN_ALPHA) * wave
2907        colour = _with_alpha(COLOUR_LOW_HULL_PULSE, alpha)
2908        renderer.rect((x, y), (w, band), fill=colour)
2909        renderer.rect((x, y + h - band), (w, band), fill=colour)
2910        renderer.rect((x, y), (band, h), fill=colour)
2911        renderer.rect((x + w - band, y), (band, h), fill=colour)
2912
2913    def _draw_band(self, renderer, cx: float, cy: float, scale: float, offset: float, lines, font: float) -> None:
2914        """Write *lines* into the reserved band *offset* pixels under the ship.
2915
2916        Every line under the hull goes through here, which is what keeps the
2917        bands from drifting into each other: a caller picks its band and the
2918        band picks the pixels. *lines* is a sequence of ``(text, colour)``.
2919        """
2920        line_height = WARNING_LINE_HEIGHT_PX * scale
2921        top = cy + offset * scale
2922        width = BAND_HALF_WIDTH_PX * 2.0 * scale
2923        for index, (text, colour) in enumerate(lines):
2924            renderer.text(
2925                text,
2926                fill=colour,
2927                scale=font * scale,
2928                align="centre",
2929                valign="centre",
2930                rect=(cx - BAND_HALF_WIDTH_PX * scale, top + index * line_height, width, line_height),
2931            )
2932
2933    def _warning_lines(self) -> list[tuple[str, tuple[float, float, float, float]]]:
2934        """The survival lines the warning band is carrying, at most its height.
2935
2936        Ordered by what kills first, because the band is shorter than the
2937        worst case and the tail is what gets dropped: a breached hull, then
2938        the air, then the tank. An empty tank strands a pilot who is otherwise
2939        fine, so it yields to both of the clocks that do not.
2940        """
2941        if not self.is_element_visible(HudElements.WARNINGS):
2942            return []
2943        lines: list[tuple[str, tuple[float, float, float, float]]] = []
2944        if self.low_hull:
2945            lines.append((LOW_HULL_WARNING, COLOUR_WARNING))
2946        if self.low_o2:
2947            lines.append((f"OXYGEN {max(0.0, self._o2_seconds):.0f}s", COLOUR_WARNING))
2948            lines.append((LOW_O2_HINT, COLOUR_HINT))
2949        if self.low_fuel and LOW_FUEL_SOURCE not in self._warp_status.lower():
2950            # The drive's own refusal already names the shop once the tank
2951            # will not cover a jump, and the two lines land a hand's width
2952            # apart. The refusal is the better of the two, because it quotes
2953            # the shortfall as well, so the hint is the one that stands down.
2954            lines.append((LOW_FUEL_HINT, COLOUR_HINT))
2955        return lines[:BAND_WARNING_LINES]
2956
2957    def caption_block(self, rect, scale: float) -> tuple[float, float] | None:
2958        """Top and bottom of the caption track's plate, or ``None`` when it is empty.
2959
2960        The track grows upward from a fixed base as sounds stack, so how far up
2961        the screen it reaches is a thing that changes several times a second.
2962        The standing lines under the hull are pinned to the ship, and the two
2963        meet at whatever height the camera is holding, which can put
2964        ``IT TEARS IN`` straight through ``OBJECTIVE: MINE THE GLOWING VEINS``
2965        and leave neither readable.
2966        """
2967        if not self._subtitles or not self.is_element_visible(HudElements.SUBTITLES):
2968            return None
2969        _x, y, _w, h = rect
2970        line = SUBTITLE_LINE_HEIGHT_PX * scale
2971        padding = SUBTITLE_PLATE_PADDING_Y_PX * scale
2972        base = y + h - SUBTITLE_BOTTOM_MARGIN_PX * scale - line * len(self._subtitles)
2973        return (base - padding, base + line * len(self._subtitles) + padding)
2974
2975    def _band_is_clear(self, offset: float, lines: int, rect, scale: float) -> bool:
2976        """Whether a band *offset* under the hull is clear of the caption track."""
2977        block = self.caption_block(rect, scale)
2978        if block is None:
2979            return True
2980        top = self._band_top(offset, rect, scale)
2981        bottom = top + WARNING_LINE_HEIGHT_PX * scale * max(1, lines)
2982        return bottom <= block[0] or top >= block[1]
2983
2984    def _bottom_furniture_top(self, rect, scale: float) -> float:
2985        """The topmost pixel the permanent furniture along the bottom edge owns.
2986
2987        That is the controls strip and nothing else. It is the floor the
2988        ladder under the hull stops at: the strip is up for the whole run, so a
2989        standing line written through it is not a collision that clears, it is
2990        two sentences ruined for as long as the pilot flies that low.
2991        """
2992        _x, y, _w, h = rect
2993        return y + h - (CONTROLS_BAR_BAND_PX * scale if self.controls_bar else 0.0)
2994
2995    def _lower_band_lift(self, rect, scale: float) -> float:
2996        """Pixels the objective and boundary bands ride up, together, off the floor.
2997
2998        Everything above them is a reading of the ship and stays welded to the
2999        hull. These two are statements about the run and the sector, they are
3000        the bottom of the ladder, and they are the pair a diving camera walks
3001        into the controls strip: holding thrust puts the hull most of the way
3002        down the frame and the boundary notice off the bottom of it entirely.
3003
3004        The pair moves as one, by the whole overflow, so the gap between them
3005        never changes and neither appearing shifts the other. It rides up only
3006        over rows nothing is using (:meth:`_lower_band_ceiling`): a reserved
3007        row is free ground while its own line is silent and never while it is
3008        not, and a survival warning is not a thing that yields. When the lift
3009        that is available is not enough the line stands down for the frame, the
3010        way it already does for the caption track, because a sentence written
3011        half off the bottom edge is not a sentence anybody read.
3012        """
3013        _cx, cy = self._ship_screen_position(rect)
3014        overflow = cy + BAND_LADDER_PX * scale - self._bottom_furniture_top(rect, scale)
3015        if overflow <= 0.0:
3016            return 0.0
3017        spare = max(0.0, BAND_OBJECTIVE_PX - self._lower_band_ceiling())
3018        return min(overflow, spare * scale)
3019
3020    def _lower_band_ceiling(self) -> float:
3021        """The offset the objective band may not ride above, in unscaled pixels.
3022
3023        The bottom of the lowest band over the pair that is writing something
3024        this frame, so the lift takes empty rows and stops dead at a used one.
3025        The resource strip's own plate reaches to :data:`BAND_CONTEXT_PX`, and
3026        the strip is up for the whole run, so that is the hard ceiling.
3027        """
3028        warnings = len(self._warning_lines())
3029        if warnings:
3030            return BAND_WARNING_PX + warnings * WARNING_LINE_HEIGHT_PX
3031        if self._warp_chip_is_up():
3032            return BAND_CONTEXT_PX + BAND_CONTEXT_LINES * WARNING_LINE_HEIGHT_PX
3033        return BAND_CONTEXT_PX
3034
3035    def _warp_chip_is_up(self) -> bool:
3036        """Whether the drive's one-line answer is occupying the context band."""
3037        return bool(self._warp_status) and not self._warp_active and self.is_element_visible(HudElements.WARP_STATUS)
3038
3039    def _band_top(self, offset: float, rect, scale: float) -> float:
3040        """The top pixel of the band *offset* under the hull, lift included."""
3041        _cx, cy = self._ship_screen_position(rect)
3042        top = cy + offset * scale
3043        if offset >= BAND_OBJECTIVE_PX:
3044            top -= self._lower_band_lift(rect, scale)
3045        return top
3046
3047    def _band_clears_floor(self, offset: float, rect, scale: float) -> bool:
3048        """Whether one lifted line at *offset* still fits above the bottom furniture."""
3049        top = self._band_top(offset, rect, scale)
3050        return top + WARNING_LINE_HEIGHT_PX * scale <= self._bottom_furniture_top(rect, scale)
3051
3052    def _objective_line(self, rect, scale: float) -> str:
3053        """What the objective band is carrying: the lock's instruction, or the run's."""
3054        if self._warp_active or not self.is_element_visible(HudElements.WARNINGS):
3055            return ""
3056        if not self._band_is_clear(BAND_OBJECTIVE_PX, 1, rect, scale):
3057            return ""
3058        if not self._band_clears_floor(BAND_OBJECTIVE_PX, rect, scale):
3059            return ""
3060        return SIGNATURE_LOCKED_INSTRUCTION if self._signature_locked else self._objective
3061
3062    def _boundary_line(self, rect, scale: float) -> str:
3063        """What the boundary band is carrying, empty while the drive is spooling."""
3064        if self._warp_active or not self.is_element_visible(HudElements.WARNINGS):
3065            return ""
3066        if not self._band_is_clear(BAND_BOUNDARY_PX, 1, rect, scale):
3067            return ""
3068        if not self._band_clears_floor(BAND_BOUNDARY_PX, rect, scale):
3069            return ""
3070        return self.boundary_notice
3071
3072    def _draw_warnings(self, renderer, rect, cx: float, cy: float, scale: float) -> None:
3073        """The survival states and the standing cues, in their reserved bands.
3074
3075        Three bands rather than one stack: what the ship is in trouble about,
3076        what the run is asking of it, and where the sector stops. Sharing one
3077        offset with each other and with the warp chip, a pilot flying out of a
3078        sector while the meter is locked would get all four sentences printed
3079        through one another.
3080
3081        While the drive is spooling the standing lines stand down: the ring's
3082        own label sweeps down through these bands as it closes, and it is the
3083        live version of what the objective is asking for anyway. They stand
3084        down for the caption track on the same principle: a sound the pilot is
3085        hearing right now is the live version of what is happening, the
3086        objective is a standing reminder that will still be true in two
3087        seconds, and the two written through each other are neither. The
3088        survival band never stands down for anything, because a hull warning
3089        buried under a caption is the death nobody saw coming.
3090
3091        The two lowest bands also ride up off the controls strip when a diving
3092        camera has put the hull low enough to walk them into it
3093        (:meth:`_lower_band_lift`).
3094        """
3095        if not self.is_element_visible(HudElements.WARNINGS):
3096            return
3097        self._draw_band(renderer, cx, cy, scale, BAND_WARNING_PX, self._warning_lines(), WARNING_FONT_SCALE)
3098        lifted = cy - self._lower_band_lift(rect, scale)
3099        objective = self._objective_line(rect, scale)
3100        if objective:
3101            lines = [(objective, COLOUR_OBJECTIVE)]
3102            self._draw_band(renderer, cx, lifted, scale, BAND_OBJECTIVE_PX, lines, WARNING_FONT_SCALE)
3103        boundary = self._boundary_line(rect, scale)
3104        if boundary:
3105            row = [(boundary, COLOUR_HINT)]
3106            self._draw_band(renderer, cx, lifted, scale, BAND_BOUNDARY_PX, row, WARNING_FONT_SCALE)
3107
3108    def _marker_point(self, target: Vec3, rect, margin: float, scale: float) -> tuple[float, float]:
3109        """Where a waypoint marker for *target* is pinned, *margin* inside the edge.
3110
3111        A pitched camera puts anything far enough behind it outside the frustum,
3112        and the projection then says nothing at all. Falling back to the centre
3113        of the screen would plant the marker on the ship, and a pilot flying at
3114        a DEPOT marker that sits over their own hull flies out of the sector
3115        doing it. The flight plane maps straight onto the screen axes, so a
3116        failed projection is answered with the plane bearing, which is a real
3117        direction rather than a shrug.
3118
3119        The bottom edge keeps :data:`MARKER_BOTTOM_MARGIN_PX` whatever *margin*
3120        asks for, because the captions and the controls strip live down there.
3121        """
3122        x, y, w, h = rect
3123        floor = max(margin, MARKER_BOTTOM_MARGIN_PX * scale)
3124        point = self._project(target, rect)
3125        if point is None:
3126            sx, sy = self._ship_screen_position(rect)
3127            ship = self._ship
3128            if ship is None:
3129                return (x + w * 0.5, y + h * 0.5)
3130            offset = Vec3(target) - Vec3(ship.world_position)
3131            dx, dz = float(offset.x), float(offset.z)  # world +Z is screen down
3132            reach = math.hypot(dx, dz)
3133            if reach < 1e-6:
3134                return (sx, sy)
3135            span = w + h
3136            point = (sx + dx / reach * span, sy + dz / reach * span)
3137        return (
3138            min(x + w - margin, max(x + margin, point[0])),
3139            min(y + h - floor, max(y + margin, point[1])),
3140        )
3141
3142    def _range_label(self, base: str, target: Vec3) -> str:
3143        """``"DEPOT 41 u"``: a waypoint's name, its range and the unit.
3144
3145        The unit is not decoration. A bare ``ORE 147`` in the corner of the
3146        screen reads as a second currency standing against the SCRAP counter,
3147        and sends the pilot looking for the shop that takes it.
3148        """
3149        ship = self._ship
3150        if ship is None:
3151            return base
3152        offset = Vec3(target) - Vec3(ship.world_position)
3153        return f"{base} {math.hypot(float(offset.x), float(offset.z)):.0f} {RANGE_UNIT_SUFFIX}"
3154
3155    def _marker_label_left(self, px: float, half_width: float, rect, scale: float) -> float:
3156        """The left edge of a centred marker label, slid inboard of the screen.
3157
3158        The marker is clamped to the edge margin, but a label centred on it is
3159        wider than the margin is deep, so a marker pinned to a side edge would
3160        print the outer half of its text off the screen. The rect keeps its
3161        width and slides until it fits; the text stays centred inside it.
3162        """
3163        x, _y, w, _h = rect
3164        inset = LABEL_EDGE_INSET_PX * scale
3165        left = px - half_width
3166        return min(max(left, x + inset), x + w - 2.0 * half_width - inset)
3167
3168    def reading_band(self, rect, scale: float) -> tuple[float, float, float, float]:
3169        """The column of screen the ship's own standing lines own, as a rect.
3170
3171        Everything the HUD writes about the hull is drawn on one vertical
3172        column: the affordance line and the signature reading above the ring,
3173        and the resource strip, the warp chip, the warnings, the objective and
3174        the boundary notice below it. A waypoint label is the one thing on the
3175        screen that is placed by where a rock is rather than by a layout, so it
3176        is the one thing that can land in the middle of them: the warp chip and
3177        a depot marker sharing a row of pixels read as
3178        ``PRESS R TO JUMDEPOT ON``.
3179        """
3180        cx, _cy = self._ship_screen_position(rect)
3181        half = BAND_HALF_WIDTH_PX * scale
3182        bottom = self._band_top(BAND_BOUNDARY_PX, rect, scale) + WARNING_LINE_HEIGHT_PX * scale
3183        return (cx - half, self.status_band_top(rect, scale), cx + half, bottom)
3184
3185    def _corner_zones(self, rect, scale: float) -> list[tuple[float, float, float, float]]:
3186        """The standing furniture in the corners, as rects.
3187
3188        The sector name in the top right and the two readout stacks down the
3189        left edge. They are the persistent part of the HUD, and a marker label
3190        that ignored them would print ``HULK 153 u`` and ``VEIN 131 u`` straight
3191        through ``DEBRIS FIELD`` from the top edge.
3192        """
3193        x, y, w, _h = rect
3194        margin = READOUT_MARGIN_PX * scale
3195        pad = READING_PLATE_PADDING_X_PX
3196        zones: list[tuple[float, float, float, float]] = []
3197        if self._sector_name and self.is_element_visible(HudElements.SECTOR):
3198            line = SECTOR_NAME_LINE_PX * scale
3199            width = _plate_width(self._sector_name, SECTOR_NAME_FONT_SCALE, scale, pad, SECTOR_NAME_BOX_PX * scale)
3200            zones.append((x + w - margin - width, y + margin, x + w - margin, y + margin + line))
3201        line = READOUT_LINE_HEIGHT_PX * scale
3202        stacks = (
3203            (self._top_left_readouts(), y + margin),
3204            (self._bottom_left_readouts(), self._bottom_left_base(rect, scale)),
3205        )
3206        for texts, top in stacks:
3207            if not texts:
3208                continue
3209            widest = max(texts, key=len)
3210            width = _plate_width(widest, READOUT_FONT_SCALE, scale, pad, w)
3211            zones.append((x + margin, top, x + margin + width, top + line * len(texts)))
3212        return zones
3213
3214    def _plate_zones(self, rect, scale: float) -> list[tuple[float, float, float, float]]:
3215        """The two dark plates at the foot of the screen, as rects.
3216
3217        The caption track and the answer line. Both are transient and both are
3218        centred, which is exactly where a marker riding the bottom edge puts its
3219        range: a ``KILL CHIME / HULL BREACHED`` plate dropped over ``HULK 91 u``
3220        leaves the hulk's range unreadable.
3221        """
3222        x, _y, w, _h = rect
3223        zones: list[tuple[float, float, float, float]] = []
3224        block = self.caption_block(rect, scale)
3225        if block is not None:
3226            widest = max(self._subtitles, key=lambda entry: len(entry[0]))[0]
3227            plate = _plate_width(widest, SUBTITLE_FONT_SCALE, scale, SUBTITLE_PLATE_PADDING_X_PX, w)
3228            zones.append((x + (w - plate) * 0.5, block[0], x + (w + plate) * 0.5, block[1]))
3229        text = self.toast
3230        if text and self.is_element_visible(HudElements.TOAST):
3231            padding = SUBTITLE_PLATE_PADDING_Y_PX * scale
3232            base = self.message_row(rect, scale)
3233            plate = _plate_width(text, TOAST_FONT_SCALE, scale, SUBTITLE_PLATE_PADDING_X_PX, w)
3234            top = base - padding
3235            bottom = base + TOAST_LINE_HEIGHT_PX * scale + padding
3236            zones.append((x + (w - plate) * 0.5, top, x + (w + plate) * 0.5, bottom))
3237        return zones
3238
3239    def _label_zones(self, rect, scale: float) -> list[tuple[float, float, float, float]]:
3240        """The standing HUD a waypoint label steps around, as screen rects.
3241
3242        The ship's own readings (:meth:`reading_band`), whichever tutorial or
3243        arrival card is up, and the standing furniture in the corners. The cards
3244        yield around the ship's band and the labels are placed by where a rock
3245        is, so without this the two meet in the one place neither of them is
3246        watching, with ``HULK 25 u`` written into the middle of the aiming
3247        lesson.
3248
3249        Avoiding the other markers is not enough: the sector name and the
3250        ``HULL 100`` chip beside the ring would each take a range through them.
3251        A label that dodges only its own kind is a label that lands on
3252        everything else.
3253
3254        The transient plates at the foot of the screen are not here: they are
3255        :meth:`_plate_zones`, and a label treats them differently because they
3256        go away on their own.
3257        """
3258        zones = [self.reading_band(rect, scale)]
3259        for layout in (self.control_card_layout(rect, scale), self.arrival_card_layout(rect, scale)):
3260            if layout is not None:
3261                zones.append((layout.x, layout.y, layout.x + layout.width, layout.bottom))
3262        zones.extend(self._corner_zones(rect, scale))
3263        return zones
3264
3265    def _blocked_rows(
3266        self, zones, left_edge: float, right_edge: float, taken: list, scale
3267    ) -> list[tuple[float, float]]:
3268        """Row spans a label between *left_edge* and *right_edge* may not take.
3269
3270        One merged, disjoint list rather than a rect at a time, because dodging
3271        a zone one at a time is how a label ends up oscillating between two of
3272        them: the tutorial card sits directly above the ship's band, so stepping
3273        up out of the band lands in the card and stepping down out of the card
3274        lands back in the band. Merged spans have edges, and an edge is a row
3275        that is clear of everything at once.
3276        """
3277        line = WARNING_LINE_HEIGHT_PX * scale
3278        clearance = MARKER_ZONE_CLEARANCE_PX * scale
3279        separation = MARKER_LABEL_SEPARATION_PX * scale
3280        spans: list[tuple[float, float]] = []
3281        for left, top, right, bottom in zones:
3282            if left_edge < right and right_edge > left:
3283                spans.append((top - line - clearance, bottom + clearance))
3284        for other_row, other_left, other_right in taken:
3285            if left_edge < other_right and right_edge > other_left:
3286                spans.append((other_row - separation, other_row + separation))
3287        merged: list[list[float]] = []
3288        for start, end in sorted(spans):
3289            if merged and start < merged[-1][1]:
3290                merged[-1][1] = max(merged[-1][1], end)
3291            else:
3292                merged.append([start, end])
3293        return [(start, end) for start, end in merged]
3294
3295    @staticmethod
3296    def _nearest_free_row(wanted: float, spans, floor: float, ceiling: float) -> float | None:
3297        """The row nearest *wanted* between *floor* and *ceiling* and outside *spans*.
3298
3299        ``None`` when every row in the range is spoken for, which is a marker
3300        that has been shut out of its own edge. The candidates are *wanted* and
3301        the edges of the merged spans, because the nearest clear row to a point
3302        inside a span is that span's own edge.
3303        """
3304        if floor > ceiling:
3305            return None
3306        candidates = [wanted]
3307        for start, end in spans:
3308            candidates.append(start)
3309            candidates.append(end)
3310        best: float | None = None
3311        for candidate in candidates:
3312            row = min(max(candidate, floor), ceiling)
3313            if any(start < row < end for start, end in spans):
3314                continue
3315            if best is None or abs(row - wanted) < abs(best - wanted):
3316                best = row
3317        return best
3318
3319    def _marker_label_y(
3320        self,
3321        wanted: float,
3322        px: float,
3323        half_width: float,
3324        taken: list,
3325        rect,
3326        scale,
3327        *,
3328        required: bool = False,
3329    ) -> float | None:
3330        """A free row for a marker label at *wanted*, clear of the HUD and *taken*.
3331
3332        Two waypoints on similar bearings pin to the same edge, so the beacon,
3333        the compass and the Shrike countdown share *taken*, one list for the
3334        whole frame: with a private list each, ``DEPOT 8 u`` and ``VEIN 7 u``
3335        would land on one row and read as ``DEPO7 08 u``. It carries each
3336        label's span as well as its row, so two markers at opposite edges still
3337        share a row.
3338
3339        The label steps along its own edge, up or down to whichever free row is
3340        nearer, until it is clear of the standing HUD too, however far that is:
3341        the tutorial card and the readings under the hull are not going away and
3342        a marker without a range is not a waypoint.
3343
3344        The two transient plates at the foot of the screen are the exception. A
3345        label steps around them as well, but only as far as
3346        :data:`MARKER_LABEL_MAX_TRAVEL_PX` from the row it was standing on;
3347        beyond that it returns ``None`` and the caller stands the whole marker
3348        down for the frame, because a plate that has taken the row is a plate
3349        that will hand it back in a second or two. *required* markers never
3350        stand down: that is the Shrike countdown and an armed dock prompt, and a
3351        warning that yields to a caption is a warning nobody got.
3352        """
3353        _x, y, _w, h = rect
3354        line = WARNING_LINE_HEIGHT_PX * scale
3355        ceiling = y + h - CONTROLS_BAR_BAND_PX * scale - line
3356        floor = y + line
3357        left_edge = self._marker_label_left(px, half_width, rect, scale)
3358        right_edge = left_edge + 2.0 * half_width
3359        standing = self._label_zones(rect, scale)
3360        spans = self._blocked_rows(standing, left_edge, right_edge, taken, scale)
3361        row = self._nearest_free_row(wanted, spans, floor, ceiling)
3362        if row is None:
3363            row = min(max(wanted, floor), ceiling)
3364        plates = self._plate_zones(rect, scale)
3365        if plates:
3366            spans = self._blocked_rows(standing + plates, left_edge, right_edge, taken, scale)
3367            clear = self._nearest_free_row(row, spans, floor, ceiling)
3368            if clear is None or abs(clear - row) > MARKER_LABEL_MAX_TRAVEL_PX * scale:
3369                if not required:
3370                    return None
3371            else:
3372                row = clear
3373        taken.append((row, left_edge, right_edge))
3374        return row
3375
3376    def _draw_compass(self, renderer, rect, scale: float) -> None:
3377        """Point at each of the nearest vein and hulk that is not on screen.
3378
3379        The depot beacon generalised: a pilot who has flown out of the content
3380        is given the bearing and the range of each kind of thing there is to
3381        fly back to. A marker goes away when its own target comes into view,
3382        and only then. Suppressing the whole set the moment any one of them is
3383        visible would make a depot marker vanish mid-flight and leave a pilot
3384        holding a heading out of the sector.
3385        """
3386        targets = self.compass_targets
3387        if not targets or not self.is_element_visible(HudElements.BEACON):
3388            return
3389        x, y, w, h = rect
3390        inset = COMPASS_ON_SCREEN_MARGIN_PX * scale
3391        margin = COMPASS_EDGE_MARGIN_PX * scale
3392        line = WARNING_LINE_HEIGHT_PX * scale
3393        rows = self._marker_rows
3394        for position, kind in targets:
3395            point = self._project(position, rect)
3396            if point is not None and x + inset <= point[0] <= x + w - inset and y + inset <= point[1] <= y + h - inset:
3397                continue  # the thing itself is on screen; a marker over it is noise
3398            px, py = self._marker_point(position, rect, margin, scale)
3399            half = MARKER_LABEL_HALF_WIDTH_PX * scale
3400            row = self._marker_label_y(py + MARKER_LABEL_GAP_PX * scale, px, half, rows, rect, scale)
3401            if row is None:
3402                # Shut out of its own edge by a panel. A ring with no range on
3403                # it is a dot; the panel is transient and the marker returns.
3404                continue
3405            renderer.circle(
3406                (px, py), COMPASS_RADIUS_PX * scale, stroke=COLOUR_COMPASS, segments=COMPASS_SEGMENTS.get(kind, 6)
3407            )
3408            renderer.text(
3409                self._range_label(COMPASS_LABELS.get(kind, kind.upper()), position),
3410                fill=COLOUR_COMPASS,
3411                scale=COMPASS_FONT_SCALE * scale,
3412                align="centre",
3413                valign="centre",
3414                rect=(self._marker_label_left(px, half, rect, scale), row, 2.0 * half, line),
3415            )
3416
3417    def _draw_beacon(self, renderer, rect, scale: float) -> None:
3418        """Mark the sector's depot, how far away it is, and when you are in it.
3419
3420        Drawn where the depot is when that is on screen, and pinned inside the
3421        edge in its direction when it is not, so the marker is a bearing and a
3422        range rather than a thing that vanishes the moment it matters.
3423
3424        Inside the bay it becomes the prompt. A ring whose range just keeps
3425        counting, with the one line that names the key small, at the top of the
3426        screen and under a tutorial card, is a ring a pilot crosses three times
3427        without docking. The ring fills, grows and carries the verb itself, which
3428        puts the answer where the pilot is already looking.
3429        """
3430        target = self._beacon_position
3431        if target is None or not self.is_element_visible(HudElements.BEACON):
3432            return
3433        prompt = self.dock_prompt
3434        armed = bool(prompt)
3435        colour = COLOUR_BEACON_ARMED if armed else COLOUR_BEACON
3436        radius = BEACON_RADIUS_PX * scale * (BEACON_ARMED_RADIUS_MULT if armed else 1.0)
3437        px, py = self._marker_point(target, rect, BEACON_EDGE_MARGIN_PX * scale, scale)
3438        half = MARKER_LABEL_HALF_WIDTH_PX * scale
3439        # An armed bay carries the verb rather than a range, and the verb is the
3440        # answer to "why am I sitting in this ring": it never stands down.
3441        row = self._marker_label_y(
3442            py + MARKER_LABEL_GAP_PX * scale, px, half, self._marker_rows, rect, scale, required=armed
3443        )
3444        if row is None:
3445            return
3446        # The ring's armed form: filled, with a second ring standing off it.
3447        if armed:
3448            renderer.circle((px, py), radius, fill=colour, segments=4)
3449            renderer.circle((px, py), radius * 1.6, stroke=colour, segments=4)
3450        else:
3451            renderer.circle((px, py), radius, stroke=colour, segments=4)
3452        renderer.text(
3453            prompt if armed else self._range_label(self._beacon_label, target),
3454            fill=colour,
3455            scale=(BEACON_ARMED_FONT_SCALE if armed else BEACON_FONT_SCALE) * scale,
3456            align="centre",
3457            valign="centre",
3458            rect=(self._marker_label_left(px, half, rect, scale), row, 2.0 * half, WARNING_LINE_HEIGHT_PX * scale),
3459        )
3460
3461    def _float_target_point(self, name: str, rect, scale: float):
3462        """Where on screen the counter *name* is drawn, or None when it is not.
3463
3464        The strip under the ring is the target, because the hold lives there: a
3465        label flying a hand's width to a number beside the ship reads as
3466        income, where the same label crossing the whole screen to a corner
3467        mostly reads as something leaving.
3468        """
3469        if name != FLOAT_TARGET_SCRAP:
3470            return None
3471        cells = self._resource_cells()
3472        if not cells or not self.is_element_visible(HudElements.SCRAP):
3473            return None
3474        cx, cy = self._ship_screen_position(rect)
3475        left = cx - RESOURCE_CELL_PX * scale * len(cells) * 0.5
3476        return (
3477            left + RESOURCE_CELL_PX * scale * 0.5,
3478            cy + (BAND_RESOURCES_PX + RESOURCE_LINE_PX * 0.5) * scale,
3479        )
3480
3481    def _float_lane(self, point, width: float, line: float, readings, rect, scale: float) -> float:
3482        """How far sideways a rising float steps to clear the ship's own readings.
3483
3484        A rising label sweeps :data:`FLOAT_TEXT_RISE_PX` upward from where it
3485        landed, and the readings around the hull sit in a narrow column in the
3486        middle of that sweep. The burn float is the case that matters: it is
3487        anchored on the ship it just hit, and would rise straight through ``HULL``
3488        on the one frame where the hull number had something to say. The lane
3489        is decided from the whole flight rather than from this frame's row, so
3490        a label picks a side once and holds it instead of hopping sideways
3491        half way up.
3492
3493        Only labels planted in the world get a lane. One pinned to a node is a
3494        label *about* that node, and a tag that steps off the enemy it names is
3495        a tag pointing at nothing; those keep their anchor and their overlap.
3496
3497        Zero when nothing is in the way, which is almost always.
3498        """
3499        if not readings:
3500            return 0.0
3501        left = point[0] - width * 0.5
3502        right = point[0] + width * 0.5
3503        bottom = point[1]
3504        top = point[1] - FLOAT_TEXT_RISE_PX * scale - line
3505        blocked = [
3506            plate
3507            for plate in readings
3508            if left < plate[0] + plate[2] and right > plate[0] and top < plate[1] + plate[3] and bottom > plate[1]
3509        ]
3510        if not blocked:
3511            return 0.0
3512        wanted_right = max(plate[0] + plate[2] for plate in blocked) + width * 0.5 - point[0]
3513        wanted_left = min(plate[0] for plate in blocked) - width * 0.5 - point[0]
3514        x, _y, w, _h = rect
3515        if point[0] + wanted_right + width * 0.5 <= x + w:
3516            return wanted_right
3517        if point[0] + wanted_left - width * 0.5 >= x:
3518            return wanted_left
3519        return wanted_right if abs(wanted_right) <= abs(wanted_left) else wanted_left
3520
3521    def _draw_float_texts(self, renderer, rect, scale: float) -> None:
3522        """Draw the world-anchored labels, flying to the counter they credit.
3523
3524        A label with no counter rises and fades where it landed, which is what
3525        a first-sighting tag or a refill wants. One that names a counter eases
3526        across to it instead, so the number in the corner visibly grows because
3527        of the thing the pilot just flew through.
3528
3529        A riser that would sweep through one of the ship's own readings takes a
3530        lane beside it (:meth:`_float_lane`): the reading is what the label is
3531        there to explain, and a label written over it explains nothing.
3532        """
3533        if not self._floats or not self.is_element_visible(HudElements.FLOAT_TEXT):
3534            return
3535        line = WARNING_LINE_HEIGHT_PX * scale
3536        # Measured on the first label that could need a lane, and usually never.
3537        readings: list[tuple[float, float, float, float]] | None = None
3538        for text, remaining, total, colour, reference, anchor, toward, plate in self._floats:
3539            node = reference() if reference is not None else None
3540            world = anchor
3541            live = getattr(node, "world_position", None) if getattr(node, "tree", None) is not None else None
3542            if live is not None:
3543                world = Vec3(live)
3544            if world is None:
3545                continue
3546            point = self._project(world, rect)
3547            if point is None:
3548                continue
3549            travelled = 1.0 - remaining / total
3550            destination = self._float_target_point(toward, rect, scale) if toward else None
3551            planted = destination is None and live is None
3552            # Measured only when it is going to be used, for the plate or for
3553            # the lane: this runs per label per frame.
3554            plate_width = (
3555                _plate_width(text, FLOAT_TEXT_FONT_SCALE, scale, SUBTITLE_PLATE_PADDING_X_PX, rect[2])
3556                if plate or planted
3557                else 0.0
3558            )
3559            if destination is None:
3560                px, py = point[0], point[1] - FLOAT_TEXT_RISE_PX * scale * travelled
3561                if planted:
3562                    if readings is None:
3563                        cx, cy = self._ship_screen_position(rect)
3564                        readings = self.reading_plate_rects(cx, cy, scale)
3565                    px += self._float_lane(point, plate_width, line, readings, rect, scale)
3566            else:
3567                flight = travelled**FLOAT_TEXT_FLIGHT_EASE
3568                px = point[0] + (destination[0] - point[0]) * flight
3569                py = point[1] + (destination[1] - point[1]) * flight
3570            alpha = min(1.0, remaining / FLOAT_TEXT_FADE_S)
3571            if plate:
3572                plate_padding = SUBTITLE_PLATE_PADDING_Y_PX * scale
3573                renderer.rect(
3574                    (px - plate_width * 0.5, py - line - plate_padding),
3575                    (plate_width, line + 2.0 * plate_padding),
3576                    fill=_with_alpha(COLOUR_SUBTITLE_PLATE, alpha),
3577                )
3578            renderer.text(
3579                text,
3580                fill=_with_alpha(colour, alpha),
3581                scale=FLOAT_TEXT_FONT_SCALE * scale,
3582                align="centre",
3583                valign="centre",
3584                rect=(px - 140.0 * scale, py - line, 280.0 * scale, line),
3585            )
3586
3587    def _band_occupancy(self, rect, scale: float) -> list[tuple[float, float]]:
3588        """Every row under the hull that is spoken for this frame, outward from the ring.
3589
3590        The standing lines (:meth:`_standing_bands`) and the failed spool's own
3591        name, which is not standing but is written into the same column and is
3592        up long enough to be landed on.
3593        """
3594        spans = self._standing_bands(rect, scale)
3595        outcome = self.warp_outcome_row(rect, scale)
3596        if outcome is not None:
3597            spans.append(outcome)
3598            spans.sort()
3599        return spans
3600
3601    def _standing_bands(self, rect, scale: float) -> list[tuple[float, float]]:
3602        """Top and bottom of every standing line the HUD is writing under the hull.
3603
3604        Ordered outward from the ring, and only what is actually on screen this
3605        frame: a band reserved for a warning nobody is having is free ground,
3606        and the message line is what uses it. The plate under the resource strip
3607        is part of the strip's extent, because the plate is what the eye reads
3608        as the edge of the row.
3609        """
3610        _cx, cy = self._ship_screen_position(rect)
3611        spans: list[tuple[float, float]] = []
3612        cells = self._resource_cells()
3613        pips = self.state_pips()
3614        if cells or pips:
3615            pad = RESOURCE_PLATE_PADDING_PX * scale
3616            height = (RESOURCE_LINE_PX * bool(cells) + STATE_PIP_LINE_PX * bool(pips)) * scale
3617            top = cy + BAND_RESOURCES_PX * scale - pad
3618            spans.append((top, top + height + 2.0 * pad))
3619        line = WARNING_LINE_HEIGHT_PX * scale
3620        if self._warp_chip_is_up():
3621            top = cy + BAND_CONTEXT_PX * scale
3622            spans.append((top, top + line))
3623        warnings = len(self._warning_lines())
3624        if warnings:
3625            top = cy + BAND_WARNING_PX * scale
3626            spans.append((top, top + line * warnings))
3627        if self._objective_line(rect, scale):
3628            top = self._band_top(BAND_OBJECTIVE_PX, rect, scale)
3629            spans.append((top, top + line))
3630        if self._boundary_line(rect, scale):
3631            top = self._band_top(BAND_BOUNDARY_PX, rect, scale)
3632            spans.append((top, top + line))
3633        return spans
3634
3635    def message_row(self, rect, scale: float) -> float:
3636        """The row the transient answer line owns this frame.
3637
3638        The answer to a key press is anchored to the screen and the ship's
3639        standing lines are anchored to the hull, so the two cross whenever the
3640        pilot flies at the height that brings them together, which would
3641        write the chart refusal straight through
3642        OBJECTIVE. The message keeps its usual row and steps up past whatever
3643        standing line is in it, taking the first free row it finds. When the
3644        stack under the hull leaves no free row at all, it goes above the ship's
3645        whole cluster instead, and the tutorial card yields to it there
3646        (:meth:`_top_band_ceiling`).
3647        """
3648        x, y, w, h = rect
3649        del x, w
3650        line = TOAST_LINE_HEIGHT_PX * scale
3651        gap = MESSAGE_ROW_GAP_PX * scale
3652        row = y + h - SUBTITLE_BOTTOM_MARGIN_PX * scale - TOAST_GAP_PX * scale
3653        row -= SUBTITLE_LINE_HEIGHT_PX * scale * SUBTITLE_MAX_LINES + line
3654        _cx, cy = self._ship_screen_position(rect)
3655        floor = cy + BAND_RESOURCES_PX * scale - gap - line
3656        for top, bottom in reversed(self._band_occupancy(rect, scale)):
3657            if row < bottom + gap and row + line > top - gap:
3658                row = top - gap - line
3659        if row < floor:
3660            return self.status_band_top(rect, scale) - gap - line
3661        return row
3662
3663    def _message_in_top_band(self, rect, scale: float) -> bool:
3664        """Whether the message line has been pushed above the ship's cluster."""
3665        if self._toast_remaining <= 0.0 or not self.is_element_visible(HudElements.TOAST):
3666            return False
3667        return self.message_row(rect, scale) < self.status_band_top(rect, scale)
3668
3669    def _draw_toast(self, renderer, rect, scale: float) -> None:
3670        """The answer to the last key press, on its own plate above the captions.
3671
3672        It gets the caption track's treatment because it is read the same way:
3673        one line, glanced at mid-manoeuvre, over whatever the sector happens to
3674        be drawing. Unbacked and dimmed it would lose every contest with the
3675        standing lines under the hull, and a refusal a pilot cannot read is a
3676        refusal they file as a broken key.
3677        """
3678        text = self.toast
3679        if not text or not self.is_element_visible(HudElements.TOAST):
3680            return
3681        x, _y, w, _h = rect
3682        line = TOAST_LINE_HEIGHT_PX * scale
3683        base = self.message_row(rect, scale)
3684        alpha = min(1.0, self._toast_remaining / TOAST_FADE_S)
3685        plate_width = _plate_width(text, TOAST_FONT_SCALE, scale, SUBTITLE_PLATE_PADDING_X_PX, w)
3686        plate_padding = SUBTITLE_PLATE_PADDING_Y_PX * scale
3687        renderer.rect(
3688            (x + (w - plate_width) * 0.5, base - plate_padding),
3689            (plate_width, line + 2.0 * plate_padding),
3690            fill=_with_alpha(COLOUR_SUBTITLE_PLATE, alpha),
3691        )
3692        renderer.text(
3693            text,
3694            fill=_with_alpha(_opaque(COLOUR_TOAST), alpha),
3695            scale=TOAST_FONT_SCALE * scale,
3696            align="centre",
3697            valign="centre",
3698            rect=(x, base, w, line),
3699        )
3700
3701    def ring_labels(self) -> tuple[tuple[str, str], ...]:
3702        """The gauge captions as ``(compass_point, text)``, in drawing order.
3703
3704        Hull and capacitor carry their number; oxygen and fuel do not, because
3705        theirs is in the resource strip a hand's width below.
3706        """
3707        labels = []
3708        for element, _centre, _span, _colour, caption, compass in _ARC_SPECS:
3709            if not self.is_element_visible(element):
3710                continue
3711            if element in _ARC_VALUE_ELEMENTS:
3712                caption = ARC_LABEL_WITH_VALUE.format(caption=caption, value=self._levels[element])
3713            labels.append((compass, caption))
3714        return tuple(labels)
3715
3716    @staticmethod
3717    def _arc_label_rect(compass: str, cx: float, cy: float, scale: float) -> tuple[tuple, str]:
3718        """Where a ring caption on *compass* is written, and how it is aligned.
3719
3720        The side captions hang outward from the rings and are aligned to the
3721        edge nearest the hull, so the type grows away from the ship instead of
3722        into it. The top and bottom ones are centred above and below every ring
3723        that reaches their bearing.
3724        """
3725        box = ARC_LABEL_BOX_PX * scale
3726        line = ARC_LABEL_LINE_PX * scale
3727        side = ARC_LABEL_RADIUS_PX * scale
3728        if compass == "left":
3729            return ((cx - side - box, cy - line * 0.5, box, line), "right")
3730        if compass == "right":
3731            return ((cx + side, cy - line * 0.5, box, line), "left")
3732        if compass == "top":
3733            return ((cx - box * 0.5, cy - ARC_LABEL_TOP_RADIUS_PX * scale - line, box, line), "centre")
3734        return ((cx - box * 0.5, cy + side, box, line), "centre")
3735
3736    def reading_plate_rects(self, cx: float, cy: float, scale: float) -> list[tuple[float, float, float, float]]:
3737        """Backing plates for the readings around the hull, as ``(x, y, w, h)``.
3738
3739        Every caption the ring cluster writes and the signature reading above
3740        it. Sized to the type rather than to the caption box, so the plates read
3741        as chips behind the numbers rather than as a panel drawn around the
3742        ship. Public because the thing they exist to survive is a rendered
3743        frame, and a test that cannot ask where they are can only assert that
3744        some rects were drawn.
3745        """
3746        pad_x = READING_PLATE_PADDING_X_PX * scale
3747        pad_y = READING_PLATE_PADDING_Y_PX * scale
3748        line = ARC_LABEL_LINE_PX * scale
3749        plates: list[tuple[float, float, float, float]] = []
3750        for compass, text in self.ring_labels():
3751            box, alignment = self._arc_label_rect(compass, cx, cy, scale)
3752            width = _plate_width(text, ARC_LABEL_FONT_SCALE, scale, READING_PLATE_PADDING_X_PX, box[2] + 2.0 * pad_x)
3753            if alignment == "right":
3754                left = box[0] + box[2] - width
3755            elif alignment == "left":
3756                left = box[0]
3757            else:
3758                left = box[0] + (box[2] - width) * 0.5
3759            plates.append((left, box[1] - pad_y, width, line + 2.0 * pad_y))
3760        if self.is_element_visible(HudElements.SIGNATURE):
3761            text = SIGNATURE_LABEL.format(value=self._signature, maximum=balance.SIGNATURE_MAX)
3762            width = _plate_width(text, SIGNATURE_LABEL_FONT_SCALE, scale, READING_PLATE_PADDING_X_PX, 320.0 * scale)
3763            top = cy - (ARC_RADIUS_PX + SIGNATURE_ARC_GAP_PX + SIGNATURE_LABEL_GAP_PX) * scale
3764            line = SIGNATURE_LABEL_LINE_PX * scale
3765            plates.append((cx - width * 0.5, top - line - pad_y, width, line + 2.0 * pad_y))
3766        return plates
3767
3768    def _draw_reading_plates(self, renderer, cx: float, cy: float, scale: float) -> None:
3769        """Lay the reading plates down before anything is written on them."""
3770        for x, y, width, height in self.reading_plate_rects(cx, cy, scale):
3771            renderer.rect((x, y), (width, height), fill=COLOUR_READING_PLATE)
3772
3773    def _draw_gauge_arcs(self, renderer, cx: float, cy: float, scale: float) -> None:
3774        radius = ARC_RADIUS_PX * scale
3775        thickness = ARC_THICKNESS_PX * scale
3776        texts = dict(self.ring_labels())
3777        blacked = self.capacitor_blacked_out
3778        for element, centre_deg, span_deg, colour, _caption, compass in _ARC_SPECS:
3779            if not self.is_element_visible(element):
3780                continue
3781            if element == HudElements.CAPACITOR and blacked:
3782                colour = COLOUR_BLACKOUT
3783            start = centre_deg - span_deg * 0.5
3784            _draw_arc(renderer, cx, cy, radius, start, span_deg, thickness, COLOUR_TRACK)
3785            filled = span_deg * self._gauges[element]
3786            if filled > 0.0:
3787                _draw_arc(renderer, cx, cy, radius, start, filled, thickness, colour)
3788            if element == HudElements.HULL:
3789                chip = span_deg * (self.hull_chip - self._gauges[element])
3790                if chip > 0.0:
3791                    # Drawn proud of the ring as well as in its own colour, so
3792                    # the loss is a shape change and not only a hue change.
3793                    chip_thickness = thickness * HULL_CHIP_THICKNESS_MULT
3794                    _draw_arc(renderer, cx, cy, radius, start + filled, chip, chip_thickness, COLOUR_HULL_CHIP)
3795            box, alignment = self._arc_label_rect(compass, cx, cy, scale)
3796            renderer.text(
3797                texts[compass],
3798                fill=_opaque(colour),
3799                scale=ARC_LABEL_FONT_SCALE * scale,
3800                align=alignment,
3801                valign="centre",
3802                rect=box,
3803            )
3804
3805    def _draw_shield_arc(self, renderer, cx: float, cy: float, scale: float) -> None:
3806        """Draw the shield pad on the bearing it is actually covering.
3807
3808        Q and E move it, and an arc that existed only in the collision maths
3809        would make the two keys read as unbound. It rides just outside the gauge ring and turns red once the
3810        pad is broken, which is the other state the pilot has to know about.
3811        """
3812        shield = getattr(self._ship, "shield", None)
3813        bearing = self.shield_bearing
3814        if shield is None or bearing is None:
3815            return
3816        span = math.degrees(float(shield.half_width)) * 2.0
3817        radius = (ARC_RADIUS_PX + SHIELD_ARC_GAP_PX) * scale
3818        broken = bool(getattr(shield, "broken", False))
3819        colour = COLOUR_SHIELD_BROKEN if broken else COLOUR_SHIELD
3820        charge = _fraction(float(getattr(shield, "charge", 0.0)), float(getattr(shield, "charge_max", 0.0)))
3821        _draw_arc(renderer, cx, cy, radius, bearing - span * 0.5, span, SHIELD_ARC_THICKNESS_PX * scale, COLOUR_TRACK)
3822        filled = span * (0.0 if broken else charge)
3823        if filled > 0.0:
3824            _draw_arc(renderer, cx, cy, radius, bearing - span * 0.5, filled, SHIELD_ARC_THICKNESS_PX * scale, colour)
3825        elif broken:
3826            _draw_arc(renderer, cx, cy, radius, bearing - span * 0.5, span, SHIELD_ARC_THICKNESS_PX * scale, colour)
3827
3828    def _draw_resource_strip(self, renderer, cx: float, cy: float, scale: float) -> None:
3829        """The numbers the run charges and credits, in one row under the ring.
3830
3831        Two rows, in fact: the resources on top and the power-state pips under
3832        them, both in fixed cells so a number growing a digit never shoves its
3833        neighbours sideways.
3834
3835        Both rows sit on a faint plate. They are drawn over the playfield rather
3836        than over a panel, and a bright nebula or a wreck drifting under them
3837        would take the row with it, leaving the word SCRAP without its
3838        count. The plate is the cheapest thing that makes
3839        a number in front of the world stay a number.
3840        """
3841        cells = self._resource_cells()
3842        self._draw_resource_plate(renderer, cx, cy, scale, cells)
3843        if cells:
3844            self._draw_cells(
3845                renderer,
3846                cx,
3847                cy + BAND_RESOURCES_PX * scale,
3848                scale,
3849                cells,
3850                RESOURCE_CELL_PX,
3851                RESOURCE_LINE_PX,
3852                RESOURCE_FONT_SCALE,
3853            )
3854        pips = self.state_pips()
3855        if pips:
3856            self._draw_cells(
3857                renderer,
3858                cx,
3859                cy + (BAND_RESOURCES_PX + RESOURCE_LINE_PX) * scale,
3860                scale,
3861                [(text, COLOUR_STATE_PIP_ON if lit else COLOUR_STATE_PIP_OFF) for text, lit in pips],
3862                STATE_PIP_CELL_PX,
3863                STATE_PIP_LINE_PX,
3864                STATE_PIP_FONT_SCALE,
3865            )
3866
3867    def _draw_resource_plate(self, renderer, cx: float, cy: float, scale: float, cells) -> None:
3868        """The backing plate under both rows of the strip, sized to the wider one."""
3869        pips = self.state_pips()
3870        width = max(RESOURCE_CELL_PX * len(cells), STATE_PIP_CELL_PX * len(pips)) * scale
3871        if width <= 0.0:
3872            return
3873        pad = RESOURCE_PLATE_PADDING_PX * scale
3874        height = (RESOURCE_LINE_PX * bool(cells) + STATE_PIP_LINE_PX * bool(pips)) * scale
3875        renderer.rect(
3876            (cx - width * 0.5 - pad, cy + BAND_RESOURCES_PX * scale - pad),
3877            (width + 2.0 * pad, height + 2.0 * pad),
3878            fill=COLOUR_RESOURCE_PLATE,
3879        )
3880
3881    @staticmethod
3882    def _draw_cells(renderer, cx: float, top: float, scale: float, cells, cell_px, line_px, font) -> None:
3883        """Lay *cells* out as one centred row of fixed-width boxes."""
3884        cell = cell_px * scale
3885        line = line_px * scale
3886        left = cx - cell * len(cells) * 0.5
3887        for index, (text, colour) in enumerate(cells):
3888            renderer.text(
3889                text,
3890                fill=colour,
3891                scale=font * scale,
3892                align="centre",
3893                valign="centre",
3894                rect=(left + index * cell, top, cell, line),
3895            )
3896
3897    def _draw_affordance(self, renderer, cx: float, cy: float, scale: float) -> None:
3898        """The one verb in reach, above the gauge ring and under the reticle.
3899
3900        Stands down when the bay's ring is already carrying this very sentence
3901        (:meth:`dock_prompt`): the ring is beside the ship the whole time the
3902        hull is inside it, and the same three words printed twice a hand's
3903        width apart read as a stutter rather than as emphasis.
3904        """
3905        text = self.affordance
3906        if not text or not self.is_element_visible(HudElements.AFFORDANCE):
3907            return
3908        if text == self.dock_prompt and self.is_element_visible(HudElements.BEACON):
3909            return
3910        line = AFFORDANCE_LINE_PX * scale
3911        top = cy - (SIGNATURE_GAUGE_TOP_PX + AFFORDANCE_GAP_PX) * scale - line
3912        renderer.text(
3913            text,
3914            fill=COLOUR_AFFORDANCE,
3915            scale=AFFORDANCE_FONT_SCALE * scale,
3916            align="centre",
3917            valign="centre",
3918            rect=(cx - 260.0 * scale, top, 520.0 * scale, line),
3919        )
3920
3921    def warp_chip_plate_rect(self, cx: float, cy: float, scale: float) -> tuple[float, float, float, float] | None:
3922        """The plate behind the drive's chip as ``(x, y, w, h)``, None when it is down.
3923
3924        Public for the reason the readings' plates are (:meth:`reading_plate_rects`):
3925        the thing it exists to survive is a rendered frame, and what it has to
3926        survive is drawn a moment before it.
3927        """
3928        if not self._warp_chip_is_up():
3929            return None
3930        width = _plate_width(
3931            self._warp_status,
3932            WARP_STATUS_FONT_SCALE,
3933            scale,
3934            WARP_STATUS_PLATE_PADDING_X_PX,
3935            BAND_HALF_WIDTH_PX * 2.0 * scale,
3936        )
3937        return (cx - width * 0.5, cy + BAND_CONTEXT_PX * scale, width, WARNING_LINE_HEIGHT_PX * scale)
3938
3939    def _draw_warp_status(self, renderer, cx: float, cy: float, scale: float) -> None:
3940        """The drive's standing answer, in the context band under the ship.
3941
3942        Suppressed while a spool is actually running: the ring and its own
3943        label are the live version of the same sentence. It is written on a
3944        plate, and the plate is what the failed ring passes behind: the refusal
3945        and the red ring that announces it are on screen together, and the ring
3946        holds the outer radius, which is this row.
3947        """
3948        if not self._warp_chip_is_up():
3949            return
3950        plate = self.warp_chip_plate_rect(cx, cy, scale)
3951        if plate is not None:
3952            x, y, width, height = plate
3953            renderer.rect((x, y), (width, height), fill=COLOUR_WARP_STATUS_PLATE)
3954        colour = COLOUR_WARP_STATUS_BLOCKED if self._warp_status_blocked else COLOUR_WARP_STATUS
3955        self._draw_band(renderer, cx, cy, scale, BAND_CONTEXT_PX, [(self._warp_status, colour)], WARP_STATUS_FONT_SCALE)
3956
3957    def _draw_controls_bar(self, renderer, rect, scale: float) -> None:
3958        """The persistent key list along the bottom edge, on its own plate.
3959
3960        Two rows on a dark plate. Dim letters straight over a star field turn
3961        the strip into a mote-speckled smear, and one row can only ever name
3962        half the keyboard.
3963        """
3964        rows = [row for row in (self.controls_bar, self.controls_bar_second_row) if row]
3965        if not rows or not self.is_element_visible(HudElements.CONTROLS_BAR):
3966            return
3967        x, y, w, h = rect
3968        line = CONTROLS_BAR_LINE_PX * scale
3969        padding = CONTROLS_BAR_PLATE_PADDING_PX * scale
3970        plate_height = line * len(rows) + 2.0 * padding
3971        plate_top = y + h - CONTROLS_BAR_MARGIN_PX * scale - plate_height
3972        renderer.rect((x, plate_top), (w, plate_height), fill=COLOUR_CONTROLS_BAR_PLATE)
3973        for index, row in enumerate(rows):
3974            renderer.text(
3975                row,
3976                fill=COLOUR_CONTROLS_BAR,
3977                scale=CONTROLS_BAR_FONT_SCALE * scale,
3978                align="centre",
3979                valign="centre",
3980                rect=(x, plate_top + padding + index * line, w, line),
3981            )
3982
3983    def _draw_warp_ring(self, renderer, rect, cx: float, cy: float, scale: float) -> None:
3984        if not self._warp_active or not self.is_element_visible(HudElements.WARP_SPOOL):
3985            return
3986        closed = self.warp_spool_fraction
3987        outer = WARP_RING_START_RADIUS_PX * scale
3988        inner = ARC_RADIUS_PX * scale
3989        radius = outer + (inner - outer) * closed
3990        if self._warp_stutter > 0.0:
3991            radius += (
3992                self._warp_stutter
3993                * WARP_STUTTER_AMPLITUDE_PX
3994                * scale
3995                * math.sin(self._elapsed * WARP_STUTTER_HZ * math.tau)
3996            )
3997        radius = max(1.0, radius)
3998        renderer.circle((cx, cy), radius, stroke=COLOUR_WARP_RING, segments=64)
3999        self._draw_warp_label(renderer, rect, cx, cy, radius, scale)
4000
4001    def _warp_label_top(
4002        self, rect, cy: float, radius: float, scale: float, lines: int, *, standing_only: bool = False
4003    ) -> float:
4004        """Where the ring's own caption starts, clear of the standing lines.
4005
4006        The caption rides the ring inward, so the row it lands on changes every
4007        frame and sweeps straight through the band under the hull on the way,
4008        where ``ICE FIELD for 15 fuel`` written through
4009        ``LOW FUEL: depots sell fuel cells`` leaves neither readable. It is
4010        pushed outward rather than in, because inward is the hull, the gauge
4011        ring and every reading on it. Outward is empty screen.
4012
4013        *standing_only* leaves the ending's own name out of what is stepped
4014        around, which is what the ending's name has to ask for: it is in the
4015        occupancy itself, and a line that steps around itself walks off the
4016        bottom of the screen.
4017        """
4018        top = cy + radius + WARP_LABEL_GAP_PX * scale
4019        block = WARP_LABEL_LINE_PX * scale * lines
4020        gap = MESSAGE_ROW_GAP_PX * scale
4021        # Outward in order, so a push clear of one band is tested against the
4022        # next one out before it is used.
4023        bands = self._standing_bands(rect, scale) if standing_only else self._band_occupancy(rect, scale)
4024        for band_top, band_bottom in bands:
4025            if top < band_bottom + gap and top + block > band_top - gap:
4026                top = band_bottom + gap
4027        return top
4028
4029    def _draw_warp_label(self, renderer, rect, cx: float, cy: float, radius: float, scale: float) -> None:
4030        """Say what the ring is: the drive, the destination, the price, the abort.
4031
4032        A closing circle is a countdown to nothing until it is named. The
4033        lines sit under the ring so they travel inward with it and never cross
4034        the hull in the middle, stepping outward past whatever standing line
4035        the ring's own radius has put them in (:meth:`_warp_label_top`).
4036        """
4037        line = WARP_LABEL_LINE_PX * scale
4038        lines = [WARP_LABEL]
4039        if self._warp_target:
4040            quoted = self.warp_price
4041            price = WARP_LABEL_PRICE.format(price=quoted) if quoted else ""
4042            lines.append(f"{self._warp_target}{price}")
4043        lines.append(WARP_ABORT_HINT)
4044        top = self._warp_label_top(rect, cy, radius, scale, len(lines))
4045        for index, text in enumerate(lines):
4046            renderer.text(
4047                text,
4048                fill=COLOUR_WARP_RING,
4049                scale=WARP_LABEL_FONT_SCALE * scale,
4050                align="centre",
4051                valign="centre",
4052                rect=(cx - 200.0 * scale, top + index * line, 400.0 * scale, line),
4053            )
4054        if self._warp_interrupt_remaining <= 0.0:
4055            return
4056        renderer.text(
4057            self._warp_interrupt_text,
4058            fill=_with_alpha(COLOUR_WARNING, min(1.0, self._warp_interrupt_remaining / TOAST_FADE_S)),
4059            scale=WARP_INTERRUPT_FONT_SCALE * scale,
4060            align="centre",
4061            valign="centre",
4062            rect=(cx - 200.0 * scale, cy - radius - WARP_LABEL_GAP_PX * scale - line, 400.0 * scale, line),
4063        )
4064
4065    def _warp_outcome_radius(self, scale: float) -> float:
4066        """The radius the ending flash holds: where the ring was when it stopped."""
4067        outer = WARP_RING_START_RADIUS_PX * scale
4068        inner = ARC_RADIUS_PX * scale
4069        return max(1.0, outer + (inner - outer) * self._warp_outcome_fraction)
4070
4071    def warp_outcome_row(self, rect, scale: float) -> tuple[float, float] | None:
4072        """Top and bottom of the ending's name, or None when no ending is flashing.
4073
4074        The flash and the answer line are both transient, both centred and both
4075        raised by the same key press, and neither knows about the other, so a
4076        refusal can put the toast plate's top edge through the bottom half of
4077        ``SPOOL ABORTED``. This is what carries the name into
4078        :meth:`_band_occupancy`, so the message row steps around it exactly as
4079        it steps around a standing line.
4080        """
4081        if self._warp_outcome_remaining <= 0.0 or not self._warp_outcome_text:
4082            return None
4083        if not self.is_element_visible(HudElements.WARP_SPOOL):
4084            return None
4085        _cx, cy = self._ship_screen_position(rect)
4086        top = self._warp_label_top(rect, cy, self._warp_outcome_radius(scale), scale, 1, standing_only=True)
4087        return (top, top + WARP_LABEL_LINE_PX * scale)
4088
4089    def _draw_warp_outcome(self, renderer, rect, cx: float, cy: float, scale: float) -> None:
4090        """Hold the ring in red on the ending it just had, and name it.
4091
4092        Drawn where the ring was when it stopped, so the flash reads as that
4093        ring failing rather than as a new element appearing from nowhere. The
4094        name under it steps outward past the standing lines the way the live
4095        ring's caption does, and the answer line steps around the name.
4096        """
4097        row = self.warp_outcome_row(rect, scale)
4098        if row is None:
4099            return
4100        radius = self._warp_outcome_radius(scale)
4101        alpha = min(1.0, self._warp_outcome_remaining / WARP_OUTCOME_FLASH_S)
4102        colour = _with_alpha(COLOUR_WARP_FAILED, alpha)
4103        renderer.circle((cx, cy), radius, stroke=colour, segments=64)
4104        renderer.text(
4105            self._warp_outcome_text,
4106            fill=colour,
4107            scale=WARP_LABEL_FONT_SCALE * scale,
4108            align="centre",
4109            valign="centre",
4110            rect=(cx - 260.0 * scale, row[0], 520.0 * scale, row[1] - row[0]),
4111        )
4112
4113    def _draw_jump_flash(self, renderer, rect) -> None:
4114        """White out the screen for the instant the drive actually jumps."""
4115        if self._warp_jump_flash <= 0.0 or not self.is_element_visible(HudElements.WARP_SPOOL):
4116            return
4117        x, y, w, h = rect
4118        alpha = WARP_JUMP_FLASH_ALPHA * min(1.0, self._warp_jump_flash / WARP_JUMP_FLASH_S)
4119        renderer.rect((x, y), (w, h), fill=_with_alpha(COLOUR_JUMP_FLASH, alpha))
4120
4121    def _draw_interact_fill(self, renderer, cx: float, cy: float, scale: float) -> None:
4122        if self._interact_fraction <= 0.0 or not self.is_element_visible(HudElements.INTERACT_FILL):
4123            return
4124        _draw_arc(
4125            renderer,
4126            cx,
4127            cy,
4128            INTERACT_FILL_RADIUS_PX * scale,
4129            -90.0,
4130            360.0 * self._interact_fraction,
4131            ARC_THICKNESS_PX * scale,
4132            COLOUR_INTERACT,
4133        )
4134
4135    def _top_left_readouts(self) -> list[str]:
4136        """The corner stack above the notoriety line, top down.
4137
4138        The hold is not here: it is in the strip under the ring, where it is
4139        read at a glance beside the fuel it competes with. Two copies of one
4140        number is one copy too many, and the corner is the one a pilot never
4141        looks at.
4142        """
4143        if not self.is_element_visible(HudElements.NOTORIETY):
4144            return []
4145        return [f"NOTORIETY {self._notoriety}"]
4146
4147    def _bottom_left_readouts(self) -> list[str]:
4148        """The power postures standing in the bottom corner, top down.
4149
4150        Ammunition is not here either: it is a cell in the strip, beside the
4151        other numbers the run spends, for the same reason the hold is there.
4152        """
4153        lines: list[str] = []
4154        if self.is_element_visible(HudElements.GENERATOR) and self._generator_running:
4155            lines.append("GENERATOR")
4156        if self.is_element_visible(HudElements.SILENT_RUNNING) and self._silent_running:
4157            lines.append("SILENT RUNNING")
4158        return lines
4159
4160    def _bottom_left_base(self, rect, scale: float) -> float:
4161        """The top of the bottom-left readout stack.
4162
4163        The controls strip owns the bottom edge whenever it is up, so the stack
4164        starts above its band rather than through it.
4165        """
4166        _x, y, _w, h = rect
4167        floor = CONTROLS_BAR_BAND_PX if self.controls_bar else 0.0
4168        return (
4169            y
4170            + h
4171            - READOUT_MARGIN_PX * scale
4172            - floor * scale
4173            - READOUT_LINE_HEIGHT_PX * scale * len(self._bottom_left_readouts())
4174        )
4175
4176    def _draw_readouts(self, renderer, rect, scale: float) -> None:
4177        x, y, _w, _h = rect
4178        margin = READOUT_MARGIN_PX * scale
4179        line = READOUT_LINE_HEIGHT_PX * scale
4180        font = READOUT_FONT_SCALE * scale
4181
4182        for index, text in enumerate(self._top_left_readouts()):
4183            renderer.text(text, (x + margin, y + margin + index * line), fill=COLOUR_READOUT, scale=font)
4184
4185        base = self._bottom_left_base(rect, scale)
4186        for index, text in enumerate(self._bottom_left_readouts()):
4187            renderer.text(text, (x + margin, base + index * line), fill=COLOUR_READOUT, scale=font)
4188
4189    def status_band_top(self, rect, scale: float) -> float:
4190        """The topmost pixel the ship's own status band occupies.
4191
4192        The affordance line, the signature reading and the gauge ring are the
4193        ship's band, and nothing in the top band may be drawn over them. A
4194        pilot flying with the mouse high rides the aim lead and puts the hull
4195        near the top of the screen, which is exactly when a fixed tutorial card
4196        would land on ``SIGNATURE LOCKED``.
4197        """
4198        _x, _y, _w, _h = rect
4199        _cx, cy = self._ship_screen_position(rect)
4200        top = cy - ARC_RADIUS_PX * scale
4201        if self.is_element_visible(HudElements.SIGNATURE):
4202            top = min(top, cy - SIGNATURE_GAUGE_TOP_PX * scale)
4203        if self.affordance and self.is_element_visible(HudElements.AFFORDANCE):
4204            top = min(
4205                top,
4206                cy - (SIGNATURE_GAUGE_TOP_PX + AFFORDANCE_GAP_PX) * scale - AFFORDANCE_LINE_PX * scale,
4207            )
4208        return top
4209
4210    def _top_band_ceiling(self, rect, scale: float) -> float:
4211        """The lowest pixel the top band may reach: the status band, or a stamp.
4212
4213        A message that has been pushed above the ship's cluster because the
4214        stack under the hull was full raises the ceiling too, so the card yields
4215        to the answer rather than being written over it.
4216        """
4217        x, y, w, h = rect
4218        del x, w
4219        ceiling = self.status_band_top(rect, scale)
4220        if self._stamp_remaining > 0.0 and self.is_element_visible(HudElements.STAMP):
4221            ceiling = min(ceiling, y + h * STAMP_TOP_FRACTION)
4222        if self._message_in_top_band(rect, scale):
4223            ceiling = min(ceiling, self.message_row(rect, scale) - MESSAGE_ROW_GAP_PX * scale)
4224        return ceiling
4225
4226    def control_card_layout(self, rect, scale: float) -> CardLayout | None:
4227        """Where the control card fits, or None when there is no card up.
4228
4229        The card yields in three steps rather than overprinting: it moves up,
4230        then it drops its hint line, then it shrinks to a compact title. The
4231        ship's status band never moves for it, because a reading that moves
4232        when a tutorial appears is a reading the pilot has to find again.
4233
4234        An arrival card holds the same slot for its two and a half seconds and
4235        the lesson waits: two panels sharing one band would overprint
4236        each other, and a lesson that has been up for a minute can
4237        stand to be off screen while the sector is named.
4238        """
4239        if not self._card_text or not self.is_element_visible(HudElements.CONTROL_CARD):
4240            return None
4241        if self._arrival_remaining > 0.0 and self.is_element_visible(HudElements.ARRIVAL_CARD):
4242            return None
4243        x, y, w, h = rect
4244        preferred = y + h * CONTROL_CARD_TOP_FRACTION
4245        minimum = y + CONTROL_CARD_MIN_TOP_PX * scale
4246        ceiling = self._top_band_ceiling(rect, scale) - CONTROL_CARD_CLEARANCE_PX * scale
4247
4248        hint = CONTROL_CARD_HINT_LINE_HEIGHT_PX * scale if self._card_hint else 0.0
4249        forms = (
4250            (CONTROL_CARD_PADDING_PX, CONTROL_CARD_LINE_HEIGHT_PX, CONTROL_CARD_FONT_SCALE, hint),
4251            (CONTROL_CARD_PADDING_PX, CONTROL_CARD_LINE_HEIGHT_PX, CONTROL_CARD_FONT_SCALE, 0.0),
4252            (CONTROL_CARD_COMPACT_PADDING_PX, CONTROL_CARD_COMPACT_LINE_PX, CONTROL_CARD_COMPACT_FONT_SCALE, 0.0),
4253        )
4254        bar = 0.0 if self._card_progress is None else (CONTROL_CARD_BAR_HEIGHT_PX + CONTROL_CARD_BAR_GAP_PX) * scale
4255        chosen = None
4256        for padding_px, line_px, font, second in forms:
4257            padding = padding_px * scale
4258            line = line_px * scale
4259            width = min(w - 2.0 * padding, CONTROL_CARD_WIDTH_PX * scale)
4260            height = line + second + bar + 2.0 * padding
4261            top = min(preferred, ceiling - height)
4262            chosen = CardLayout(
4263                x=x + (w - width) * 0.5,
4264                y=max(minimum, top),
4265                width=width,
4266                height=height,
4267                padding=padding,
4268                line=line,
4269                font_scale=font,
4270                second_line=second,
4271                second_font_scale=CONTROL_CARD_HINT_FONT_SCALE,
4272                bar=bar,
4273            )
4274            if top >= minimum:
4275                break
4276        if chosen is not None and chosen.bottom > ceiling:
4277            # Nothing fits. A lesson that cannot be drawn without covering the
4278            # hull's own readings steps aside entirely and comes back the
4279            # moment the aim lead lets the hull down the screen again: the
4280            # readings are what keep the pilot alive, and the card is not.
4281            return None
4282        return chosen
4283
4284    def arrival_card_layout(self, rect, scale: float) -> CardLayout | None:
4285        """Where the arrival title card fits, or None when it is not showing.
4286
4287        It owns the top band for its two and a half seconds and yields the same
4288        way the control card does: it drops its flavour line first and then
4289        rides up, so it never lands on the ship's own readings.
4290        """
4291        if self._arrival_remaining <= 0.0 or not self.is_element_visible(HudElements.ARRIVAL_CARD):
4292            return None
4293        x, y, w, h = rect
4294        preferred = y + h * ARRIVAL_CARD_TOP_FRACTION
4295        minimum = y + CONTROL_CARD_MIN_TOP_PX * scale
4296        ceiling = self._top_band_ceiling(rect, scale) - CONTROL_CARD_CLEARANCE_PX * scale
4297
4298        line = ARRIVAL_CARD_LINE_PX * scale
4299        flavour = ARRIVAL_CARD_FLAVOUR_LINE_PX * scale if self._arrival_flavour else 0.0
4300        chosen = None
4301        for second in (flavour, 0.0):
4302            height = line + second
4303            top = min(preferred, ceiling - height)
4304            chosen = CardLayout(
4305                x=x,
4306                y=max(minimum, top),
4307                width=w,
4308                height=height,
4309                padding=0.0,
4310                line=line,
4311                font_scale=ARRIVAL_CARD_FONT_SCALE,
4312                second_line=second,
4313                second_font_scale=ARRIVAL_CARD_FLAVOUR_FONT_SCALE,
4314            )
4315            if top >= minimum:
4316                break
4317        return None if chosen is not None and chosen.bottom > ceiling else chosen
4318
4319    def _draw_control_card(self, renderer, rect, scale: float) -> None:
4320        """Draw the lesson panel: centred, boxed, and in type you cannot miss.
4321
4322        It sits in the upper band of the screen rather than on the centre, so
4323        the panel never covers the ship or the gauge ring around it, and it
4324        yields (:meth:`control_card_layout`) rather than overprinting when the
4325        hull rides high enough to bring its own band up into the same pixels.
4326        """
4327        layout = self.control_card_layout(rect, scale)
4328        if layout is None:
4329            return
4330        renderer.rect((layout.x, layout.y), (layout.width, layout.height), fill=COLOUR_CONTROL_CARD_PANEL)
4331        renderer.rect((layout.x, layout.y), (layout.width, layout.height), stroke=COLOUR_CONTROL_CARD_EDGE)
4332        inner = layout.width - 2.0 * layout.padding
4333        renderer.text(
4334            self._card_text,
4335            fill=COLOUR_CONTROL_CARD_TEXT,
4336            scale=layout.font_scale * scale,
4337            align="centre",
4338            valign="centre",
4339            rect=(layout.x + layout.padding, layout.y + layout.padding, inner, layout.line),
4340            fit_to_width=True,
4341            min_scale=CONTROL_CARD_MIN_FONT_SCALE * scale,
4342        )
4343        if layout.shows_second_line:
4344            renderer.text(
4345                self._card_hint,
4346                fill=COLOUR_CONTROL_CARD_HINT,
4347                scale=layout.second_font_scale * scale,
4348                align="centre",
4349                valign="centre",
4350                rect=(
4351                    layout.x + layout.padding,
4352                    layout.y + layout.padding + layout.line,
4353                    inner,
4354                    layout.second_line,
4355                ),
4356                fit_to_width=True,
4357                min_scale=CONTROL_CARD_MIN_FONT_SCALE * 0.7 * scale,
4358            )
4359        if layout.bar > 0.0 and self._card_progress is not None:
4360            height = CONTROL_CARD_BAR_HEIGHT_PX * scale
4361            top = layout.bottom - layout.padding - height
4362            renderer.rect((layout.x + layout.padding, top), (inner, height), fill=COLOUR_CONTROL_CARD_BAR_TRACK)
4363            filled = inner * _clamp01(self._card_progress)
4364            if filled > 0.0:
4365                renderer.rect((layout.x + layout.padding, top), (filled, height), fill=COLOUR_CONTROL_CARD_BAR)
4366
4367    def _draw_arrival_card(self, renderer, rect, scale: float) -> None:
4368        """Name the place the ship has just arrived in, once, in large type."""
4369        layout = self.arrival_card_layout(rect, scale)
4370        if layout is None:
4371            return
4372        alpha = min(1.0, self._arrival_remaining / ARRIVAL_CARD_FADE_S)
4373        renderer.text(
4374            self._arrival_name,
4375            fill=_with_alpha(COLOUR_ARRIVAL_CARD, alpha),
4376            scale=layout.font_scale * scale,
4377            align="centre",
4378            valign="centre",
4379            rect=(layout.x, layout.y, layout.width, layout.line),
4380        )
4381        if layout.shows_second_line:
4382            renderer.text(
4383                self._arrival_flavour,
4384                fill=_with_alpha(COLOUR_ARRIVAL_FLAVOUR, alpha),
4385                scale=layout.second_font_scale * scale,
4386                align="centre",
4387                valign="centre",
4388                rect=(layout.x, layout.y + layout.line, layout.width, layout.second_line),
4389            )
4390
4391    def _draw_sector_name(self, renderer, rect, scale: float) -> None:
4392        """The standing sector name, top right, opposite the scrap stack."""
4393        if not self._sector_name or not self.is_element_visible(HudElements.SECTOR):
4394            return
4395        x, y, w, _h = rect
4396        margin = READOUT_MARGIN_PX * scale
4397        line = SECTOR_NAME_LINE_PX * scale
4398        width = SECTOR_NAME_BOX_PX * scale
4399        renderer.text(
4400            self._sector_name,
4401            fill=COLOUR_READOUT,
4402            scale=SECTOR_NAME_FONT_SCALE * scale,
4403            align="right",
4404            valign="centre",
4405            rect=(x + w - margin - width, y + margin, width, line),
4406        )
4407
4408    def _draw_frame_stats(self, renderer, rect, scale: float) -> None:
4409        """The developer readout, top left, over everything and under nothing.
4410
4411        Not subject to the onboarding subtraction schedule and not a HUD element:
4412        it is a measuring instrument, so it appears exactly when it is asked for.
4413        """
4414        lines = self.frame_stats_lines() if self._frame_stats else ()
4415        if not lines:
4416            return
4417        x, y, _w, _h = rect
4418        margin = READOUT_MARGIN_PX * scale
4419        line = FRAME_STATS_LINE_PX * scale
4420        width = FRAME_STATS_WIDTH_PX * scale
4421        renderer.rect(
4422            (x + margin - 6.0 * scale, y + margin - 4.0 * scale),
4423            (width + 12.0 * scale, line * len(lines) + 8.0 * scale),
4424            fill=COLOUR_FRAME_STATS_PLATE,
4425        )
4426        for index, text in enumerate(lines):
4427            renderer.text(
4428                text,
4429                fill=COLOUR_FRAME_STATS,
4430                scale=FRAME_STATS_FONT_SCALE * scale,
4431                align="left",
4432                valign="centre",
4433                rect=(x + margin, y + margin + index * line, width, line),
4434            )
4435
4436    def _draw_subtitles(self, renderer, rect, scale: float) -> None:
4437        """The caption track: the sounds, on their own plate, at the bottom.
4438
4439        The plate is not decoration. Unbacked captions over a star field read
4440        as debug output that shipped. A dark strip behind the words is the
4441        convention that says "this
4442        is the caption track", and the speaker glyphs say "this is a sound".
4443        """
4444        if not self._subtitles or not self.is_element_visible(HudElements.SUBTITLES):
4445            return
4446        x, y, w, h = rect
4447        line = SUBTITLE_LINE_HEIGHT_PX * scale
4448        base = y + h - SUBTITLE_BOTTOM_MARGIN_PX * scale - line * len(self._subtitles)
4449
4450        widest = max(self._subtitles, key=lambda entry: len(entry[0]))[0]
4451        plate_width = _plate_width(widest, SUBTITLE_FONT_SCALE, scale, SUBTITLE_PLATE_PADDING_X_PX, w)
4452        plate_padding = SUBTITLE_PLATE_PADDING_Y_PX * scale
4453        renderer.rect(
4454            (x + (w - plate_width) * 0.5, base - plate_padding),
4455            (plate_width, line * len(self._subtitles) + 2.0 * plate_padding),
4456            fill=COLOUR_SUBTITLE_PLATE,
4457        )
4458        for index, (text, remaining) in enumerate(self._subtitles):
4459            colour = _with_alpha(COLOUR_SUBTITLE, min(1.0, remaining / 0.4))
4460            renderer.text(
4461                text,
4462                fill=colour,
4463                scale=SUBTITLE_FONT_SCALE * scale,
4464                align="centre",
4465                valign="centre",
4466                rect=(x, base + index * line, w, line),
4467            )
4468
4469    def _draw_stamp(self, renderer, rect, scale: float) -> None:
4470        if self._stamp_remaining <= 0.0 or not self.is_element_visible(HudElements.STAMP):
4471            return
4472        x, y, w, h = rect
4473        renderer.text(
4474            self._stamp_text,
4475            fill=_with_alpha(COLOUR_STAMP, min(1.0, self._stamp_remaining / STAMP_FADE_S)),
4476            scale=STAMP_FONT_SCALE * scale,
4477            align="centre",
4478            valign="centre",
4479            rect=(x, y + h * STAMP_TOP_FRACTION, w, h * STAMP_HEIGHT_FRACTION),
4480        )