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. */