From 6a8a7ead04fb6cb67f46697ef8daa4633ac8bd3f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 12:20:48 +0100 Subject: [PATCH] feat(client): keep the lead pipper through radar loss Follows the ship matched to the selected radar contact (true position and velocity) and extrapolates the last contact when no ship is loaded, so notching or the radar speed gate no longer removes the pipper. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh --- .../client/pipper/GunPipperRenderer.java | 54 +++++--- .../client/pipper/PipperConfig.java | 9 ++ .../client/pipper/TargetTrack.java | 131 ++++++++++++++++++ .../client/pipper/TargetTracker.java | 40 +++++- 4 files changed, 207 insertions(+), 27 deletions(-) create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/TargetTrack.java diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunPipperRenderer.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunPipperRenderer.java index b138c6a..bade3e2 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunPipperRenderer.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunPipperRenderer.java @@ -32,16 +32,19 @@ import org.valkyrienskies.mod.common.VSGameUtilsKt; /** * Draws two markers for the first light autocannon of the aircraft you sit in: * an orange ring where its shells come down on the ground, and, for the radar target selected on the seat, a green - * ring you put on the target to hit it (the lead pipper). Both come from the same ballistics Warium's shells follow. + * ring you put on the target to hit it (the lead pipper): green from the radar, cyan while following the ship itself, + * yellow while only extrapolating a lost contact. Both come from the same ballistics Warium's shells follow. */ @Mod.EventBusSubscriber(modid = ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT) public final class GunPipperRenderer { private static final int IMPACT_COLOR = 0xFFFF9020; private static final int LEAD_COLOR = 0xFF40FF60; + private static final int VISUAL_COLOR = 0xFF40E8FF; + private static final int COAST_COLOR = 0xFFFFE040; private static final float DISTANCE = 2.0F; private static final GunFinder FINDER = new GunFinder(); - private static final TargetTracker TRACKER = new TargetTracker(); + private static final TargetTrack TRACK = new TargetTrack(); private static Ballistics ballistics; private static double ballisticsDrag = Double.NaN; private static double ballisticsGravity = Double.NaN; @@ -62,7 +65,7 @@ public final class GunPipperRenderer { } Entity seat = WariumSeat.seatOf(minecraft.player); if (seat == null) { - TRACKER.reset(); + TRACK.reset(); return; } BlockPos seatPos = BlockPos.containing(seat.getX(), seat.getY(), seat.getZ()); @@ -109,9 +112,9 @@ public final class GunPipperRenderer { } if (PipperConfig.SHOW_LEAD.get()) { - drawn |= lead(builder, matrix, camera, level, seat, seatPos, table, muzzle, shot.speed(), svx, svy, svz); + drawn |= lead(builder, matrix, camera, level, seat, seatPos, ship.getId(), table, muzzle, shot.speed(), svx, svy, svz); } else { - TRACKER.reset(); + TRACK.reset(); } if (drawn) { @@ -125,20 +128,39 @@ public final class GunPipperRenderer { } private static boolean lead(BufferBuilder builder, Matrix4f matrix, Camera camera, ClientLevel level, Entity seat, BlockPos seatPos, - Ballistics table, Vector3d muzzle, double speed, double svx, double svy, double svz) { + long ownShipId, Ballistics table, Vector3d muzzle, double speed, double svx, double svy, double svz) { int selected = WariumSeat.selectedTarget(seat); - if (selected <= 0) { - TRACKER.reset(); + double[] radar = selected > 0 ? radarPosition(level, seatPos, selected) : null; + TargetTrack.Fix fix = TRACK.resolve(level, ownShipId, selected, radar, level.getGameTime(), + PipperConfig.VISUAL_TRACK.get(), PipperConfig.COAST_SECONDS.get()); + if (fix == null) { return false; } + Ballistics.Lead lead = table.lead(muzzle.x, muzzle.y, muzzle.z, speed, fix.x(), fix.y(), fix.z(), + fix.vx(), fix.vy(), fix.vz(), svx, svy, svz); + if (lead == null) { + return false; + } + int color = switch (fix.source()) { + case RADAR -> LEAD_COLOR; + case VISUAL -> VISUAL_COLOR; + case COAST -> COAST_COLOR; + }; + return marker(builder, matrix, camera, + muzzle.x + lead.dirX() * lead.range(), muzzle.y + lead.dirY() * lead.range(), muzzle.z + lead.dirZ() * lead.range(), + color, true); + } + + /** Position the node reports for a target index, or null if it reports none. */ + private static double[] radarPosition(ClientLevel level, BlockPos seatPos, int selected) { BlockEntity seatEntity = level.getBlockEntity(seatPos); if (seatEntity == null) { - return false; + return null; } var seatData = seatEntity.getPersistentData(); BlockEntity node = level.getBlockEntity(BlockPos.containing(seatData.getDouble("ControlX"), seatData.getDouble("ControlY"), seatData.getDouble("ControlZ"))); if (node == null) { - return false; + return null; } var data = node.getPersistentData(); // Warium builds these keys from a double, so they read TargetX1.0, TargetY1.0 and so on @@ -146,17 +168,9 @@ public final class GunPipperRenderer { double ty = data.getDouble("TargetY" + (double) selected); double tz = data.getDouble("TargetZ" + (double) selected); if (tx == 0.0 && ty == 0.0 && tz == 0.0) { - return false; + return null; } - TRACKER.update(selected, tx, ty, tz, level.getGameTime()); - Ballistics.Lead lead = table.lead(muzzle.x, muzzle.y, muzzle.z, speed, tx, ty, tz, - TRACKER.vx(), TRACKER.vy(), TRACKER.vz(), svx, svy, svz); - if (lead == null) { - return false; - } - return marker(builder, matrix, camera, - muzzle.x + lead.dirX() * lead.range(), muzzle.y + lead.dirY() * lead.range(), muzzle.z + lead.dirZ() * lead.range(), - LEAD_COLOR, true); + return new double[] {tx, ty, tz}; } private static Ballistics table() { diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/PipperConfig.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/PipperConfig.java index 7e6810f..17ebaa2 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/PipperConfig.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/PipperConfig.java @@ -10,6 +10,8 @@ public final class PipperConfig { public static final ForgeConfigSpec.BooleanValue SHOW_IMPACT; public static final ForgeConfigSpec.BooleanValue SHOW_LEAD; public static final ForgeConfigSpec.BooleanValue INHERIT_SHIP_VELOCITY; + public static final ForgeConfigSpec.BooleanValue VISUAL_TRACK; + public static final ForgeConfigSpec.DoubleValue COAST_SECONDS; public static final ForgeConfigSpec.DoubleValue MUZZLE_SPEED_OVERRIDE; public static final ForgeConfigSpec.DoubleValue GRAVITY; public static final ForgeConfigSpec.DoubleValue DRAG; @@ -24,6 +26,13 @@ public final class PipperConfig { .define("showImpact", true); SHOW_LEAD = builder.comment("Show the lead pipper for the radar target you selected (green ring)") .define("showLead", true); + VISUAL_TRACK = builder.comment("Follow the selected target's ship itself once the radar has shown it, so the lead pipper keeps working when", + "the radar loses it (notching, speed gate, ground clutter). Only while the ship is loaded, which is within render distance", + "(cyan ring)") + .define("visualTrack", true); + COAST_SECONDS = builder.comment("Seconds a lost radar contact is extrapolated from its last position and velocity when its ship is not loaded", + "(yellow ring), 0 turns it off") + .defineInRange("coastSeconds", 8.0, 0.0, 60.0); INHERIT_SHIP_VELOCITY = builder.comment("Add the speed of your aircraft to the shells. Warium does not do this itself, only turn it", "on if a test shows your shells keep the speed of the jet (fire level at full speed, then hovering)") .define("inheritShipVelocity", false); diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/TargetTrack.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/TargetTrack.java new file mode 100644 index 0000000..f5a9803 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/TargetTrack.java @@ -0,0 +1,131 @@ +package dev.lumareth.improvedAerodynamics.client.pipper; + +import net.minecraft.client.multiplayer.ClientLevel; +import org.joml.Vector3d; +import org.joml.primitives.AABBdc; +import org.valkyrienskies.core.api.ships.ClientShip; +import org.valkyrienskies.mod.common.VSGameUtilsKt; + +/** + * The target the lead pipper aims at. The radar is only the way in: its contact is matched to the closest loaded ship + * and from then on that ship is followed directly, with its true position and velocity, so the pipper survives the + * radar losing the contact (notching, the speed gate, ground clutter). Without a loaded ship, which happens beyond + * render distance, the last radar position is extrapolated for a limited time. + *

+ * The selected index is no help for this: the radar rebuilds its list, so after a loss the index can point at another + * contact or at an empty slot. + */ +final class TargetTrack { + /** A radar position this close to a ship is taken to be that ship. */ + private static final double SNAP_BLOCKS = 80.0; + /** A fresh radar position this far from the followed ship means the player moved on to another target. */ + private static final double REBIND_BLOCKS = 250.0; + private static final long NONE = Long.MIN_VALUE; + + enum Source { + RADAR, VISUAL, COAST + } + + /** Position in blocks, velocity in blocks per tick. */ + record Fix(double x, double y, double z, double vx, double vy, double vz, Source source) { + } + + private final TargetTracker radar = new TargetTracker(); + private int index = -1; + private long boundId = NONE; + + void reset() { + radar.reset(); + index = -1; + boundId = NONE; + } + + /** + * @param radarPosition the position the node reports for the selected index, or null if it reports none + */ + Fix resolve(ClientLevel level, long ownShipId, int selected, double[] radarPosition, long tick, + boolean visualTrack, double coastSeconds) { + if (selected <= 0) { + reset(); + return null; + } + if (selected != index) { + reset(); + index = selected; + } + if (radarPosition != null) { + radar.update(selected, radarPosition[0], radarPosition[1], radarPosition[2], tick); + } + + if (visualTrack) { + ClientShip bound = boundId == NONE ? null : shipById(level, boundId); + if (radarPosition != null && radar.age(tick) <= 1) { + // Fresh contact: pick the ship it belongs to, or let go of the old one if the player switched + if (bound == null || distance(center(bound), radarPosition) > REBIND_BLOCKS) { + bound = nearest(level, ownShipId, radarPosition); + boundId = bound == null ? NONE : bound.getId(); + } + } + if (bound != null) { + Vector3d center = center(bound); + var velocity = bound.getVelocity(); + return new Fix(center.x, center.y, center.z, velocity.x() / 20.0, velocity.y() / 20.0, velocity.z() / 20.0, Source.VISUAL); + } + boundId = NONE; + } + + if (!radar.hasPosition()) { + return null; + } + long age = radar.age(tick); + if (age > TargetTracker.STALE_TICKS) { + if (coastSeconds <= 0.0 || !radar.hasVelocity() || age > coastSeconds * 20.0) { + return null; + } + double[] p = radar.predicted(tick); + return new Fix(p[0], p[1], p[2], radar.vx(), radar.vy(), radar.vz(), Source.COAST); + } + double[] p = radar.predicted(tick); + // A contact is only extrapolated up to the age of its last update + return new Fix(p[0], p[1], p[2], radar.vx(), radar.vy(), radar.vz(), Source.RADAR); + } + + private static ClientShip shipById(ClientLevel level, long id) { + for (ClientShip ship : VSGameUtilsKt.getShipObjectWorld(level).getLoadedShips()) { + if (ship.getId() == id) { + return ship; + } + } + return null; + } + + private static ClientShip nearest(ClientLevel level, long ownShipId, double[] position) { + ClientShip best = null; + double bestDistance = SNAP_BLOCKS; + for (ClientShip ship : VSGameUtilsKt.getShipObjectWorld(level).getLoadedShips()) { + if (ship.getId() == ownShipId) { + continue; + } + double distance = distance(center(ship), position); + if (distance < bestDistance) { + bestDistance = distance; + best = ship; + } + } + return best; + } + + /** The same point Warium's radar reports: the center of the world bounding box. */ + private static Vector3d center(ClientShip ship) { + AABBdc box = ship.getWorldAABB(); + return box.center(new Vector3d()); + } + + private static double distance(Vector3d a, double[] b) { + return Math.sqrt(sq(a.x - b[0]) + sq(a.y - b[1]) + sq(a.z - b[2])); + } + + private static double sq(double v) { + return v * v; + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/TargetTracker.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/TargetTracker.java index 97fcff0..a8d5586 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/TargetTracker.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/TargetTracker.java @@ -3,10 +3,11 @@ package dev.lumareth.improvedAerodynamics.client.pipper; /** * Target velocity from the radar. The node only stores positions, and they change when the radar updates, which is * not every tick. So the velocity is the change between two distinct positions divided by the ticks between them, - * smoothed, and it is dropped once the position has not changed for a while. + * smoothed. When the radar stops updating the last velocity is kept so the track can be extrapolated. */ final class TargetTracker { - private static final long STALE_TICKS = 60; + /** Ticks without a new position after which the contact counts as lost and is only extrapolated. */ + static final long STALE_TICKS = 40; private static final double SMOOTHING = 0.5; private static final double JUMP_BLOCKS_PER_TICK = 15.0; @@ -55,17 +56,42 @@ final class TargetTracker { py = y; pz = z; sampleTick = tick; - } else if (tick - sampleTick > STALE_TICKS) { - hasVelocity = false; - vx = 0.0; - vy = 0.0; - vz = 0.0; } } void reset() { index = -1; hasVelocity = false; + vx = 0.0; + vy = 0.0; + vz = 0.0; + } + + boolean hasPosition() { + return index >= 0; + } + + /** Ticks since the radar last gave a new position. */ + long age(long tick) { + return tick - sampleTick; + } + + /** The last radar position moved on by the velocity over the time since. */ + double[] predicted(long tick) { + long dt = Math.max(0L, tick - sampleTick); + return new double[] {px + vx * dt, py + vy * dt, pz + vz * dt}; + } + + double lastX() { + return px; + } + + double lastY() { + return py; + } + + double lastZ() { + return pz; } /** Velocity in blocks per tick, zero when unknown. */