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