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 it
16 sets one, 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# A mining laser that drew the same full-length bar of light whether it was
117# cutting a vein or crossing empty space would leave a scrap counter that ticks
118# twice a second as the only way to find out whether the beam was on anything,
119# and a pilot could sweep it across a whole sector without ever learning what
120# it was for. So the beam ends where it bites, marks the bite, and thins out
121# 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 out.extend(tree.group(group, Node3D))
353 return out
354
355
356def _sweep(
357 tree,
358 origin: tuple[float, float],
359 direction: tuple[float, float],
360 length: float,
361 padding: float,
362 *groups: str,
363) -> list[tuple[float, Node3D, tuple[float, float]]]:
364 """Every group member the segment crosses, nearest first.
365
366 Each hit is ``(distance, node, centre)``. *centre* is the plane centre of
367 the body that was actually struck, which for a swarm is the member's own
368 position rather than the shoal node's: a shoal is tested against each of
369 its live members, so it reports the distance at which the segment reaches
370 its nearest mite, and the damage that follows has to land on that mite
371 rather than on the shoal's centre of mass.
372 """
373 hits: list[tuple[float, Node3D, tuple[float, float]]] = []
374 for node in _group_nodes(tree, *groups):
375 radius = _target_radius(node) + padding
376 if _is_swarm(node):
377 reached = [
378 (distance, member)
379 for distance, member in (
380 (_segment_circle_distance(origin, direction, length, member, radius), member)
381 for member in _swarm_members(node)
382 )
383 if distance is not None
384 ]
385 if not reached:
386 continue
387 distance, centre = min(reached, key=lambda pair: pair[0])
388 else:
389 centre = _plane_of(node)
390 distance = _segment_circle_distance(origin, direction, length, centre, radius)
391 if distance is None:
392 continue
393 hits.append((distance, node, centre))
394 hits.sort(key=lambda hit: hit[0])
395 return hits
396
397
398def _hostiles(tree) -> list[Node3D]:
399 """Everything player fire is allowed to damage."""
400 return _group_nodes(tree, Groups.ENEMIES, Groups.HUNTER)
401
402
403def _deal(
404 tree,
405 target: Node3D,
406 amount: float,
407 kind: str,
408 direction: tuple[float, float],
409 at: tuple[float, float] | None = None,
410 splash: float = 0.0,
411) -> None:
412 """Hand a damage number to the router, the only code that applies one.
413
414 A swarm is the one exception, and it is an interface gap rather than a
415 choice: ``DamageRouter.deal`` takes a node, and a shoal's members are array
416 rows at their own positions, so damage that landed at a point is applied
417 through the shoal's own ``damage_at`` instead. *at* is therefore the struck
418 member's own centre, not the point on its surface the round touched: the
419 sweep pads a target's radius by the projectile's, and a burst centred on
420 that padded surface would fall entirely outside the member it just hit.
421 """
422 if at is not None and _is_swarm(target):
423 target.damage_at(Vec3(at[0], PLANE_Y, at[1]), max(splash, _target_radius(target)), amount, kind)
424 return
425 router = tree.singletons.get(Services.DAMAGE) if tree is not None else None
426 if router is None:
427 return
428 router.deal(target, amount, kind=kind, direction=Vec3(direction[0], 0.0, direction[1]))
429
430
431# ============================================================================
432# Projectiles
433# ============================================================================
434
435
436class Projectile(Area3D):
437 """One round in flight on the plane.
438
439 The sensor body carries the ``Layers.PLAYER_FIRE`` contract so terrain and
440 anything else watching player fire sees the round; damage is resolved by
441 sweeping the segment travelled this step, which is what stops a fast round
442 from stepping over a small target between frames.
443 """
444
445 def __init__(
446 self,
447 *,
448 weapon_id: str,
449 kind: str,
450 damage: float,
451 direction: tuple[float, float],
452 speed: float,
453 reach: float,
454 pierce: int = 0,
455 blast: float = 0.0,
456 homing_rate: float = 0.0,
457 **kwargs,
458 ):
459 kwargs.setdefault("shape", SphereShape3D(radius=PROJECTILE_RADIUS))
460 super().__init__(**kwargs)
461 self.collision_layer = Layers.PLAYER_FIRE
462 self.collision_mask = Layers.MASK_PLAYER_FIRE
463 self.weapon_id = weapon_id
464 self.kind = kind
465 self.damage = float(damage)
466 self.speed = float(speed)
467 self.reach = float(reach)
468 self.pierce = int(pierce)
469 self.blast = float(blast)
470 self.homing_rate = float(homing_rate)
471 self._direction = _normalise(direction[0], direction[1])
472 self._travelled = 0.0
473 self._struck: set[int] = set()
474
475 def on_enter_tree(self):
476 super().on_enter_tree()
477 self.add_to_group(Groups.PLAYER_PROJECTILES)
478
479 def on_ready(self):
480 tracer = Material(colour=PROJECTILE_COLOUR, emissive_colour=PROJECTILE_COLOUR[:3], emissive_strength=3.0)
481 self.add_child(
482 MeshInstance3D(
483 name="Tracer",
484 mesh=Mesh.sphere(radius=PROJECTILE_RADIUS, rings=6, segments=8),
485 material=tracer,
486 )
487 )
488 self.body_entered.connect(self._on_body_entered)
489
490 def on_fixed_update(self, dt: float):
491 if self.destroying:
492 return
493 if self.homing_rate > 0.0:
494 self._steer(dt)
495 step = min(self.speed * dt, self.reach - self._travelled)
496 if step <= 0.0:
497 self._expire(detonate=False)
498 return
499 origin = _plane_of(self)
500 if self._resolve(origin, step):
501 return
502 self.position = Vec3(
503 origin[0] + self._direction[0] * step,
504 PLANE_Y,
505 origin[1] + self._direction[1] * step,
506 )
507 self._travelled += step
508
509 def _steer(self, dt: float):
510 """Turn toward the nearest hostile, limited by the homing rate."""
511 here = _plane_of(self)
512 nearest = None
513 best = float("inf")
514 for node in _hostiles(self.tree):
515 tx, tz = _plane_of(node)
516 distance = math.hypot(tx - here[0], tz - here[1])
517 if distance < best:
518 best, nearest = distance, node
519 if nearest is None:
520 return
521 tx, tz = _plane_of(nearest)
522 wanted = _normalise(tx - here[0], tz - here[1])
523 cross = self._direction[0] * wanted[1] - self._direction[1] * wanted[0]
524 dot = max(-1.0, min(1.0, self._direction[0] * wanted[0] + self._direction[1] * wanted[1]))
525 turn = max(-self.homing_rate * dt, min(self.homing_rate * dt, math.acos(dot) * (1.0 if cross >= 0.0 else -1.0)))
526 self._direction = _normalise(*_rotate(self._direction[0], self._direction[1], turn))
527
528 def _resolve(self, origin: tuple[float, float], step: float) -> bool:
529 """Damage everything the step crosses. True when the round is spent."""
530 for distance, target, centre in _sweep(
531 self.tree, origin, self._direction, step, PROJECTILE_RADIUS, Groups.ENEMIES, Groups.HUNTER
532 ):
533 if id(target) in self._struck:
534 continue
535 self._struck.add(id(target))
536 _deal(self.tree, target, self.damage, self.kind, self._direction, at=centre, splash=self.blast)
537 if self.pierce <= 0:
538 self.position = Vec3(
539 origin[0] + self._direction[0] * distance,
540 PLANE_Y,
541 origin[1] + self._direction[1] * distance,
542 )
543 self._expire(detonate=True)
544 return True
545 self.pierce -= 1
546 return False
547
548 def _on_body_entered(self, other):
549 """Terrain stops a round where it hits, blast and all."""
550 if int(getattr(other, "collision_layer", 0)) & Layers.TERRAIN:
551 self._expire(detonate=True)
552
553 def _expire(self, *, detonate: bool):
554 if self.destroying:
555 return
556 if detonate and self.blast > 0.0:
557 self._detonate()
558 self.remove_from_group(Groups.PLAYER_PROJECTILES)
559 self.destroy()
560
561 def _detonate(self):
562 """Splash damage on everything in the blast that the round did not hit.
563
564 A shoal caught by the blast takes it across the whole radius in one
565 call, which is the burst clearing several rows at once.
566 """
567 centre = _plane_of(self)
568 for target in _hostiles(self.tree):
569 if id(target) in self._struck:
570 continue
571 if _is_swarm(target):
572 _deal(self.tree, target, self.damage, self.kind, self._direction, at=centre, splash=self.blast)
573 continue
574 tx, tz = _plane_of(target)
575 if math.hypot(tx - centre[0], tz - centre[1]) <= self.blast + _target_radius(target):
576 _deal(self.tree, target, self.damage, self.kind, self._direction)
577
578
579# ============================================================================
580# Weapons
581# ============================================================================
582
583
584class Weapon(Node3D):
585 """One mounted weapon: cadence, cost and the shape of what it fires.
586
587 A weapon is inert until something drives it. :meth:`set_trigger` holds the
588 trigger down (the rack does this from the input actions every frame) and
589 :meth:`try_fire` demands a shot right now, returning whether one left the
590 muzzle. Beams report whether they are currently burning.
591 """
592
593 def __init__(self, weapon_id: str, **kwargs):
594 if weapon_id not in balance.WEAPONS:
595 raise ValueError(f"unknown weapon id {weapon_id!r}")
596 kwargs.setdefault("name", weapon_id)
597 super().__init__(**kwargs)
598 self.weapon_id = weapon_id
599 self.spec = balance.WEAPONS[weapon_id]
600 self.profile = FIRE_PROFILES[weapon_id]
601 self.rack: WeaponRack | None = None
602 self._trigger = False
603 self._cooldown = 0.0
604 self._aim_point = Vec3(0.0, PLANE_Y, 0.0)
605 self._beam_active = False
606 self._beam: Node3D | None = None
607 self._beam_mesh: MeshInstance3D | None = None
608 self._beam_spot: MeshInstance3D | None = None
609 self._beam_hit = False
610 self._beam_length = 0.0
611 self._spark_wait = 0.0
612 self._tracer_left = 0.0
613 self._hacking: set[int] = set()
614
615 # -- the mount --------------------------------------------------------
616
617 def on_ready(self):
618 self.add_child(self._build_mount())
619
620 def _build_mount(self) -> Node3D:
621 """The gun as it sits on the hull: a breech block and a barrel.
622
623 Without this a hardpoint is an empty transform, so buying a weapon
624 changes the ship's fire and nothing about the ship, and a pilot has no
625 way to see which hardpoint carries what. The barrel runs along local
626 -Z, which is the hull's nose, and its length and bore come off the
627 family, so a rail lance and a pulse blaster are told apart in
628 silhouette. Geometry from engine primitives, as the beam and the tracer
629 already are; ``artkit`` would be the tidier home for it once it grows a
630 player-weapon builder.
631 """
632 family = self.spec.family
633 length = MOUNT_BARREL_LENGTH[family]
634 radius = MOUNT_BARREL_RADIUS[family]
635 mount = Node3D(name="Mount")
636 mount.add_child(
637 MeshInstance3D(
638 name="Breech",
639 mesh=Mesh.box(size=MOUNT_BREECH_SIZE),
640 material=Material(colour=MOUNT_METAL_COLOUR, metallic=0.8, roughness=0.45),
641 )
642 )
643 mount.add_child(
644 MeshInstance3D(
645 name="Barrel",
646 mesh=Mesh.cylinder(radius=radius, height=length, segments=10),
647 material=Material(colour=MOUNT_METAL_COLOUR, metallic=0.85, roughness=0.35),
648 rotation=Quat.from_euler(math.radians(90.0), 0.0, 0.0),
649 position=Vec3(0.0, 0.0, -length * 0.5),
650 )
651 )
652 accent = MOUNT_ACCENT_COLOURS[family]
653 mount.add_child(
654 MeshInstance3D(
655 name="Muzzle",
656 mesh=Mesh.sphere(radius=radius * 1.25, rings=6, segments=8),
657 material=Material(colour=accent, emissive_colour=accent[:3], emissive_strength=MOUNT_ACCENT_STRENGTH),
658 position=Vec3(0.0, 0.0, -length),
659 )
660 )
661 return mount
662
663 # -- description ------------------------------------------------------
664
665 @property
666 def trigger_action(self) -> str:
667 """The input action this weapon answers."""
668 return MINING_ACTION if self.weapon_id in TRIGGER_MINING else PRIMARY_ACTION
669
670 @property
671 def damage_per_projectile(self) -> float:
672 """DPS divided evenly across a second's worth of pellets."""
673 shots_per_second = self.profile.rate * self.profile.pellets
674 if self.profile.beam or shots_per_second <= 0.0:
675 return 0.0
676 return self.spec.dps / shots_per_second
677
678 @property
679 def beam_active(self) -> bool:
680 """Whether a beam weapon is burning right now."""
681 return self._beam_active
682
683 # -- driving ----------------------------------------------------------
684
685 def set_trigger(self, held: bool) -> None:
686 """Hold or release the trigger; beams and automatics keep going."""
687 self._trigger = bool(held)
688
689 def set_aim(self, world_point: Vec3) -> None:
690 """The flight-plane point every shot converges on."""
691 self._aim_point = Vec3(world_point)
692
693 def try_fire(self, aim_point: Vec3) -> bool:
694 """Fire one shot at *aim_point* if the weapon is ready and can pay.
695
696 Beams engage instead of firing a shot and report whether they burn.
697 """
698 self.set_aim(aim_point)
699 if self.profile.beam:
700 self._trigger = True
701 return self._beam_active
702 if self._cooldown > 0.0 or not self._pay_for_shot():
703 return False
704 self._cooldown = 1.0 / self.profile.rate
705 self._discharge()
706 self._announce_fired()
707 return True
708
709 def on_update(self, dt: float):
710 self._cooldown = max(0.0, self._cooldown - dt)
711 if self.profile.beam:
712 self._tick_beam(dt)
713 return
714 if self._trigger:
715 self.try_fire(self._aim_point)
716 if self._tracer_left > 0.0:
717 self._tracer_left -= dt
718 if self._tracer_left <= 0.0 and self._beam is not None:
719 self._beam.visible = False
720 self._beam_hit = False
721
722 # -- payment (overridden by the two economies) ------------------------
723
724 def _pay_for_shot(self) -> bool:
725 return True
726
727 def _pay_for_beam(self, dt: float) -> bool:
728 return True
729
730 def _power(self):
731 return self.tree.singletons.get(Services.POWER) if self.tree is not None else None
732
733 def _free_fire(self) -> bool:
734 return bool(self.rack is not None and self.rack.free_fire)
735
736 # -- discharge --------------------------------------------------------
737
738 def _aim_direction(self) -> tuple[float, float]:
739 muzzle = _plane_of(self)
740 aim = self._aim_point
741 return _normalise(float(aim[0]) - muzzle[0], float(aim[2]) - muzzle[1])
742
743 def _discharge(self) -> None:
744 """Put one trigger pull's worth of shots into the world."""
745 base = self._aim_direction()
746 pellets = self.profile.pellets
747 for index in range(pellets):
748 offset = 0.0
749 if pellets > 1 and self.profile.spread > 0.0:
750 offset = self.profile.spread * (2.0 * index / (pellets - 1) - 1.0)
751 direction = _rotate(base[0], base[1], offset)
752 if self.profile.speed <= 0.0:
753 self._hitscan(direction)
754 else:
755 self._spawn_projectile(direction)
756
757 def _hitscan(self, direction: tuple[float, float]) -> None:
758 """Resolve an instant shot and leave a tracer along the line it took."""
759 origin = _plane_of(self)
760 allowed = self.profile.pierce + 1
761 reach = self.profile.reach
762 hit = False
763 for distance, target, centre in _sweep(
764 self.tree, origin, direction, self.profile.reach, 0.0, Groups.ENEMIES, Groups.HUNTER
765 ):
766 _deal(self.tree, target, self.damage_per_projectile, self.spec.family, direction, at=centre)
767 reach, hit = distance, True
768 allowed -= 1
769 if allowed <= 0:
770 break
771 self._show_beam(direction, reach, hit=hit, full_bore=True)
772 self._tracer_left = HITSCAN_TRACER_S
773
774 def _spawn_projectile(self, direction: tuple[float, float]) -> None:
775 if self.tree is None or self.tree.root is None:
776 return
777 muzzle = _plane_of(self)
778 self.tree.root.add_child(
779 Projectile(
780 name=f"{self.weapon_id}_round",
781 weapon_id=self.weapon_id,
782 kind=self.spec.family,
783 damage=self.damage_per_projectile,
784 direction=direction,
785 speed=self.profile.speed,
786 reach=self.profile.reach,
787 pierce=self.profile.pierce,
788 blast=self.profile.blast,
789 homing_rate=self.profile.homing_rate,
790 position=Vec3(muzzle[0], PLANE_Y, muzzle[1]),
791 )
792 )
793
794 # -- beams ------------------------------------------------------------
795
796 def _tick_beam(self, dt: float) -> None:
797 if not self._trigger or not self._pay_for_beam(dt):
798 self._end_beam()
799 return
800 if not self._beam_active:
801 self._beam_active = True
802 self._announce_fired()
803 self._beam_damage(dt)
804
805 def _beam_damage(self, dt: float) -> None:
806 origin = _plane_of(self)
807 direction = self._aim_direction()
808 reach = self.profile.reach
809 hit = False
810 struck = _sweep(self.tree, origin, direction, reach, 0.0, Groups.ENEMIES, Groups.HUNTER)
811 if struck:
812 distance, target, centre = struck[0]
813 reach, hit = min(reach, distance), True
814 _deal(self.tree, target, self.spec.dps * dt, self.spec.family, direction, at=centre)
815 if self.profile.mines:
816 mined = self._mine(origin, direction, dt)
817 hit = hit or mined < self.profile.reach
818 reach = min(reach, mined)
819 if not hit:
820 reach = self.profile.reach * BEAM_MISS_REACH_FRACTION
821 self._show_beam(direction, reach, hit=hit)
822 self._tick_sparks(dt, origin, direction, reach, hit)
823
824 def _mine(self, origin: tuple[float, float], direction: tuple[float, float], dt: float) -> float:
825 """Work the beam against the surfaces it can reach; returns the reach used.
826
827 Deposits take the mining multiplier rather than the raw beam DPS, and a
828 vault held in the beam starts its hack channel once. A wreck's hull is
829 neither, but it is solid: the beam stops on it rather than passing
830 through, because a beam that draws straight through a hulk is telling
831 the pilot the hulk is not there.
832 """
833 reach = self.profile.reach
834 padding = BEAM_CONTACT_PADDING
835 deposits = _sweep(self.tree, origin, direction, reach, padding, Groups.DEPOSITS)
836 if deposits:
837 distance, deposit, _centre = deposits[0]
838 reach = min(reach, distance)
839 deposit.mine(self.spec.dps * balance.MINING_LASER_DEPOSIT_MULT, dt)
840 vaults = _sweep(self.tree, origin, direction, reach, padding, Groups.VAULTS)
841 if vaults:
842 distance, vault, _centre = vaults[0]
843 reach = min(reach, distance)
844 if id(vault) not in self._hacking:
845 self._hacking.add(id(vault))
846 vault.begin_hack()
847 wrecks = _sweep(self.tree, origin, direction, reach, padding, Groups.WRECKS)
848 if wrecks:
849 reach = min(reach, wrecks[0][0])
850 return reach
851
852 @property
853 def beam_on_target(self) -> bool:
854 """Whether the beam currently drawn is standing on a surface."""
855 return self._beam_hit
856
857 @property
858 def beam_reach(self) -> float:
859 """How far the beam currently drawn actually travels, world units."""
860 return self._beam_length
861
862 def _tick_sparks(
863 self,
864 dt: float,
865 origin: tuple[float, float],
866 direction: tuple[float, float],
867 reach: float,
868 hit: bool,
869 ) -> None:
870 """Throw chips off the contact point while the beam is biting something.
871
872 A beam in vacuum throws none, so the sparks and their absence are the
873 same message read twice: the pilot who cannot tell a thinner beam from a
874 fatter one can still tell a shower from an empty line.
875 """
876 if not hit or vfx is None or self.tree is None:
877 self._spark_wait = 0.0
878 return
879 self._spark_wait -= dt
880 if self._spark_wait > 0.0:
881 return
882 self._spark_wait = BEAM_SPARK_INTERVAL_S
883 contact = Vec3(origin[0] + direction[0] * reach, PLANE_Y, origin[1] + direction[1] * reach)
884 vfx.Vfx.spawn(self.tree, "beam_spark", contact, direction=Vec3(-direction[0], 0.0, -direction[1]))
885
886 def _show_beam(
887 self,
888 direction: tuple[float, float],
889 length: float,
890 *,
891 hit: bool = True,
892 full_bore: bool | None = None,
893 ) -> None:
894 """Draw the beam out to *length*, saying whether it found anything.
895
896 A held beam that ends on a surface is at full bore, warm, and capped
897 with a bead of light on the thing it is cutting; one that ends in vacuum
898 is thin and grey. The two must not be told apart by the scrap counter
899 alone.
900
901 *full_bore* separates the two questions for the tracer a hitscan round
902 leaves behind, which is a slug's own trace rather than a beam standing
903 on something: it is drawn hot whether or not the slug connected, and
904 only the bead answers to *hit*.
905 """
906 if self._beam is None:
907 self._build_beam()
908 hot = hit if full_bore is None else bool(full_bore)
909 self._beam.visible = True
910 self._beam.face_along(Vec3(direction[0], 0.0, direction[1]))
911 width = 1.0 if hot else BEAM_MISS_WIDTH
912 self._beam_mesh.position = Vec3(0.0, 0.0, -length * 0.5)
913 self._beam_mesh.scale = Vec3(width, max(length, 1e-3), width)
914 material = self._beam_mesh.material
915 colour = BEAM_COLOUR if hot else BEAM_MISS_COLOUR
916 material.colour = colour
917 material.emissive_colour = colour[:3]
918 material.emissive_strength = BEAM_EMISSIVE_HIT if hot else BEAM_EMISSIVE_MISS
919 self._beam_spot.position = Vec3(0.0, 0.0, -length)
920 self._beam_spot.visible = hit
921 self._beam_hit = hit
922 self._beam_length = float(length)
923
924 def _build_beam(self) -> None:
925 self._beam = self.add_child(Node3D(name="Beam"))
926 self._beam_mesh = self._beam.add_child(
927 MeshInstance3D(
928 name="Core",
929 mesh=Mesh.cylinder(radius=BEAM_RADIUS, height=1.0, segments=8),
930 material=Material(
931 colour=BEAM_COLOUR,
932 emissive_colour=BEAM_COLOUR[:3],
933 emissive_strength=BEAM_EMISSIVE_HIT,
934 ),
935 rotation=Quat.from_euler(math.radians(90.0), 0.0, 0.0),
936 )
937 )
938 self._beam_spot = self._beam.add_child(
939 MeshInstance3D(
940 name="Contact",
941 mesh=Mesh.sphere(radius=BEAM_RADIUS * BEAM_SPOT_RADIUS_MULT, rings=6, segments=10),
942 material=Material(
943 colour=BEAM_COLOUR,
944 emissive_colour=BEAM_COLOUR[:3],
945 emissive_strength=BEAM_EMISSIVE_HIT,
946 ),
947 )
948 )
949 self._beam_spot.visible = False
950
951 def _end_beam(self) -> None:
952 self._beam_active = False
953 self._beam_hit = False
954 self._spark_wait = 0.0
955 self._hacking.clear()
956 if self._beam is not None:
957 self._beam.visible = False
958 if self._beam_spot is not None:
959 self._beam_spot.visible = False
960
961 # -- signals ----------------------------------------------------------
962
963 def _announce_fired(self) -> None:
964 if self.rack is not None:
965 self.rack.weapon_fired.emit(self.weapon_id)
966
967
968class EnergyWeapon(Weapon):
969 """Pulse blaster, scatter coil, arc beam, mining laser and nova mortar.
970
971 Fed entirely by the capacitor through ``PowerSystem``; no magazine, no
972 ammo box, no scrap. Last Stand waives the cost without waiving the shot.
973 """
974
975 def _energy_per_shot(self) -> float:
976 if self.spec.energy_per_shot is not None:
977 return float(self.spec.energy_per_shot)
978 return PROVISIONAL_ENERGY_PER_SHOT.get(self.weapon_id, 0.0)
979
980 def _energy_per_second(self) -> float:
981 if self.spec.energy_per_s is not None:
982 return float(self.spec.energy_per_s)
983 return 0.0
984
985 def _pay_for_shot(self) -> bool:
986 cost = self._energy_per_shot()
987 power = self._power()
988 if cost <= 0.0 or power is None or self._free_fire():
989 return True
990 return bool(power.request(cost, self.weapon_id))
991
992 def _pay_for_beam(self, dt: float) -> bool:
993 cost = self._energy_per_second()
994 power = self._power()
995 if cost <= 0.0 or power is None or self._free_fire():
996 return True
997 return bool(power.drain(cost, dt, self.weapon_id))
998
999
1000class BallisticWeapon(Weapon):
1001 """Rail lance, flak cannon, autocannon and missile rack.
1002
1003 Fed by a magazine that ammo boxes refill and by nothing else: a ballistic
1004 weapon costs zero capacitor, which is what lets a scrap-fed build leave the
1005 power economy altogether. An empty magazine swaps a box in automatically
1006 when the rack holds one, at the price of a reload lockout.
1007 """
1008
1009 def __init__(self, weapon_id: str, **kwargs):
1010 super().__init__(weapon_id, **kwargs)
1011 self.magazine_size = int(self.spec.rounds_per_box or 0)
1012 self.rounds = self.magazine_size
1013
1014 def reload_from_box(self) -> bool:
1015 """Consume one of the rack's boxes to refill the magazine."""
1016 if self.rounds >= self.magazine_size or self.rack is None:
1017 return False
1018 if not self.rack.take_ammo_box(self.weapon_id):
1019 return False
1020 self.rounds = self.magazine_size
1021 self._announce_ammo()
1022 return True
1023
1024 def _pay_for_shot(self) -> bool:
1025 needed = self.profile.rounds_per_shot
1026 if self.rounds < needed:
1027 if self.reload_from_box():
1028 self._cooldown = RELOAD_S
1029 return False
1030 self.rounds -= needed
1031 self._announce_ammo()
1032 return True
1033
1034 def _announce_ammo(self) -> None:
1035 if self.rack is not None:
1036 self.rack.ammo_changed.emit(self.weapon_id, self.rounds)
1037
1038
1039class GravHook(Weapon):
1040 """The utility hardpoint: yanks one enemy or salvage mote toward the ship.
1041
1042 Deals no damage at all. What it moves, it moves on the flight plane at a
1043 fixed speed for a fixed time, so a mote pulled out of a Skimmer's path and
1044 a Lancer pulled out of its dash both read the same way.
1045 """
1046
1047 def __init__(self, weapon_id: str = "grav_hook", **kwargs):
1048 super().__init__(weapon_id, **kwargs)
1049 self._hooked: list[tuple[Node3D, float]] = []
1050
1051 def _pay_for_shot(self) -> bool:
1052 cost = PROVISIONAL_ENERGY_PER_SHOT.get(self.weapon_id, 0.0)
1053 power = self._power()
1054 if cost <= 0.0 or power is None or self._free_fire():
1055 return True
1056 return bool(power.request(cost, self.weapon_id))
1057
1058 def _discharge(self) -> None:
1059 origin = _plane_of(self)
1060 direction = self._aim_direction()
1061 caught = _sweep(
1062 self.tree, origin, direction, self.profile.reach, 0.0, Groups.ENEMIES, Groups.SALVAGE, Groups.HUNTER
1063 )
1064 # A swarm has no single body to grab: its members are array rows that
1065 # do not follow the shoal node.
1066 for _distance, target, _centre in caught:
1067 if not _is_swarm(target):
1068 self._hooked.append((target, GRAV_HOOK_PULL_S))
1069 return
1070
1071 def on_update(self, dt: float):
1072 super().on_update(dt)
1073 self._pull(dt)
1074
1075 def _pull(self, dt: float) -> None:
1076 if not self._hooked:
1077 return
1078 anchor = self._anchor()
1079 still: list[tuple[Node3D, float]] = []
1080 for target, remaining in self._hooked:
1081 if target.destroying or remaining <= 0.0:
1082 continue
1083 tx, tz = _plane_of(target)
1084 towards = _normalise(anchor[0] - tx, anchor[1] - tz)
1085 step = GRAV_HOOK_PULL_SPEED * dt
1086 target.position = Vec3(
1087 float(target.position[0]) + towards[0] * step,
1088 PLANE_Y,
1089 float(target.position[2]) + towards[1] * step,
1090 )
1091 still.append((target, remaining - dt))
1092 self._hooked = still
1093
1094 def _anchor(self) -> tuple[float, float]:
1095 """Where hooked things are dragged to: the ship, or the hook itself."""
1096 ships = _group_nodes(self.tree, Groups.SHIP)
1097 return _plane_of(ships[0]) if ships else _plane_of(self)
1098
1099
1100#: Which class mounts each roster entry.
1101_WEAPON_CLASSES = {"energy": EnergyWeapon, "ballistic": BallisticWeapon, "utility": GravHook}
1102
1103
1104def make_weapon(weapon_id: str, **kwargs) -> Weapon:
1105 """Build the weapon node for a roster id, picking the class by family."""
1106 if weapon_id not in balance.WEAPONS:
1107 raise ValueError(f"unknown weapon id {weapon_id!r}")
1108 return _WEAPON_CLASSES[balance.WEAPONS[weapon_id].family](weapon_id, **kwargs)
1109
1110
1111# ============================================================================
1112# The rack
1113# ============================================================================
1114
1115
1116class WeaponRack(Node3D):
1117 """The ship's hardpoints, what is bolted to them, and the ammo locker.
1118
1119 The rack is the single place other systems connect to for fire and ammo
1120 events, and the single place the two fire actions are read. It drives its
1121 weapons every frame: aim first, then trigger state, so a held button keeps
1122 an automatic firing and a released one drops a beam within the frame.
1123
1124 It is a ``Node3D`` rather than a bare ``Node`` because the weapons hanging
1125 off it are positioned hardpoints and must inherit the hull's transform.
1126 """
1127
1128 #: ``(weapon_id)`` whenever a shot leaves a muzzle or a beam engages.
1129 weapon_fired = Signal(str)
1130 #: ``(weapon_id, rounds)`` after every round spent and every reload.
1131 ammo_changed = Signal(str, int)
1132 #: ``(weapon_id, hardpoint)`` when a hardpoint's fitting changes.
1133 weapon_equipped = Signal(str, int)
1134
1135 def __init__(self, hardpoints: int = balance.HARDPOINTS_STARTER, **kwargs):
1136 kwargs.setdefault("name", "Hardpoints")
1137 super().__init__(**kwargs)
1138 self.hardpoints: list[Weapon | None] = [None] * self._validated_count(hardpoints)
1139 self._boxes: dict[str, int] = {}
1140 self._aim_point: Vec3 | None = None
1141 self._triggers = {PRIMARY_ACTION: False, MINING_ACTION: False}
1142 #: When True the rack reads the fire actions itself. Turrets and other
1143 #: non-player mounts set it False and drive the triggers directly.
1144 self.player_controlled = True
1145 #: Last Stand waives energy costs; ammo is still spent.
1146 self.free_fire = False
1147
1148 @staticmethod
1149 def _validated_count(hardpoints: int) -> int:
1150 if not 1 <= int(hardpoints) <= balance.HARDPOINTS_MAX:
1151 raise ValueError(f"hardpoint count must be 1 to {balance.HARDPOINTS_MAX}, got {hardpoints}")
1152 return int(hardpoints)
1153
1154 def on_enter_tree(self):
1155 super().on_enter_tree()
1156 self.add_to_group(WEAPON_RACK_GROUP)
1157
1158 # -- fitting ----------------------------------------------------------
1159
1160 def equip(self, weapon_id: str, hardpoint: int) -> None:
1161 """Bolt *weapon_id* to *hardpoint*, replacing whatever was there."""
1162 self._check_hardpoint(hardpoint)
1163 self.unequip(hardpoint)
1164 offset = HARDPOINT_OFFSETS[hardpoint % len(HARDPOINT_OFFSETS)]
1165 weapon = make_weapon(weapon_id, position=Vec3(offset[0], PLANE_Y, offset[1]))
1166 weapon.rack = self
1167 self.hardpoints[hardpoint] = self.add_child(weapon)
1168 self.weapon_equipped.emit(weapon_id, hardpoint)
1169
1170 def unequip(self, hardpoint: int) -> None:
1171 """Strip a hardpoint back to bare mounting."""
1172 self._check_hardpoint(hardpoint)
1173 fitted = self.hardpoints[hardpoint]
1174 if fitted is not None:
1175 self.hardpoints[hardpoint] = None
1176 fitted.rack = None
1177 fitted.destroy()
1178
1179 def swap_in(self, weapon_id: str, hardpoint: int) -> str:
1180 """Fit *weapon_id* to *hardpoint* and return whatever it displaced.
1181
1182 The empty string when the hardpoint was bare. The caller owns the
1183 locker, so a swap is one call here and one list edit there rather than
1184 two half-authoritative copies of what the ship is carrying; the run
1185 scene puts the returned id back into its stowage.
1186 """
1187 self._check_hardpoint(hardpoint)
1188 fitted = self.hardpoints[hardpoint]
1189 displaced = fitted.weapon_id if fitted is not None else ""
1190 self.equip(weapon_id, hardpoint)
1191 return displaced
1192
1193 def free_hardpoint(self) -> int | None:
1194 """The lowest bare hardpoint, or None when every mount is taken.
1195
1196 The same choice :meth:`equip` is given when a purchase lands, so a
1197 shelf can name the hardpoint a weapon would take before it is bought.
1198 """
1199 for index, fitted in enumerate(self.hardpoints):
1200 if fitted is None:
1201 return index
1202 return None
1203
1204 def fit_lines(self) -> list[str]:
1205 """One line per hardpoint, for a screen that shows the current fit.
1206
1207 Bare mounts are listed as bare, because the question a pilot asks in a
1208 shop is as often "where would this go?" as "what have I got?". A
1209 ballistic mount also carries its magazine and its spare boxes, which is
1210 the number that decides whether the next purchase is a gun or a box.
1211 """
1212 lines: list[str] = []
1213 for index, fitted in enumerate(self.hardpoints):
1214 head = f"hardpoint {index + 1}"
1215 if fitted is None:
1216 lines.append(f"{head}: empty")
1217 continue
1218 name = fitted.weapon_id.replace("_", " ").upper()
1219 line = f"{head}: {name}, {weapon_summary(fitted.weapon_id)}"
1220 if isinstance(fitted, BallisticWeapon):
1221 line += f", {fitted.rounds}/{fitted.magazine_size} loaded, {self.ammo_boxes(fitted.weapon_id)} boxes"
1222 lines.append(line)
1223 return lines
1224
1225 def add_hardpoint(self) -> int:
1226 """Open one more hardpoint, up to the hull maximum, and return its index."""
1227 if len(self.hardpoints) >= balance.HARDPOINTS_MAX:
1228 raise ValueError(f"a hull carries at most {balance.HARDPOINTS_MAX} hardpoints")
1229 self.hardpoints.append(None)
1230 return len(self.hardpoints) - 1
1231
1232 def weapons(self) -> list[Weapon]:
1233 """Every fitted weapon, in hardpoint order."""
1234 return [w for w in self.hardpoints if w is not None]
1235
1236 def _check_hardpoint(self, hardpoint: int) -> None:
1237 if not 0 <= hardpoint < len(self.hardpoints):
1238 raise ValueError(f"hardpoint {hardpoint} does not exist (rack has {len(self.hardpoints)})")
1239
1240 # -- ammunition -------------------------------------------------------
1241
1242 def ammo_boxes(self, weapon_id: str) -> int:
1243 """Spare boxes held for *weapon_id*."""
1244 return self._boxes.get(weapon_id, 0)
1245
1246 def add_ammo_box(self, weapon_id: str) -> None:
1247 """Stow one box; boxes are per weapon, as the magazines are."""
1248 self._require_ballistic(weapon_id)
1249 self._boxes[weapon_id] = self._boxes.get(weapon_id, 0) + 1
1250
1251 def take_ammo_box(self, weapon_id: str) -> bool:
1252 """Consume one stowed box. False when the locker is empty."""
1253 held = self._boxes.get(weapon_id, 0)
1254 if held <= 0:
1255 return False
1256 self._boxes[weapon_id] = held - 1
1257 return True
1258
1259 def ammo_eater(self) -> str | None:
1260 """The mounted ballistic weapon a bought box feeds: the hungriest one.
1261
1262 None when nothing on the hull takes boxes, which is what makes a box a
1263 refusable purchase rather than scrap spent on a locker with no gun.
1264 """
1265 ballistic = [weapon for weapon in self.weapons() if weapon.spec.family == "ballistic"]
1266 if not ballistic:
1267 return None
1268 return min(ballistic, key=lambda weapon: self.ammo_boxes(weapon.weapon_id)).weapon_id
1269
1270 def locker(self) -> dict[str, int]:
1271 """Every non-empty box count by weapon id, for the suspend save."""
1272 return {weapon_id: held for weapon_id, held in self._boxes.items() if held > 0}
1273
1274 @staticmethod
1275 def _require_ballistic(weapon_id: str) -> None:
1276 spec = balance.WEAPONS.get(weapon_id)
1277 if spec is None or spec.family != "ballistic":
1278 raise ValueError(f"{weapon_id!r} does not take ammo boxes")
1279
1280 # -- driving ----------------------------------------------------------
1281
1282 def set_aim(self, world_point: Vec3) -> None:
1283 """The flight-plane point every fitted weapon converges on."""
1284 self._aim_point = Vec3(world_point)
1285
1286 def set_primary_trigger(self, held: bool) -> None:
1287 self._triggers[PRIMARY_ACTION] = bool(held)
1288
1289 def set_mining_trigger(self, held: bool) -> None:
1290 self._triggers[MINING_ACTION] = bool(held)
1291
1292 def on_update(self, dt: float):
1293 if self.player_controlled:
1294 self.set_primary_trigger(Input.pressed(PRIMARY_ACTION))
1295 self.set_mining_trigger(Input.pressed(MINING_ACTION))
1296 aim = self._aim_point if self._aim_point is not None else self._default_aim()
1297 for weapon in self.weapons():
1298 weapon.set_aim(aim)
1299 weapon.set_trigger(self._triggers[weapon.trigger_action])
1300
1301 def _default_aim(self) -> Vec3:
1302 """Straight down the hull's nose when nothing has set an aim point."""
1303 heading = float(getattr(self.parent, "heading", 0.0))
1304 here = _plane_of(self)
1305 return Vec3(here[0] + math.cos(heading) * 10.0, PLANE_Y, here[1] - math.sin(heading) * 10.0)
1306
1307
1308def attach_rack(ship: Node, hardpoints: int = balance.HARDPOINTS_STARTER, loadout: tuple[str, ...] = ()) -> WeaponRack:
1309 """Mount a rack on *ship* and fit *loadout* to its hardpoints in order."""
1310 rack = ship.add_child(WeaponRack(hardpoints=hardpoints))
1311 for index, weapon_id in enumerate(loadout[:hardpoints]):
1312 rack.equip(weapon_id, index)
1313 return rack