shrike/weapons.py¶
Part of SHRIKE.
1"""Hardpoints, the ten-weapon roster, and everything they put in the air.
2
3The roster splits down the game's two economies and the split is structural
4rather than cosmetic. :class:`EnergyWeapon` spends the capacitor through the
5``PowerSystem`` service and never looks at a magazine; :class:`BallisticWeapon`
6spends rounds from a per-weapon magazine fed by ammo boxes and never touches
7the capacitor; :class:`GravHook` spends neither, because it deals no damage and
8exists to move things.
9
10Firing model
11============
12
13Every weapon reads three data sources and nothing else:
14
15* ``balance.WEAPONS[weapon_id]`` for family, sustained DPS and, where the
16 design has fixed it, the energy cost.
17* :data:`FIRE_PROFILES` for the cadence, reach and shot shape, which are feel
18 numbers rather than balance numbers.
19* the live services, ``Services.POWER`` for energy and ``Services.DAMAGE`` for
20 every damage number that leaves this module.
21
22Per-projectile damage is always derived, never typed: a weapon that fires
23``pellets`` shots at ``rate`` per second deals ``spec.dps / (rate * pellets)``
24per pellet, so sustained fire into a single target delivers exactly the
25roster's DPS. Beams deal ``spec.dps * dt`` per tick for the same reason.
26
27Hit resolution happens in flight-plane geometry: a projectile sweeps the
28segment it covers this step against the hostile groups, so a fast round cannot
29step over a target between frames. Projectiles are still real
30:class:`~simvx.core.Area3D` sensors on ``Layers.PLAYER_FIRE`` carrying the
31collision contract, which is how terrain stops them and how anything watching
32for player fire sees them. Damage itself is never applied here: it is handed to
33``DamageRouter.deal``, which owns shield coverage, the ballistic premium and
34elite modifiers.
35
36The fit
37=======
38
39A mounted weapon is geometry on the hull, not only a transform: every one
40builds a breech and a barrel sized by its family, so swapping a pulse blaster
41for a rail lance changes the ship the player is looking at. :func:`weapon_summary`
42is the one mechanical line a shop or a fit panel prints for a weapon, derived
43from ``balance.WEAPONS`` and :data:`FIRE_PROFILES`; :meth:`WeaponRack.swap_in`
44is how a fit is changed, returning the id it displaced so the caller that owns
45the stowage can bank it.
46"""
47
48import math
49from dataclasses import dataclass
50
51from simvx.core import (
52 Area3D,
53 Input,
54 Material,
55 Mesh,
56 MeshInstance3D,
57 Node,
58 Node3D,
59 Quat,
60 Signal,
61 SphereShape3D,
62 Vec3,
63)
64
65from . import balance
66from .runtime import PLANE_Y, Groups, Layers, Services
67
68try: # The contact sparks come from the effect pool when the art layer is in.
69 from . import vfx
70except ImportError: # pragma: no cover - the art layer is optional here
71 vfx = None
72
73# ============================================================================
74# Numbers this module owns
75#
76# Everything here is a cadence, a reach or a shot shape: feel numbers that
77# balance.py does not fix. The DPS, the energy costs and the magazine sizes all
78# come from balance.py and are never restated.
79# ============================================================================
80
81#: Group the weapon rack joins so other systems reach it without walking the
82#: tree by node name.
83WEAPON_RACK_GROUP = "weapon_rack"
84
85#: Plane offsets of the four hardpoints from the hull centre, ``(x, z)``.
86#: The outboard pair is fitted first, the forward pair as the hull grows.
87HARDPOINT_OFFSETS = ((-0.85, 0.15), (0.85, 0.15), (-1.45, -0.55), (1.45, -0.55))
88
89#: Radius assumed for a target that publishes no hitbox radius.
90DEFAULT_TARGET_RADIUS = 1.0
91#: Radius one member of a swarm occupies, for shoals that publish array rows
92#: rather than a body per member.
93SWARM_MEMBER_RADIUS = 0.6
94#: Radius of a projectile's own body, added to the target radius when sweeping.
95PROJECTILE_RADIUS = 0.35
96#: Seconds a magazine swap locks a ballistic weapon out for.
97RELOAD_S = 1.2
98#: Per-shot energy for the two energy weapons balance.py leaves unset, plus the
99#: grav hook. A weapon whose ``WeaponSpec`` carries a cost uses that instead.
100PROVISIONAL_ENERGY_PER_SHOT = {"scatter_coil": 4.0, "nova_mortar": 12.0, "grav_hook": 6.0}
101
102#: Speed and duration of a grav hook yank.
103GRAV_HOOK_PULL_SPEED = 26.0
104GRAV_HOOK_PULL_S = 0.8
105
106#: Player fire is warm gold and white; enemy fire is cold magenta. The palette
107#: is the readability contract, so projectile materials live at one site.
108PROJECTILE_COLOUR = (1.0, 0.82, 0.42, 1.0)
109BEAM_COLOUR = (1.0, 0.93, 0.72, 1.0)
110BEAM_RADIUS = 0.09
111#: How long a hitscan shot leaves its tracer line on screen.
112HITSCAN_TRACER_S = 0.06
113
114# -- A beam that says whether it is working ----------------------------------
115#
116# The mining laser used to draw the same full-length bar of light whether it
117# was cutting a vein or crossing empty space, so the only way to find out
118# whether the beam was on anything was to watch a scrap counter that ticks
119# twice a second. A blind pilot swept the beam across a whole sector without
120# ever learning what it was for. The beam now ends where it bites, marks the
121# bite, and thins out when it bites nothing.
122
123#: Beam colour while it is cutting nothing: the same hue drained of saturation
124#: and heat, so a miss reads as a spent aiming line rather than as work.
125BEAM_MISS_COLOUR = (0.62, 0.62, 0.62, 1.0)
126#: Emissive strength of the beam core, on a surface and in vacuum.
127BEAM_EMISSIVE_HIT = 4.0
128BEAM_EMISSIVE_MISS = 0.9
129#: Cross-section of a beam that is cutting nothing, as a fraction of full bore.
130BEAM_MISS_WIDTH = 0.4
131#: Reach of a beam with nothing in front of it, as a fraction of the profile's
132#: own reach. A miss stops short of the range ring as well as thinning: the
133#: pilot sees the beam fail to arrive at what they were pointing at.
134BEAM_MISS_REACH_FRACTION = 0.7
135#: The bead of light burning on whatever the beam has found, as a multiple of
136#: the beam's own radius: the highlight that says which of two veins in line
137#: with the muzzle is the one being cut.
138BEAM_SPOT_RADIUS_MULT = 3.4
139#: Seconds between the chip bursts a working beam throws off its contact point.
140BEAM_SPARK_INTERVAL_S = 0.12
141#: How far past a mineable surface's own radius the beam still counts as being
142#: on it. Aiming is done with a mouse at a rock the camera draws small, and a
143#: beam that has to pass through the exact centre of a vein is a beam that
144#: usually pays nothing. Combat reach is deliberately not padded this way.
145BEAM_CONTACT_PADDING = 1.0
146
147#: Weapons that answer the utility trigger (``mining_beam``) rather than
148#: ``fire_primary``.
149TRIGGER_MINING = frozenset({"mining_laser", "grav_hook"})
150
151PRIMARY_ACTION = "fire_primary"
152MINING_ACTION = "mining_beam"
153
154#: What the utility trigger is called on a shelf row. A pilot who buys a mining
155#: laser and holds the fire button gets nothing, so the row has to name the key.
156#: The primary trigger is unremarked, being what every other weapon answers.
157TRIGGER_LABELS = {MINING_ACTION: "utility trigger"}
158
159#: The mount a weapon puts on the hull, so a hardpoint reads as fitted.
160#: Barrel dimensions are metres along the hull's local -Z, which is the nose.
161MOUNT_BARREL_LENGTH = {"energy": 0.62, "ballistic": 0.95, "utility": 0.42}
162MOUNT_BARREL_RADIUS = {"energy": 0.075, "ballistic": 0.11, "utility": 0.14}
163#: The receiver block behind the barrel, shared by every family.
164MOUNT_BREECH_SIZE = 0.22
165#: Gunmetal for the barrel; the muzzle carries the family's own warm accent so
166#: two different guns on two hardpoints are told apart at a glance.
167MOUNT_METAL_COLOUR = (0.30, 0.31, 0.34, 1.0)
168MOUNT_ACCENT_COLOURS = {
169 "energy": (0.55, 0.85, 1.0, 1.0),
170 "ballistic": (1.0, 0.72, 0.34, 1.0),
171 "utility": (0.72, 0.55, 0.95, 1.0),
172}
173#: Below the bloom threshold: a muzzle marks the gun, it does not light the sky.
174MOUNT_ACCENT_STRENGTH = 0.9
175
176
177@dataclass(frozen=True)
178class FireProfile:
179 """How one weapon puts its damage into the world.
180
181 ``rate`` is trigger pulls per second and is ignored by beams, which are
182 continuous. ``speed`` of zero means hitscan. ``pellets`` shots leave the
183 muzzle per pull, fanned evenly across ``spread`` radians either side of the
184 aim line, and each carries an equal share of the weapon's DPS.
185 """
186
187 rate: float
188 reach: float
189 speed: float = 0.0
190 pellets: int = 1
191 spread: float = 0.0
192 pierce: int = 0
193 blast: float = 0.0
194 homing_rate: float = 0.0
195 beam: bool = False
196 mines: bool = False
197 rounds_per_shot: int = 1
198
199
200#: One profile per roster entry; the keys match ``balance.WEAPONS`` exactly.
201FIRE_PROFILES: dict[str, FireProfile] = {
202 "pulse_blaster": FireProfile(rate=5.0, reach=60.0, speed=90.0),
203 "scatter_coil": FireProfile(rate=2.0, reach=22.0, speed=70.0, pellets=8, spread=0.30),
204 "arc_beam": FireProfile(rate=0.0, reach=40.0, beam=True),
205 "mining_laser": FireProfile(rate=0.0, reach=26.0, beam=True, mines=True),
206 "nova_mortar": FireProfile(rate=0.8, reach=55.0, speed=40.0, blast=9.0),
207 "rail_lance": FireProfile(rate=1.5, reach=90.0, pierce=3),
208 "flak_cannon": FireProfile(rate=2.5, reach=34.0, speed=65.0, pellets=6, spread=0.26, blast=2.6),
209 "autocannon": FireProfile(rate=8.0, reach=55.0, speed=110.0),
210 "missile_rack": FireProfile(
211 rate=1.0, reach=70.0, speed=55.0, pellets=2, spread=0.05, blast=5.0, homing_rate=3.0, rounds_per_shot=2
212 ),
213 "grav_hook": FireProfile(rate=0.5, reach=45.0),
214}
215
216
217# ============================================================================
218# Saying what a weapon does
219#
220# A shelf row that says "40 DPS ballistic" has priced a word. What a pilot has
221# to decide is whether the gun feeds off the capacitor they are already
222# spending on the shield or off boxes they have to keep buying, and which
223# trigger it answers. Those are the three facts below, and they come off
224# balance.py and this module's own profiles rather than off prose.
225# ============================================================================
226
227
228def trigger_action_for(weapon_id: str) -> str:
229 """The input action *weapon_id* answers, without mounting one to ask."""
230 return MINING_ACTION if weapon_id in TRIGGER_MINING else PRIMARY_ACTION
231
232
233def feed_line(weapon_id: str) -> str:
234 """What the weapon costs to fire, in the units the pilot's meters use.
235
236 Ballistic weapons quote the box because a box is the thing that runs out;
237 energy weapons quote the capacitor draw, per shot or per second as the
238 weapon spends it.
239 """
240 spec = balance.WEAPONS[weapon_id]
241 profile = FIRE_PROFILES[weapon_id]
242 if spec.family == "ballistic":
243 rounds = int(spec.rounds_per_box or 0)
244 per_shot = f", {profile.rounds_per_shot} a shot" if profile.rounds_per_shot > 1 else ""
245 return f"uses ammo, {rounds} a box{per_shot}"
246 per_second = spec.energy_per_s if spec.energy_per_s is not None else None
247 if per_second is not None:
248 return f"drains {per_second:.0f} energy/s"
249 per_shot_cost = spec.energy_per_shot
250 if per_shot_cost is None:
251 per_shot_cost = PROVISIONAL_ENERGY_PER_SHOT.get(weapon_id, 0.0)
252 if per_shot_cost <= 0.0:
253 return "costs nothing to fire"
254 return f"{per_shot_cost:.0f} energy a shot"
255
256
257def weapon_summary(weapon_id: str) -> str:
258 """DPS, feed and trigger on one line, for a shelf row or a fit panel."""
259 spec = balance.WEAPONS[weapon_id]
260 head = f"{spec.dps:.0f} DPS" if spec.dps > 0.0 else "no damage"
261 parts = [head, feed_line(weapon_id), TRIGGER_LABELS.get(trigger_action_for(weapon_id), "")]
262 if spec.note:
263 parts.append(spec.note)
264 return ", ".join(part for part in parts if part)
265
266
267# ============================================================================
268# Flight-plane geometry
269# ============================================================================
270
271
272def _plane_of(node: Node3D) -> tuple[float, float]:
273 """A node's world position as flight-plane ``(x, z)`` floats."""
274 p = node.world_position
275 return float(p[0]), float(p[2])
276
277
278def _normalise(x: float, z: float) -> tuple[float, float]:
279 """Unit vector on the plane; a zero vector becomes +X."""
280 length = math.hypot(x, z)
281 if length < 1e-9:
282 return 1.0, 0.0
283 return x / length, z / length
284
285
286def _rotate(x: float, z: float, angle: float) -> tuple[float, float]:
287 """Rotate a plane vector by *angle* radians."""
288 c, s = math.cos(angle), math.sin(angle)
289 return x * c - z * s, x * s + z * c
290
291
292def _is_swarm(node: Node3D) -> bool:
293 """Whether *node* is a swarm whose members are array rows, not bodies.
294
295 A shoal publishes ``live_positions`` and takes damage through
296 ``damage_at`` at a world point, because no individual member has a hitbox
297 of its own to sweep against.
298 """
299 return getattr(node, "HITBOX_RADIUS", DEFAULT_TARGET_RADIUS) is None and hasattr(node, "damage_at")
300
301
302def _target_radius(node: Node3D) -> float:
303 """Collision radius a shot must reach to count as a hit on *node*."""
304 hitbox = getattr(node, "HITBOX_RADIUS", None)
305 if hitbox is not None:
306 return float(hitbox)
307 if _is_swarm(node):
308 return SWARM_MEMBER_RADIUS
309 return float(getattr(node, "hit_radius", DEFAULT_TARGET_RADIUS))
310
311
312def _swarm_members(node: Node3D) -> list[tuple[float, float]]:
313 """Plane positions of a swarm's live members."""
314 rows = getattr(node, "live_positions", None)
315 if rows is None:
316 return []
317 return [(float(row[0]), float(row[2])) for row in rows]
318
319
320def _segment_circle_distance(
321 origin: tuple[float, float],
322 direction: tuple[float, float],
323 length: float,
324 centre: tuple[float, float],
325 radius: float,
326) -> float | None:
327 """Distance along a unit-direction segment at which it first enters a circle.
328
329 Returns ``None`` when the segment misses. A segment starting inside the
330 circle reports 0.0, so a shot spawned on top of a target still connects.
331 """
332 fx, fz = origin[0] - centre[0], origin[1] - centre[1]
333 b = fx * direction[0] + fz * direction[1]
334 c = fx * fx + fz * fz - radius * radius
335 if c <= 0.0:
336 return 0.0
337 discriminant = b * b - c
338 if discriminant < 0.0:
339 return None
340 entry = -b - math.sqrt(discriminant)
341 if 0.0 <= entry <= length:
342 return entry
343 return None
344
345
346def _group_nodes(tree, *groups: str) -> list[Node3D]:
347 """Live ``Node3D`` members of the given groups, skipping anything expiring."""
348 out: list[Node3D] = []
349 if tree is None:
350 return out
351 for group in groups:
352 for node in tree.group(group):
353 if isinstance(node, Node3D) and not node.destroying:
354 out.append(node)
355 return out
356
357
358def _sweep(
359 tree,
360 origin: tuple[float, float],
361 direction: tuple[float, float],
362 length: float,
363 padding: float,
364 *groups: str,
365) -> list[tuple[float, Node3D, tuple[float, float]]]:
366 """Every group member the segment crosses, nearest first.
367
368 Each hit is ``(distance, node, centre)``. *centre* is the plane centre of
369 the body that was actually struck, which for a swarm is the member's own
370 position rather than the shoal node's: a shoal is tested against each of
371 its live members, so it reports the distance at which the segment reaches
372 its nearest mite, and the damage that follows has to land on that mite
373 rather than on the shoal's centre of mass.
374 """
375 hits: list[tuple[float, Node3D, tuple[float, float]]] = []
376 for node in _group_nodes(tree, *groups):
377 radius = _target_radius(node) + padding
378 if _is_swarm(node):
379 reached = [
380 (distance, member)
381 for distance, member in (
382 (_segment_circle_distance(origin, direction, length, member, radius), member)
383 for member in _swarm_members(node)
384 )
385 if distance is not None
386 ]
387 if not reached:
388 continue
389 distance, centre = min(reached, key=lambda pair: pair[0])
390 else:
391 centre = _plane_of(node)
392 distance = _segment_circle_distance(origin, direction, length, centre, radius)
393 if distance is None:
394 continue
395 hits.append((distance, node, centre))
396 hits.sort(key=lambda hit: hit[0])
397 return hits
398
399
400def _hostiles(tree) -> list[Node3D]:
401 """Everything player fire is allowed to damage."""
402 return _group_nodes(tree, Groups.ENEMIES, Groups.HUNTER)
403
404
405def _deal(
406 tree,
407 target: Node3D,
408 amount: float,
409 kind: str,
410 direction: tuple[float, float],
411 at: tuple[float, float] | None = None,
412 splash: float = 0.0,
413) -> None:
414 """Hand a damage number to the router, the only code that applies one.
415
416 A swarm is the one exception, and it is an interface gap rather than a
417 choice: ``DamageRouter.deal`` takes a node, and a shoal's members are array
418 rows at their own positions, so damage that landed at a point is applied
419 through the shoal's own ``damage_at`` instead. *at* is therefore the struck
420 member's own centre, not the point on its surface the round touched: the
421 sweep pads a target's radius by the projectile's, and a burst centred on
422 that padded surface would fall entirely outside the member it just hit.
423 """
424 if at is not None and _is_swarm(target):
425 target.damage_at(Vec3(at[0], PLANE_Y, at[1]), max(splash, _target_radius(target)), amount, kind)
426 return
427 router = tree.singletons.get(Services.DAMAGE) if tree is not None else None
428 if router is None:
429 return
430 router.deal(target, amount, kind=kind, direction=Vec3(direction[0], 0.0, direction[1]))
431
432
433# ============================================================================
434# Projectiles
435# ============================================================================
436
437
438class Projectile(Area3D):
439 """One round in flight on the plane.
440
441 The sensor body carries the ``Layers.PLAYER_FIRE`` contract so terrain and
442 anything else watching player fire sees the round; damage is resolved by
443 sweeping the segment travelled this step, which is what stops a fast round
444 from stepping over a small target between frames.
445 """
446
447 def __init__(
448 self,
449 *,
450 weapon_id: str,
451 kind: str,
452 damage: float,
453 direction: tuple[float, float],
454 speed: float,
455 reach: float,
456 pierce: int = 0,
457 blast: float = 0.0,
458 homing_rate: float = 0.0,
459 **kwargs,
460 ):
461 kwargs.setdefault("shape", SphereShape3D(radius=PROJECTILE_RADIUS))
462 super().__init__(**kwargs)
463 self.collision_layer = Layers.PLAYER_FIRE
464 self.collision_mask = Layers.MASK_PLAYER_FIRE
465 self.weapon_id = weapon_id
466 self.kind = kind
467 self.damage = float(damage)
468 self.speed = float(speed)
469 self.reach = float(reach)
470 self.pierce = int(pierce)
471 self.blast = float(blast)
472 self.homing_rate = float(homing_rate)
473 self._direction = _normalise(direction[0], direction[1])
474 self._travelled = 0.0
475 self._struck: set[int] = set()
476
477 def on_enter_tree(self):
478 super().on_enter_tree()
479 self.add_to_group(Groups.PLAYER_PROJECTILES)
480
481 def on_ready(self):
482 tracer = Material(colour=PROJECTILE_COLOUR, emissive_colour=PROJECTILE_COLOUR[:3], emissive_strength=3.0)
483 self.add_child(
484 MeshInstance3D(
485 name="Tracer",
486 mesh=Mesh.sphere(radius=PROJECTILE_RADIUS, rings=6, segments=8),
487 material=tracer,
488 )
489 )
490 self.body_entered.connect(self._on_body_entered)
491
492 def on_fixed_update(self, dt: float):
493 if self.destroying:
494 return
495 if self.homing_rate > 0.0:
496 self._steer(dt)
497 step = min(self.speed * dt, self.reach - self._travelled)
498 if step <= 0.0:
499 self._expire(detonate=False)
500 return
501 origin = _plane_of(self)
502 if self._resolve(origin, step):
503 return
504 self.position = Vec3(
505 origin[0] + self._direction[0] * step,
506 PLANE_Y,
507 origin[1] + self._direction[1] * step,
508 )
509 self._travelled += step
510
511 def _steer(self, dt: float):
512 """Turn toward the nearest hostile, limited by the homing rate."""
513 here = _plane_of(self)
514 nearest = None
515 best = float("inf")
516 for node in _hostiles(self.tree):
517 tx, tz = _plane_of(node)
518 distance = math.hypot(tx - here[0], tz - here[1])
519 if distance < best:
520 best, nearest = distance, node
521 if nearest is None:
522 return
523 tx, tz = _plane_of(nearest)
524 wanted = _normalise(tx - here[0], tz - here[1])
525 cross = self._direction[0] * wanted[1] - self._direction[1] * wanted[0]
526 dot = max(-1.0, min(1.0, self._direction[0] * wanted[0] + self._direction[1] * wanted[1]))
527 turn = max(-self.homing_rate * dt, min(self.homing_rate * dt, math.acos(dot) * (1.0 if cross >= 0.0 else -1.0)))
528 self._direction = _normalise(*_rotate(self._direction[0], self._direction[1], turn))
529
530 def _resolve(self, origin: tuple[float, float], step: float) -> bool:
531 """Damage everything the step crosses. True when the round is spent."""
532 for distance, target, centre in _sweep(
533 self.tree, origin, self._direction, step, PROJECTILE_RADIUS, Groups.ENEMIES, Groups.HUNTER
534 ):
535 if id(target) in self._struck:
536 continue
537 self._struck.add(id(target))
538 _deal(self.tree, target, self.damage, self.kind, self._direction, at=centre, splash=self.blast)
539 if self.pierce <= 0:
540 self.position = Vec3(
541 origin[0] + self._direction[0] * distance,
542 PLANE_Y,
543 origin[1] + self._direction[1] * distance,
544 )
545 self._expire(detonate=True)
546 return True
547 self.pierce -= 1
548 return False
549
550 def _on_body_entered(self, other):
551 """Terrain stops a round where it hits, blast and all."""
552 if int(getattr(other, "collision_layer", 0)) & Layers.TERRAIN:
553 self._expire(detonate=True)
554
555 def _expire(self, *, detonate: bool):
556 if self.destroying:
557 return
558 if detonate and self.blast > 0.0:
559 self._detonate()
560 self.remove_from_group(Groups.PLAYER_PROJECTILES)
561 self.destroy()
562
563 def _detonate(self):
564 """Splash damage on everything in the blast that the round did not hit.
565
566 A shoal caught by the blast takes it across the whole radius in one
567 call, which is the burst clearing several rows at once.
568 """
569 centre = _plane_of(self)
570 for target in _hostiles(self.tree):
571 if id(target) in self._struck:
572 continue
573 if _is_swarm(target):
574 _deal(self.tree, target, self.damage, self.kind, self._direction, at=centre, splash=self.blast)
575 continue
576 tx, tz = _plane_of(target)
577 if math.hypot(tx - centre[0], tz - centre[1]) <= self.blast + _target_radius(target):
578 _deal(self.tree, target, self.damage, self.kind, self._direction)
579
580
581# ============================================================================
582# Weapons
583# ============================================================================
584
585
586class Weapon(Node3D):
587 """One mounted weapon: cadence, cost and the shape of what it fires.
588
589 A weapon is inert until something drives it. :meth:`set_trigger` holds the
590 trigger down (the rack does this from the input actions every frame) and
591 :meth:`try_fire` demands a shot right now, returning whether one left the
592 muzzle. Beams report whether they are currently burning.
593 """
594
595 def __init__(self, weapon_id: str, **kwargs):
596 if weapon_id not in balance.WEAPONS:
597 raise ValueError(f"unknown weapon id {weapon_id!r}")
598 kwargs.setdefault("name", weapon_id)
599 super().__init__(**kwargs)
600 self.weapon_id = weapon_id
601 self.spec = balance.WEAPONS[weapon_id]
602 self.profile = FIRE_PROFILES[weapon_id]
603 self.rack: WeaponRack | None = None
604 self._trigger = False
605 self._cooldown = 0.0
606 self._aim_point = Vec3(0.0, PLANE_Y, 0.0)
607 self._beam_active = False
608 self._beam: Node3D | None = None
609 self._beam_mesh: MeshInstance3D | None = None
610 self._beam_spot: MeshInstance3D | None = None
611 self._beam_hit = False
612 self._beam_length = 0.0
613 self._spark_wait = 0.0
614 self._tracer_left = 0.0
615 self._hacking: set[int] = set()
616
617 # -- the mount --------------------------------------------------------
618
619 def on_ready(self):
620 self.add_child(self._build_mount())
621
622 def _build_mount(self) -> Node3D:
623 """The gun as it sits on the hull: a breech block and a barrel.
624
625 Without this a hardpoint is an empty transform, so buying a weapon
626 changes the ship's fire and nothing about the ship, and a pilot has no
627 way to see which hardpoint carries what. The barrel runs along local
628 -Z, which is the hull's nose, and its length and bore come off the
629 family, so a rail lance and a pulse blaster are told apart in
630 silhouette. Geometry from engine primitives, as the beam and the tracer
631 already are; ``artkit`` would be the tidier home for it once it grows a
632 player-weapon builder.
633 """
634 family = self.spec.family
635 length = MOUNT_BARREL_LENGTH[family]
636 radius = MOUNT_BARREL_RADIUS[family]
637 mount = Node3D(name="Mount")
638 mount.add_child(
639 MeshInstance3D(
640 name="Breech",
641 mesh=Mesh.cube(size=MOUNT_BREECH_SIZE),
642 material=Material(colour=MOUNT_METAL_COLOUR, metallic=0.8, roughness=0.45),
643 )
644 )
645 mount.add_child(
646 MeshInstance3D(
647 name="Barrel",
648 mesh=Mesh.cylinder(radius=radius, height=length, segments=10),
649 material=Material(colour=MOUNT_METAL_COLOUR, metallic=0.85, roughness=0.35),
650 rotation=Quat.from_euler(math.radians(90.0), 0.0, 0.0),
651 position=Vec3(0.0, 0.0, -length * 0.5),
652 )
653 )
654 accent = MOUNT_ACCENT_COLOURS[family]
655 mount.add_child(
656 MeshInstance3D(
657 name="Muzzle",
658 mesh=Mesh.sphere(radius=radius * 1.25, rings=6, segments=8),
659 material=Material(colour=accent, emissive_colour=accent[:3], emissive_strength=MOUNT_ACCENT_STRENGTH),
660 position=Vec3(0.0, 0.0, -length),
661 )
662 )
663 return mount
664
665 # -- description ------------------------------------------------------
666
667 @property
668 def trigger_action(self) -> str:
669 """The input action this weapon answers."""
670 return MINING_ACTION if self.weapon_id in TRIGGER_MINING else PRIMARY_ACTION
671
672 @property
673 def damage_per_projectile(self) -> float:
674 """DPS divided evenly across a second's worth of pellets."""
675 shots_per_second = self.profile.rate * self.profile.pellets
676 if self.profile.beam or shots_per_second <= 0.0:
677 return 0.0
678 return self.spec.dps / shots_per_second
679
680 @property
681 def beam_active(self) -> bool:
682 """Whether a beam weapon is burning right now."""
683 return self._beam_active
684
685 # -- driving ----------------------------------------------------------
686
687 def set_trigger(self, held: bool) -> None:
688 """Hold or release the trigger; beams and automatics keep going."""
689 self._trigger = bool(held)
690
691 def set_aim(self, world_point: Vec3) -> None:
692 """The flight-plane point every shot converges on."""
693 self._aim_point = Vec3(world_point)
694
695 def try_fire(self, aim_point: Vec3) -> bool:
696 """Fire one shot at *aim_point* if the weapon is ready and can pay.
697
698 Beams engage instead of firing a shot and report whether they burn.
699 """
700 self.set_aim(aim_point)
701 if self.profile.beam:
702 self._trigger = True
703 return self._beam_active
704 if self._cooldown > 0.0 or not self._pay_for_shot():
705 return False
706 self._cooldown = 1.0 / self.profile.rate
707 self._discharge()
708 self._announce_fired()
709 return True
710
711 def on_update(self, dt: float):
712 self._cooldown = max(0.0, self._cooldown - dt)
713 if self.profile.beam:
714 self._tick_beam(dt)
715 return
716 if self._trigger:
717 self.try_fire(self._aim_point)
718 if self._tracer_left > 0.0:
719 self._tracer_left -= dt
720 if self._tracer_left <= 0.0 and self._beam is not None:
721 self._beam.visible = False
722 self._beam_hit = False
723
724 # -- payment (overridden by the two economies) ------------------------
725
726 def _pay_for_shot(self) -> bool:
727 return True
728
729 def _pay_for_beam(self, dt: float) -> bool:
730 return True
731
732 def _power(self):
733 return self.tree.singletons.get(Services.POWER) if self.tree is not None else None
734
735 def _free_fire(self) -> bool:
736 return bool(self.rack is not None and self.rack.free_fire)
737
738 # -- discharge --------------------------------------------------------
739
740 def _aim_direction(self) -> tuple[float, float]:
741 muzzle = _plane_of(self)
742 aim = self._aim_point
743 return _normalise(float(aim[0]) - muzzle[0], float(aim[2]) - muzzle[1])
744
745 def _discharge(self) -> None:
746 """Put one trigger pull's worth of shots into the world."""
747 base = self._aim_direction()
748 pellets = self.profile.pellets
749 for index in range(pellets):
750 offset = 0.0
751 if pellets > 1 and self.profile.spread > 0.0:
752 offset = self.profile.spread * (2.0 * index / (pellets - 1) - 1.0)
753 direction = _rotate(base[0], base[1], offset)
754 if self.profile.speed <= 0.0:
755 self._hitscan(direction)
756 else:
757 self._spawn_projectile(direction)
758
759 def _hitscan(self, direction: tuple[float, float]) -> None:
760 """Resolve an instant shot and leave a tracer along the line it took."""
761 origin = _plane_of(self)
762 allowed = self.profile.pierce + 1
763 reach = self.profile.reach
764 hit = False
765 for distance, target, centre in _sweep(
766 self.tree, origin, direction, self.profile.reach, 0.0, Groups.ENEMIES, Groups.HUNTER
767 ):
768 _deal(self.tree, target, self.damage_per_projectile, self.spec.family, direction, at=centre)
769 reach, hit = distance, True
770 allowed -= 1
771 if allowed <= 0:
772 break
773 self._show_beam(direction, reach, hit=hit, full_bore=True)
774 self._tracer_left = HITSCAN_TRACER_S
775
776 def _spawn_projectile(self, direction: tuple[float, float]) -> None:
777 if self.tree is None or self.tree.root is None:
778 return
779 muzzle = _plane_of(self)
780 self.tree.root.add_child(
781 Projectile(
782 name=f"{self.weapon_id}_round",
783 weapon_id=self.weapon_id,
784 kind=self.spec.family,
785 damage=self.damage_per_projectile,
786 direction=direction,
787 speed=self.profile.speed,
788 reach=self.profile.reach,
789 pierce=self.profile.pierce,
790 blast=self.profile.blast,
791 homing_rate=self.profile.homing_rate,
792 position=Vec3(muzzle[0], PLANE_Y, muzzle[1]),
793 )
794 )
795
796 # -- beams ------------------------------------------------------------
797
798 def _tick_beam(self, dt: float) -> None:
799 if not self._trigger or not self._pay_for_beam(dt):
800 self._end_beam()
801 return
802 if not self._beam_active:
803 self._beam_active = True
804 self._announce_fired()
805 self._beam_damage(dt)
806
807 def _beam_damage(self, dt: float) -> None:
808 origin = _plane_of(self)
809 direction = self._aim_direction()
810 reach = self.profile.reach
811 hit = False
812 struck = _sweep(self.tree, origin, direction, reach, 0.0, Groups.ENEMIES, Groups.HUNTER)
813 if struck:
814 distance, target, centre = struck[0]
815 reach, hit = min(reach, distance), True
816 _deal(self.tree, target, self.spec.dps * dt, self.spec.family, direction, at=centre)
817 if self.profile.mines:
818 mined = self._mine(origin, direction, dt)
819 hit = hit or mined < self.profile.reach
820 reach = min(reach, mined)
821 if not hit:
822 reach = self.profile.reach * BEAM_MISS_REACH_FRACTION
823 self._show_beam(direction, reach, hit=hit)
824 self._tick_sparks(dt, origin, direction, reach, hit)
825
826 def _mine(self, origin: tuple[float, float], direction: tuple[float, float], dt: float) -> float:
827 """Work the beam against the surfaces it can reach; returns the reach used.
828
829 Deposits take the mining multiplier rather than the raw beam DPS, and a
830 vault held in the beam starts its hack channel once. A wreck's hull is
831 neither, but it is solid: the beam stops on it rather than passing
832 through, because a beam that draws straight through a hulk is telling
833 the pilot the hulk is not there.
834 """
835 reach = self.profile.reach
836 padding = BEAM_CONTACT_PADDING
837 deposits = _sweep(self.tree, origin, direction, reach, padding, Groups.DEPOSITS)
838 if deposits:
839 distance, deposit, _centre = deposits[0]
840 reach = min(reach, distance)
841 deposit.mine(self.spec.dps * balance.MINING_LASER_DEPOSIT_MULT, dt)
842 vaults = _sweep(self.tree, origin, direction, reach, padding, Groups.VAULTS)
843 if vaults:
844 distance, vault, _centre = vaults[0]
845 reach = min(reach, distance)
846 if id(vault) not in self._hacking:
847 self._hacking.add(id(vault))
848 vault.begin_hack()
849 wrecks = _sweep(self.tree, origin, direction, reach, padding, Groups.WRECKS)
850 if wrecks:
851 reach = min(reach, wrecks[0][0])
852 return reach
853
854 @property
855 def beam_on_target(self) -> bool:
856 """Whether the beam currently drawn is standing on a surface."""
857 return self._beam_hit
858
859 @property
860 def beam_reach(self) -> float:
861 """How far the beam currently drawn actually travels, world units."""
862 return self._beam_length
863
864 def _tick_sparks(
865 self,
866 dt: float,
867 origin: tuple[float, float],
868 direction: tuple[float, float],
869 reach: float,
870 hit: bool,
871 ) -> None:
872 """Throw chips off the contact point while the beam is biting something.
873
874 A beam in vacuum throws none, so the sparks and their absence are the
875 same message read twice: the pilot who cannot tell a thinner beam from a
876 fatter one can still tell a shower from an empty line.
877 """
878 if not hit or vfx is None or self.tree is None:
879 self._spark_wait = 0.0
880 return
881 self._spark_wait -= dt
882 if self._spark_wait > 0.0:
883 return
884 self._spark_wait = BEAM_SPARK_INTERVAL_S
885 contact = Vec3(origin[0] + direction[0] * reach, PLANE_Y, origin[1] + direction[1] * reach)
886 vfx.Vfx.spawn(self.tree, "beam_spark", contact, direction=Vec3(-direction[0], 0.0, -direction[1]))
887
888 def _show_beam(
889 self,
890 direction: tuple[float, float],
891 length: float,
892 *,
893 hit: bool = True,
894 full_bore: bool | None = None,
895 ) -> None:
896 """Draw the beam out to *length*, saying whether it found anything.
897
898 A held beam that ends on a surface is at full bore, warm, and capped
899 with a bead of light on the thing it is cutting; one that ends in vacuum
900 is thin and grey. The two must not be told apart by the scrap counter
901 alone.
902
903 *full_bore* separates the two questions for the tracer a hitscan round
904 leaves behind, which is a slug's own trace rather than a beam standing
905 on something: it is drawn hot whether or not the slug connected, and
906 only the bead answers to *hit*.
907 """
908 if self._beam is None:
909 self._build_beam()
910 hot = hit if full_bore is None else bool(full_bore)
911 self._beam.visible = True
912 self._beam.face_along(Vec3(direction[0], 0.0, direction[1]))
913 width = 1.0 if hot else BEAM_MISS_WIDTH
914 self._beam_mesh.position = Vec3(0.0, 0.0, -length * 0.5)
915 self._beam_mesh.scale = Vec3(width, max(length, 1e-3), width)
916 material = self._beam_mesh.material
917 colour = BEAM_COLOUR if hot else BEAM_MISS_COLOUR
918 material.colour = colour
919 material.emissive_colour = colour[:3]
920 material.emissive_strength = BEAM_EMISSIVE_HIT if hot else BEAM_EMISSIVE_MISS
921 self._beam_spot.position = Vec3(0.0, 0.0, -length)
922 self._beam_spot.visible = hit
923 self._beam_hit = hit
924 self._beam_length = float(length)
925
926 def _build_beam(self) -> None:
927 self._beam = self.add_child(Node3D(name="Beam"))
928 self._beam_mesh = self._beam.add_child(
929 MeshInstance3D(
930 name="Core",
931 mesh=Mesh.cylinder(radius=BEAM_RADIUS, height=1.0, segments=8),
932 material=Material(
933 colour=BEAM_COLOUR,
934 emissive_colour=BEAM_COLOUR[:3],
935 emissive_strength=BEAM_EMISSIVE_HIT,
936 ),
937 rotation=Quat.from_euler(math.radians(90.0), 0.0, 0.0),
938 )
939 )
940 self._beam_spot = self._beam.add_child(
941 MeshInstance3D(
942 name="Contact",
943 mesh=Mesh.sphere(radius=BEAM_RADIUS * BEAM_SPOT_RADIUS_MULT, rings=6, segments=10),
944 material=Material(
945 colour=BEAM_COLOUR,
946 emissive_colour=BEAM_COLOUR[:3],
947 emissive_strength=BEAM_EMISSIVE_HIT,
948 ),
949 )
950 )
951 self._beam_spot.visible = False
952
953 def _end_beam(self) -> None:
954 self._beam_active = False
955 self._beam_hit = False
956 self._spark_wait = 0.0
957 self._hacking.clear()
958 if self._beam is not None:
959 self._beam.visible = False
960 if self._beam_spot is not None:
961 self._beam_spot.visible = False
962
963 # -- signals ----------------------------------------------------------
964
965 def _announce_fired(self) -> None:
966 if self.rack is not None:
967 self.rack.weapon_fired(self.weapon_id)
968
969
970class EnergyWeapon(Weapon):
971 """Pulse blaster, scatter coil, arc beam, mining laser and nova mortar.
972
973 Fed entirely by the capacitor through ``PowerSystem``; no magazine, no
974 ammo box, no scrap. Last Stand waives the cost without waiving the shot.
975 """
976
977 def _energy_per_shot(self) -> float:
978 if self.spec.energy_per_shot is not None:
979 return float(self.spec.energy_per_shot)
980 return PROVISIONAL_ENERGY_PER_SHOT.get(self.weapon_id, 0.0)
981
982 def _energy_per_second(self) -> float:
983 if self.spec.energy_per_s is not None:
984 return float(self.spec.energy_per_s)
985 return 0.0
986
987 def _pay_for_shot(self) -> bool:
988 cost = self._energy_per_shot()
989 power = self._power()
990 if cost <= 0.0 or power is None or self._free_fire():
991 return True
992 return bool(power.request(cost, self.weapon_id))
993
994 def _pay_for_beam(self, dt: float) -> bool:
995 cost = self._energy_per_second()
996 power = self._power()
997 if cost <= 0.0 or power is None or self._free_fire():
998 return True
999 return bool(power.drain(cost, dt, self.weapon_id))
1000
1001
1002class BallisticWeapon(Weapon):
1003 """Rail lance, flak cannon, autocannon and missile rack.
1004
1005 Fed by a magazine that ammo boxes refill and by nothing else: a ballistic
1006 weapon costs zero capacitor, which is what lets a scrap-fed build leave the
1007 power economy altogether. An empty magazine swaps a box in automatically
1008 when the rack holds one, at the price of a reload lockout.
1009 """
1010
1011 def __init__(self, weapon_id: str, **kwargs):
1012 super().__init__(weapon_id, **kwargs)
1013 self.magazine_size = int(self.spec.rounds_per_box or 0)
1014 self.rounds = self.magazine_size
1015
1016 def reload_from_box(self) -> bool:
1017 """Consume one of the rack's boxes to refill the magazine."""
1018 if self.rounds >= self.magazine_size or self.rack is None:
1019 return False
1020 if not self.rack.take_ammo_box(self.weapon_id):
1021 return False
1022 self.rounds = self.magazine_size
1023 self._announce_ammo()
1024 return True
1025
1026 def _pay_for_shot(self) -> bool:
1027 needed = self.profile.rounds_per_shot
1028 if self.rounds < needed:
1029 if self.reload_from_box():
1030 self._cooldown = RELOAD_S
1031 return False
1032 self.rounds -= needed
1033 self._announce_ammo()
1034 return True
1035
1036 def _announce_ammo(self) -> None:
1037 if self.rack is not None:
1038 self.rack.ammo_changed(self.weapon_id, self.rounds)
1039
1040
1041class GravHook(Weapon):
1042 """The utility hardpoint: yanks one enemy or salvage mote toward the ship.
1043
1044 Deals no damage at all. What it moves, it moves on the flight plane at a
1045 fixed speed for a fixed time, so a mote pulled out of a Skimmer's path and
1046 a Lancer pulled out of its dash both read the same way.
1047 """
1048
1049 def __init__(self, weapon_id: str = "grav_hook", **kwargs):
1050 super().__init__(weapon_id, **kwargs)
1051 self._hooked: list[tuple[Node3D, float]] = []
1052
1053 def _pay_for_shot(self) -> bool:
1054 cost = PROVISIONAL_ENERGY_PER_SHOT.get(self.weapon_id, 0.0)
1055 power = self._power()
1056 if cost <= 0.0 or power is None or self._free_fire():
1057 return True
1058 return bool(power.request(cost, self.weapon_id))
1059
1060 def _discharge(self) -> None:
1061 origin = _plane_of(self)
1062 direction = self._aim_direction()
1063 caught = _sweep(
1064 self.tree, origin, direction, self.profile.reach, 0.0, Groups.ENEMIES, Groups.SALVAGE, Groups.HUNTER
1065 )
1066 # A swarm has no single body to grab: its members are array rows that
1067 # do not follow the shoal node.
1068 for _distance, target, _centre in caught:
1069 if not _is_swarm(target):
1070 self._hooked.append((target, GRAV_HOOK_PULL_S))
1071 return
1072
1073 def on_update(self, dt: float):
1074 super().on_update(dt)
1075 self._pull(dt)
1076
1077 def _pull(self, dt: float) -> None:
1078 if not self._hooked:
1079 return
1080 anchor = self._anchor()
1081 still: list[tuple[Node3D, float]] = []
1082 for target, remaining in self._hooked:
1083 if target.destroying or remaining <= 0.0:
1084 continue
1085 tx, tz = _plane_of(target)
1086 towards = _normalise(anchor[0] - tx, anchor[1] - tz)
1087 step = GRAV_HOOK_PULL_SPEED * dt
1088 target.position = Vec3(
1089 float(target.position[0]) + towards[0] * step,
1090 PLANE_Y,
1091 float(target.position[2]) + towards[1] * step,
1092 )
1093 still.append((target, remaining - dt))
1094 self._hooked = still
1095
1096 def _anchor(self) -> tuple[float, float]:
1097 """Where hooked things are dragged to: the ship, or the hook itself."""
1098 ships = _group_nodes(self.tree, Groups.SHIP)
1099 return _plane_of(ships[0]) if ships else _plane_of(self)
1100
1101
1102#: Which class mounts each roster entry.
1103_WEAPON_CLASSES = {"energy": EnergyWeapon, "ballistic": BallisticWeapon, "utility": GravHook}
1104
1105
1106def make_weapon(weapon_id: str, **kwargs) -> Weapon:
1107 """Build the weapon node for a roster id, picking the class by family."""
1108 if weapon_id not in balance.WEAPONS:
1109 raise ValueError(f"unknown weapon id {weapon_id!r}")
1110 return _WEAPON_CLASSES[balance.WEAPONS[weapon_id].family](weapon_id, **kwargs)
1111
1112
1113# ============================================================================
1114# The rack
1115# ============================================================================
1116
1117
1118class WeaponRack(Node3D):
1119 """The ship's hardpoints, what is bolted to them, and the ammo locker.
1120
1121 The rack is the single place other systems connect to for fire and ammo
1122 events, and the single place the two fire actions are read. It drives its
1123 weapons every frame: aim first, then trigger state, so a held button keeps
1124 an automatic firing and a released one drops a beam within the frame.
1125
1126 It is a ``Node3D`` rather than a bare ``Node`` because the weapons hanging
1127 off it are positioned hardpoints and must inherit the hull's transform.
1128 """
1129
1130 #: ``(weapon_id)`` whenever a shot leaves a muzzle or a beam engages.
1131 weapon_fired = Signal(str)
1132 #: ``(weapon_id, rounds)`` after every round spent and every reload.
1133 ammo_changed = Signal(str, int)
1134 #: ``(weapon_id, hardpoint)`` when a hardpoint's fitting changes.
1135 weapon_equipped = Signal(str, int)
1136
1137 def __init__(self, hardpoints: int = balance.HARDPOINTS_STARTER, **kwargs):
1138 kwargs.setdefault("name", "Hardpoints")
1139 super().__init__(**kwargs)
1140 self.hardpoints: list[Weapon | None] = [None] * self._validated_count(hardpoints)
1141 self._boxes: dict[str, int] = {}
1142 self._aim_point: Vec3 | None = None
1143 self._triggers = {PRIMARY_ACTION: False, MINING_ACTION: False}
1144 #: When True the rack reads the fire actions itself. Turrets and other
1145 #: non-player mounts set it False and drive the triggers directly.
1146 self.player_controlled = True
1147 #: Last Stand waives energy costs; ammo is still spent.
1148 self.free_fire = False
1149
1150 @staticmethod
1151 def _validated_count(hardpoints: int) -> int:
1152 if not 1 <= int(hardpoints) <= balance.HARDPOINTS_MAX:
1153 raise ValueError(f"hardpoint count must be 1 to {balance.HARDPOINTS_MAX}, got {hardpoints}")
1154 return int(hardpoints)
1155
1156 def on_enter_tree(self):
1157 super().on_enter_tree()
1158 self.add_to_group(WEAPON_RACK_GROUP)
1159
1160 # -- fitting ----------------------------------------------------------
1161
1162 def equip(self, weapon_id: str, hardpoint: int) -> None:
1163 """Bolt *weapon_id* to *hardpoint*, replacing whatever was there."""
1164 self._check_hardpoint(hardpoint)
1165 self.unequip(hardpoint)
1166 offset = HARDPOINT_OFFSETS[hardpoint % len(HARDPOINT_OFFSETS)]
1167 weapon = make_weapon(weapon_id, position=Vec3(offset[0], PLANE_Y, offset[1]))
1168 weapon.rack = self
1169 self.hardpoints[hardpoint] = self.add_child(weapon)
1170 self.weapon_equipped(weapon_id, hardpoint)
1171
1172 def unequip(self, hardpoint: int) -> None:
1173 """Strip a hardpoint back to bare mounting."""
1174 self._check_hardpoint(hardpoint)
1175 fitted = self.hardpoints[hardpoint]
1176 if fitted is not None:
1177 self.hardpoints[hardpoint] = None
1178 fitted.rack = None
1179 fitted.destroy()
1180
1181 def swap_in(self, weapon_id: str, hardpoint: int) -> str:
1182 """Fit *weapon_id* to *hardpoint* and return whatever it displaced.
1183
1184 The empty string when the hardpoint was bare. The caller owns the
1185 locker, so a swap is one call here and one list edit there rather than
1186 two half-authoritative copies of what the ship is carrying; the run
1187 scene puts the returned id back into its stowage.
1188 """
1189 self._check_hardpoint(hardpoint)
1190 fitted = self.hardpoints[hardpoint]
1191 displaced = fitted.weapon_id if fitted is not None else ""
1192 self.equip(weapon_id, hardpoint)
1193 return displaced
1194
1195 def free_hardpoint(self) -> int | None:
1196 """The lowest bare hardpoint, or None when every mount is taken.
1197
1198 The same choice :meth:`equip` is given when a purchase lands, so a
1199 shelf can name the hardpoint a weapon would take before it is bought.
1200 """
1201 for index, fitted in enumerate(self.hardpoints):
1202 if fitted is None:
1203 return index
1204 return None
1205
1206 def fit_lines(self) -> list[str]:
1207 """One line per hardpoint, for a screen that shows the current fit.
1208
1209 Bare mounts are listed as bare, because the question a pilot asks in a
1210 shop is as often "where would this go?" as "what have I got?". A
1211 ballistic mount also carries its magazine and its spare boxes, which is
1212 the number that decides whether the next purchase is a gun or a box.
1213 """
1214 lines: list[str] = []
1215 for index, fitted in enumerate(self.hardpoints):
1216 head = f"hardpoint {index + 1}"
1217 if fitted is None:
1218 lines.append(f"{head}: empty")
1219 continue
1220 name = fitted.weapon_id.replace("_", " ").upper()
1221 line = f"{head}: {name}, {weapon_summary(fitted.weapon_id)}"
1222 if isinstance(fitted, BallisticWeapon):
1223 line += f", {fitted.rounds}/{fitted.magazine_size} loaded, {self.ammo_boxes(fitted.weapon_id)} boxes"
1224 lines.append(line)
1225 return lines
1226
1227 def add_hardpoint(self) -> int:
1228 """Open one more hardpoint, up to the hull maximum, and return its index."""
1229 if len(self.hardpoints) >= balance.HARDPOINTS_MAX:
1230 raise ValueError(f"a hull carries at most {balance.HARDPOINTS_MAX} hardpoints")
1231 self.hardpoints.append(None)
1232 return len(self.hardpoints) - 1
1233
1234 def weapons(self) -> list[Weapon]:
1235 """Every fitted weapon, in hardpoint order."""
1236 return [w for w in self.hardpoints if w is not None]
1237
1238 def _check_hardpoint(self, hardpoint: int) -> None:
1239 if not 0 <= hardpoint < len(self.hardpoints):
1240 raise ValueError(f"hardpoint {hardpoint} does not exist (rack has {len(self.hardpoints)})")
1241
1242 # -- ammunition -------------------------------------------------------
1243
1244 def ammo_boxes(self, weapon_id: str) -> int:
1245 """Spare boxes held for *weapon_id*."""
1246 return self._boxes.get(weapon_id, 0)
1247
1248 def add_ammo_box(self, weapon_id: str) -> None:
1249 """Stow one box; boxes are per weapon, as the magazines are."""
1250 self._require_ballistic(weapon_id)
1251 self._boxes[weapon_id] = self._boxes.get(weapon_id, 0) + 1
1252
1253 def take_ammo_box(self, weapon_id: str) -> bool:
1254 """Consume one stowed box. False when the locker is empty."""
1255 held = self._boxes.get(weapon_id, 0)
1256 if held <= 0:
1257 return False
1258 self._boxes[weapon_id] = held - 1
1259 return True
1260
1261 def ammo_eater(self) -> str | None:
1262 """The mounted ballistic weapon a bought box feeds: the hungriest one.
1263
1264 None when nothing on the hull takes boxes, which is what makes a box a
1265 refusable purchase rather than scrap spent on a locker with no gun.
1266 """
1267 ballistic = [weapon for weapon in self.weapons() if weapon.spec.family == "ballistic"]
1268 if not ballistic:
1269 return None
1270 return min(ballistic, key=lambda weapon: self.ammo_boxes(weapon.weapon_id)).weapon_id
1271
1272 def locker(self) -> dict[str, int]:
1273 """Every non-empty box count by weapon id, for the suspend save."""
1274 return {weapon_id: held for weapon_id, held in self._boxes.items() if held > 0}
1275
1276 @staticmethod
1277 def _require_ballistic(weapon_id: str) -> None:
1278 spec = balance.WEAPONS.get(weapon_id)
1279 if spec is None or spec.family != "ballistic":
1280 raise ValueError(f"{weapon_id!r} does not take ammo boxes")
1281
1282 # -- driving ----------------------------------------------------------
1283
1284 def set_aim(self, world_point: Vec3) -> None:
1285 """The flight-plane point every fitted weapon converges on."""
1286 self._aim_point = Vec3(world_point)
1287
1288 def set_primary_trigger(self, held: bool) -> None:
1289 self._triggers[PRIMARY_ACTION] = bool(held)
1290
1291 def set_mining_trigger(self, held: bool) -> None:
1292 self._triggers[MINING_ACTION] = bool(held)
1293
1294 def on_update(self, dt: float):
1295 if self.player_controlled:
1296 self.set_primary_trigger(Input.is_action_pressed(PRIMARY_ACTION))
1297 self.set_mining_trigger(Input.is_action_pressed(MINING_ACTION))
1298 aim = self._aim_point if self._aim_point is not None else self._default_aim()
1299 for weapon in self.weapons():
1300 weapon.set_aim(aim)
1301 weapon.set_trigger(self._triggers[weapon.trigger_action])
1302
1303 def _default_aim(self) -> Vec3:
1304 """Straight down the hull's nose when nothing has set an aim point."""
1305 heading = float(getattr(self.parent, "heading", 0.0))
1306 here = _plane_of(self)
1307 return Vec3(here[0] + math.cos(heading) * 10.0, PLANE_Y, here[1] - math.sin(heading) * 10.0)
1308
1309
1310def attach_rack(ship: Node, hardpoints: int = balance.HARDPOINTS_STARTER, loadout: tuple[str, ...] = ()) -> WeaponRack:
1311 """Mount a rack on *ship* and fit *loadout* to its hardpoints in order."""
1312 rack = ship.add_child(WeaponRack(hardpoints=hardpoints))
1313 for index, weapon_id in enumerate(loadout[:hardpoints]):
1314 rack.equip(weapon_id, index)
1315 return rack