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