diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java
index b1ce383..0b08e39 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java
@@ -1,6 +1,7 @@
package dev.lumareth.improvedAerodynamics;
import com.mojang.logging.LogUtils;
+import dev.lumareth.improvedAerodynamics.client.pipper.PipperConfig;
import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge;
import dev.lumareth.improvedAerodynamics.network.Network;
import dev.lumareth.improvedAerodynamics.terrain.LookaheadLoader;
@@ -21,6 +22,7 @@ public class ImprovedAerodynamics {
MinecraftForge.EVENT_BUS.register(PhysicsBridge.class);
MinecraftForge.EVENT_BUS.register(ConfigCommands.class);
ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC);
+ ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, PipperConfig.SPEC);
Network.register();
}
}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/Ballistics.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/Ballistics.java
new file mode 100644
index 0000000..f8506d8
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/Ballistics.java
@@ -0,0 +1,147 @@
+package dev.lumareth.improvedAerodynamics.client.pipper;
+
+/**
+ * Flight of a Warium shell, which is a vanilla arrow: every tick it moves by its velocity, the velocity is then scaled
+ * by the drag and gravity is subtracted. That is linear in the launch velocity, so the position after n ticks is
+ *
M + D(n) * v0 - (0, G(n), 0)
+ * with D(n) the sum of the drag factors and G(n) the drop gravity causes on its own. Both are tabulated once, which
+ * makes both the impact point and the lead solution cheap enough to run every frame.
+ *
+ * Plain doubles and no Minecraft classes, so it can be tested on its own. Units are blocks and ticks.
+ */
+public final class Ballistics {
+ public static final int MAX_TICKS = 400;
+
+ private final double[] d = new double[MAX_TICKS + 1];
+ private final double[] g = new double[MAX_TICKS + 1];
+
+ public Ballistics(double drag, double gravity) {
+ double y = 0.0;
+ double vy = 0.0;
+ double sum = 0.0;
+ double factor = 1.0;
+ for (int n = 1; n <= MAX_TICKS; n++) {
+ y += vy;
+ vy = vy * drag - gravity;
+ sum += factor;
+ factor *= drag;
+ d[n] = sum;
+ g[n] = -y;
+ }
+ }
+
+ /** Sum of the drag factors after n ticks, n may be fractional. */
+ public double d(double n) {
+ return interpolate(d, n);
+ }
+
+ /** Drop in blocks after n ticks, n may be fractional. */
+ public double g(double n) {
+ return interpolate(g, n);
+ }
+
+ private static double interpolate(double[] table, double n) {
+ if (n <= 0.0) {
+ return 0.0;
+ }
+ if (n >= MAX_TICKS) {
+ return table[MAX_TICKS];
+ }
+ int i = (int) n;
+ double f = n - i;
+ return table[i] + (table[i + 1] - table[i]) * f;
+ }
+
+ /** Height of the terrain a shell would hit at a column. */
+ public interface Ground {
+ double heightAt(double x, double z);
+ }
+
+ /**
+ * Where a shell fired from the muzzle with the given world velocity (blocks per tick, already including the
+ * speed of the shooter if shells inherit it) comes down. Returns the impact point as {x, y, z} or null if it
+ * stays in the air for the whole table.
+ */
+ public double[] impact(double mx, double my, double mz, double vx, double vy, double vz, Ground ground) {
+ double px = mx;
+ double py = my;
+ double pz = mz;
+ for (int n = 1; n <= MAX_TICKS; n++) {
+ double nx = mx + d[n] * vx;
+ double ny = my + d[n] * vy - g[n];
+ double nz = mz + d[n] * vz;
+ double floor = ground.heightAt(nx, nz);
+ if (ny <= floor) {
+ // Linear crossing inside this step
+ double before = py - ground.heightAt(px, pz);
+ double after = ny - floor;
+ double f = before - after > 1.0E-9 ? Math.min(1.0, Math.max(0.0, before / (before - after))) : 1.0;
+ return new double[] {px + (nx - px) * f, py + (ny - py) * f, pz + (nz - pz) * f};
+ }
+ px = nx;
+ py = ny;
+ pz = nz;
+ }
+ return null;
+ }
+
+ /** Result of {@link #lead}: aim direction, time of flight in ticks and the range to the target's current position. */
+ public record Lead(double dirX, double dirY, double dirZ, double ticks, double range) {
+ }
+
+ /**
+ * Aim direction that makes a shell meet a moving target.
+ *
+ * @param speed muzzle speed in blocks per tick
+ * @param t* target position now
+ * @param tv* target velocity in blocks per tick
+ * @param sv* velocity the shell inherits from the shooter in blocks per tick, zero if it does not
+ * @return the solution or null if the shell cannot reach the target within the table
+ */
+ public Lead lead(double mx, double my, double mz, double speed, double tx, double ty, double tz,
+ double tvx, double tvy, double tvz, double svx, double svy, double svz) {
+ double range = Math.sqrt(sq(tx - mx) + sq(ty - my) + sq(tz - mz));
+ double previousN = 0.0;
+ for (int n = 1; n <= MAX_TICKS; n++) {
+ double f = miss(n, mx, my, mz, speed, tx, ty, tz, tvx, tvy, tvz, svx, svy, svz);
+ if (f <= 0.0) {
+ double lo = previousN;
+ double hi = n;
+ for (int i = 0; i < 30; i++) {
+ double mid = 0.5 * (lo + hi);
+ if (miss(mid, mx, my, mz, speed, tx, ty, tz, tvx, tvy, tvz, svx, svy, svz) > 0.0) {
+ lo = mid;
+ } else {
+ hi = mid;
+ }
+ }
+ double t = 0.5 * (lo + hi);
+ double dn = d(t);
+ double rx = tx + tvx * t - mx - dn * svx;
+ double ry = ty + tvy * t - my - dn * svy + g(t);
+ double rz = tz + tvz * t - mz - dn * svz;
+ double length = Math.sqrt(rx * rx + ry * ry + rz * rz);
+ if (length < 1.0E-6) {
+ return null;
+ }
+ return new Lead(rx / length, ry / length, rz / length, t, range);
+ }
+ previousN = n;
+ }
+ return null;
+ }
+
+ /** Distance the shell can still cover short of where the target will be, positive means it has not got there yet. */
+ private double miss(double n, double mx, double my, double mz, double speed, double tx, double ty, double tz,
+ double tvx, double tvy, double tvz, double svx, double svy, double svz) {
+ double dn = d(n);
+ double rx = tx + tvx * n - mx - dn * svx;
+ double ry = ty + tvy * n - my - dn * svy + g(n);
+ double rz = tz + tvz * n - mz - dn * svz;
+ return Math.sqrt(rx * rx + ry * ry + rz * rz) - speed * dn;
+ }
+
+ private static double sq(double v) {
+ return v * v;
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunFinder.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunFinder.java
new file mode 100644
index 0000000..02b7fb4
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunFinder.java
@@ -0,0 +1,155 @@
+package dev.lumareth.improvedAerodynamics.client.pipper;
+
+import net.minecraft.client.multiplayer.ClientLevel;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.tags.TagKey;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.block.state.properties.DirectionProperty;
+import net.minecraft.world.level.block.state.properties.Property;
+import net.minecraft.world.level.chunk.ChunkAccess;
+import net.minecraft.world.level.chunk.ChunkStatus;
+import net.minecraft.world.level.chunk.LevelChunkSection;
+import net.minecraftforge.fml.ModList;
+import net.minecraftforge.registries.ForgeRegistries;
+import org.joml.primitives.AABBic;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Finds the light autocannon of a ship and works out what it will shoot, the same way Warium's LACFireScript does:
+ * barrel count, muzzle, aim direction and muzzle speed.
+ */
+final class GunFinder {
+ private static final ResourceLocation LIGHT_AUTOCANNON = new ResourceLocation("crusty_chunks", "light_autocannon");
+ private static final TagKey BARREL = TagKey.create(Registries.BLOCK, new ResourceLocation("crusty_chunks", "acbarrel"));
+ private static final TagKey PASSTHROUGH = TagKey.create(Registries.BLOCK, new ResourceLocation("crusty_chunks", "barrelpassthrough"));
+ private static final int RESCAN_TICKS = 100;
+ private static final int MAX_GUNS = 16;
+
+ /** Muzzle and direction in ship space and the speed of the shell in blocks per tick. */
+ record Shot(double mx, double my, double mz, double dx, double dy, double dz, double speed) {
+ }
+
+ private long shipId = Long.MIN_VALUE;
+ private long lastScan = Long.MIN_VALUE;
+ private final List guns = new ArrayList<>();
+
+ /** The gun closest to the seat, or null if the ship has none loaded. */
+ Shot find(ClientLevel level, long shipId, AABBic aabb, BlockPos seat) {
+ if (shipId != this.shipId || level.getGameTime() - lastScan >= RESCAN_TICKS || level.getGameTime() < lastScan) {
+ this.shipId = shipId;
+ lastScan = level.getGameTime();
+ scan(level, aabb);
+ }
+ BlockPos best = null;
+ double bestDistance = Double.MAX_VALUE;
+ for (BlockPos pos : guns) {
+ double distance = pos.distSqr(seat);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = pos;
+ }
+ }
+ return best == null ? null : shotOf(level, best);
+ }
+
+ private void scan(ClientLevel level, AABBic aabb) {
+ guns.clear();
+ Block gun = ForgeRegistries.BLOCKS.getValue(LIGHT_AUTOCANNON);
+ if (gun == null || aabb == null) {
+ return;
+ }
+ BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
+ for (int cx = aabb.minX() >> 4; cx <= aabb.maxX() >> 4; cx++) {
+ for (int cz = aabb.minZ() >> 4; cz <= aabb.maxZ() >> 4; cz++) {
+ ChunkAccess chunk = level.getChunk(cx, cz, ChunkStatus.FULL, false);
+ if (chunk == null) {
+ continue;
+ }
+ LevelChunkSection[] sections = chunk.getSections();
+ for (int i = 0; i < sections.length; i++) {
+ LevelChunkSection section = sections[i];
+ if (section.hasOnlyAir() || !section.maybeHas(state -> state.is(gun))) {
+ continue;
+ }
+ int baseY = level.getSectionYFromSectionIndex(i) << 4;
+ for (int x = 0; x < 16; x++) {
+ for (int y = 0; y < 16; y++) {
+ for (int z = 0; z < 16; z++) {
+ if (section.getBlockState(x, y, z).is(gun) && guns.size() < MAX_GUNS) {
+ guns.add(pos.set((cx << 4) + x, baseY + y, (cz << 4) + z).immutable());
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private static Shot shotOf(ClientLevel level, BlockPos gun) {
+ BlockState state = level.getBlockState(gun);
+ Property> property = state.getBlock().getStateDefinition().getProperty("facing");
+ if (!(property instanceof DirectionProperty facingProperty)) {
+ return null;
+ }
+ Direction facing = state.getValue(facingProperty);
+ int fx = facing.getStepX();
+ int fy = facing.getStepY();
+ int fz = facing.getStepZ();
+
+ // Barrels, counted like Warium does: up to five more behind the first one
+ double barrels = 1.0;
+ for (int i = 0; i < 5 && level.getBlockState(gun.offset(fx * (int) (1 + barrels), fy * (int) (1 + barrels), fz * (int) (1 + barrels))).is(BARREL); i++) {
+ barrels++;
+ }
+ // Muzzle: past the barrels, skipping through blocks the shell passes
+ double position = barrels;
+ for (int i = 0; i < 10; i++) {
+ BlockState at = level.getBlockState(gun.offset(fx * (int) (1 + position), fy * (int) (1 + position), fz * (int) (1 + position)));
+ if ((!at.isAir() && !at.canOcclude()) || at.is(PASSTHROUGH)) {
+ position++;
+ } else {
+ break;
+ }
+ }
+ double mx = gun.getX() + fx * (1.5 + position) + 0.5;
+ double my = gun.getY() + fy * (1.5 + position) + 0.5;
+ double mz = gun.getZ() + fz * (1.5 + position) + 0.5;
+
+ // Aim, from the tags of the gun itself
+ double aimX = 0.0;
+ double aimY = 0.0;
+ double aimZ = 0.0;
+ BlockEntity entity = level.getBlockEntity(gun);
+ double gx = entity == null ? 0.0 : entity.getPersistentData().getDouble("X");
+ double gz = entity == null ? 0.0 : entity.getPersistentData().getDouble("Z");
+ double gpitch = entity == null ? 0.0 : entity.getPersistentData().getDouble("Pitch");
+ if (1.0 > Math.abs(fx - gx) || 1.0 > Math.abs(fz - gz)) {
+ aimY = gpitch;
+ aimX = gx + fx;
+ aimZ = gz + fz;
+ }
+ if (aimX * aimZ == 0.0) {
+ aimX = fx;
+ aimZ = fz;
+ }
+ double length = Math.sqrt(aimX * aimX + aimY * aimY + aimZ * aimZ);
+ if (length < 1.0E-6) {
+ return null;
+ }
+
+ double speed = PipperConfig.MUZZLE_SPEED_OVERRIDE.get();
+ if (speed <= 0.0) {
+ double multiplier = ModList.get().isLoaded("ritchiesprojectilelib") ? 2.0 : 1.0;
+ speed = (5.0 + barrels / 1.5) * multiplier;
+ }
+ return new Shot(mx, my, mz, aimX / length, aimY / length, aimZ / length, speed);
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunPipperRenderer.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunPipperRenderer.java
new file mode 100644
index 0000000..b138c6a
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunPipperRenderer.java
@@ -0,0 +1,237 @@
+package dev.lumareth.improvedAerodynamics.client.pipper;
+
+import com.mojang.blaze3d.systems.RenderSystem;
+import com.mojang.blaze3d.vertex.BufferBuilder;
+import com.mojang.blaze3d.vertex.BufferUploader;
+import com.mojang.blaze3d.vertex.DefaultVertexFormat;
+import com.mojang.blaze3d.vertex.Tesselator;
+import com.mojang.blaze3d.vertex.VertexFormat;
+import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics;
+import net.minecraft.client.Camera;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.multiplayer.ClientLevel;
+import net.minecraft.client.renderer.GameRenderer;
+import net.minecraft.core.BlockPos;
+import net.minecraft.util.Mth;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import net.minecraft.world.level.levelgen.Heightmap;
+import net.minecraft.world.phys.Vec3;
+import net.minecraftforge.api.distmarker.Dist;
+import net.minecraftforge.client.event.RenderLevelStageEvent;
+import net.minecraftforge.eventbus.api.SubscribeEvent;
+import net.minecraftforge.fml.ModList;
+import net.minecraftforge.fml.common.Mod;
+import org.joml.Matrix4f;
+import org.joml.Vector3d;
+import org.joml.Vector3f;
+import org.valkyrienskies.core.api.ships.ClientShip;
+import org.valkyrienskies.core.api.ships.Ship;
+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.
+ */
+@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 float DISTANCE = 2.0F;
+
+ private static final GunFinder FINDER = new GunFinder();
+ private static final TargetTracker TRACKER = new TargetTracker();
+ private static Ballistics ballistics;
+ private static double ballisticsDrag = Double.NaN;
+ private static double ballisticsGravity = Double.NaN;
+
+ private GunPipperRenderer() {
+ }
+
+ @SubscribeEvent
+ public static void onRenderLevel(RenderLevelStageEvent event) {
+ if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_PARTICLES || !PipperConfig.SPEC.isLoaded()
+ || !PipperConfig.ENABLED.get() || !ModList.get().isLoaded("valkyrien_warium")) {
+ return;
+ }
+ Minecraft minecraft = Minecraft.getInstance();
+ ClientLevel level = minecraft.level;
+ if (level == null || minecraft.player == null) {
+ return;
+ }
+ Entity seat = WariumSeat.seatOf(minecraft.player);
+ if (seat == null) {
+ TRACKER.reset();
+ return;
+ }
+ BlockPos seatPos = BlockPos.containing(seat.getX(), seat.getY(), seat.getZ());
+ Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, seatPos);
+ if (ship == null) {
+ return;
+ }
+
+ GunFinder.Shot shot = FINDER.find(level, ship.getId(), ship.getShipAABB(), seatPos);
+ if (shot == null) {
+ return;
+ }
+ org.joml.Matrix4dc shipToWorld = ship instanceof ClientShip clientShip
+ ? clientShip.getRenderTransform().getShipToWorld() : ship.getShipToWorld();
+ Vector3d muzzle = shipToWorld.transformPosition(new Vector3d(shot.mx(), shot.my(), shot.mz()));
+ Vector3d direction = shipToWorld.transformDirection(new Vector3d(shot.dx(), shot.dy(), shot.dz())).normalize();
+
+ double inherit = PipperConfig.INHERIT_SHIP_VELOCITY.get() ? 1.0 / 20.0 : 0.0;
+ org.joml.Vector3dc velocity = ship.getVelocity();
+ double svx = velocity.x() * inherit;
+ double svy = velocity.y() * inherit;
+ double svz = velocity.z() * inherit;
+
+ Ballistics table = table();
+ Camera camera = event.getCamera();
+ Matrix4f matrix = event.getPoseStack().last().pose();
+
+ BufferBuilder builder = Tesselator.getInstance().getBuilder();
+ RenderSystem.disableDepthTest();
+ RenderSystem.enableBlend();
+ RenderSystem.defaultBlendFunc();
+ RenderSystem.disableCull();
+ RenderSystem.setShader(GameRenderer::getPositionColorShader);
+ builder.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR);
+ boolean drawn = false;
+
+ if (PipperConfig.SHOW_IMPACT.get()) {
+ double[] impact = table.impact(muzzle.x, muzzle.y, muzzle.z,
+ direction.x * shot.speed() + svx, direction.y * shot.speed() + svy, direction.z * shot.speed() + svz,
+ (x, z) -> level.getHeight(Heightmap.Types.MOTION_BLOCKING, Mth.floor(x), Mth.floor(z)));
+ if (impact != null) {
+ drawn |= marker(builder, matrix, camera, impact[0], impact[1], impact[2], IMPACT_COLOR, false);
+ }
+ }
+
+ if (PipperConfig.SHOW_LEAD.get()) {
+ drawn |= lead(builder, matrix, camera, level, seat, seatPos, table, muzzle, shot.speed(), svx, svy, svz);
+ } else {
+ TRACKER.reset();
+ }
+
+ if (drawn) {
+ BufferUploader.drawWithShader(builder.end());
+ } else {
+ builder.end().release();
+ }
+ RenderSystem.enableCull();
+ RenderSystem.enableDepthTest();
+ RenderSystem.disableBlend();
+ }
+
+ 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) {
+ int selected = WariumSeat.selectedTarget(seat);
+ if (selected <= 0) {
+ TRACKER.reset();
+ return false;
+ }
+ BlockEntity seatEntity = level.getBlockEntity(seatPos);
+ if (seatEntity == null) {
+ return false;
+ }
+ var seatData = seatEntity.getPersistentData();
+ BlockEntity node = level.getBlockEntity(BlockPos.containing(seatData.getDouble("ControlX"), seatData.getDouble("ControlY"), seatData.getDouble("ControlZ")));
+ if (node == null) {
+ return false;
+ }
+ var data = node.getPersistentData();
+ // Warium builds these keys from a double, so they read TargetX1.0, TargetY1.0 and so on
+ double tx = data.getDouble("TargetX" + (double) selected);
+ 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;
+ }
+ 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);
+ }
+
+ private static Ballistics table() {
+ double drag = PipperConfig.DRAG.get();
+ double gravity = PipperConfig.GRAVITY.get();
+ if (ballistics == null || drag != ballisticsDrag || gravity != ballisticsGravity) {
+ ballistics = new Ballistics(drag, gravity);
+ ballisticsDrag = drag;
+ ballisticsGravity = gravity;
+ }
+ return ballistics;
+ }
+
+ /** A ring on the screen in the direction of a world point, with ticks on it for the lead pipper. */
+ private static boolean marker(BufferBuilder builder, Matrix4f matrix, Camera camera, double x, double y, double z, int color, boolean ticks) {
+ Vec3 cameraPosition = camera.getPosition();
+ Vector3f toPoint = new Vector3f((float) (x - cameraPosition.x), (float) (y - cameraPosition.y), (float) (z - cameraPosition.z));
+ if (toPoint.lengthSquared() < 1.0E-6F) {
+ return false;
+ }
+ toPoint.normalize();
+ if (toPoint.dot(camera.getLookVector()) <= 0.05F) {
+ return false;
+ }
+ Vector3f center = new Vector3f(toPoint).mul(DISTANCE);
+ Vector3f left = new Vector3f(camera.getLeftVector());
+ Vector3f up = new Vector3f(camera.getUpVector());
+ float radius = (float) (PipperConfig.MARKER_SIZE.get() * DISTANCE);
+ float thickness = radius * 0.12F;
+
+ ring(builder, matrix, center, left, up, radius, thickness, color);
+ quad(builder, matrix, center, left, up, thickness * 1.6F, thickness * 1.6F, color);
+ if (ticks) {
+ for (int i = 0; i < 4; i++) {
+ double angle = i * Math.PI / 2.0;
+ float cx = (float) Math.cos(angle);
+ float cy = (float) Math.sin(angle);
+ Vector3f tickCenter = new Vector3f(center)
+ .fma(cx * radius * 1.45F, left).fma(cy * radius * 1.45F, up);
+ float halfX = Math.abs(cx) > 0.5F ? radius * 0.35F : thickness;
+ float halfY = Math.abs(cy) > 0.5F ? radius * 0.35F : thickness;
+ quad(builder, matrix, tickCenter, left, up, halfX, halfY, color);
+ }
+ }
+ return true;
+ }
+
+ private static void ring(BufferBuilder builder, Matrix4f matrix, Vector3f center, Vector3f left, Vector3f up, float radius, float thickness, int color) {
+ final int segments = 32;
+ float inner = radius - thickness;
+ float outer = radius + thickness;
+ for (int i = 0; i < segments; i++) {
+ double a0 = i * 2.0 * Math.PI / segments;
+ double a1 = (i + 1) * 2.0 * Math.PI / segments;
+ vertex(builder, matrix, center, left, up, (float) Math.cos(a0) * inner, (float) Math.sin(a0) * inner, color);
+ vertex(builder, matrix, center, left, up, (float) Math.cos(a0) * outer, (float) Math.sin(a0) * outer, color);
+ vertex(builder, matrix, center, left, up, (float) Math.cos(a1) * outer, (float) Math.sin(a1) * outer, color);
+ vertex(builder, matrix, center, left, up, (float) Math.cos(a1) * inner, (float) Math.sin(a1) * inner, color);
+ }
+ }
+
+ private static void quad(BufferBuilder builder, Matrix4f matrix, Vector3f center, Vector3f left, Vector3f up, float halfX, float halfY, int color) {
+ vertex(builder, matrix, center, left, up, -halfX, -halfY, color);
+ vertex(builder, matrix, center, left, up, halfX, -halfY, color);
+ vertex(builder, matrix, center, left, up, halfX, halfY, color);
+ vertex(builder, matrix, center, left, up, -halfX, halfY, color);
+ }
+
+ private static void vertex(BufferBuilder builder, Matrix4f matrix, Vector3f center, Vector3f left, Vector3f up, float a, float b, int color) {
+ // The left vector points to the left of the screen, so a positive offset along it is drawn to the left
+ float vx = center.x - left.x * a + up.x * b;
+ float vy = center.y - left.y * a + up.y * b;
+ float vz = center.z - left.z * a + up.z * b;
+ builder.vertex(matrix, vx, vy, vz)
+ .color(color >> 16 & 255, color >> 8 & 255, color & 255, color >>> 24)
+ .endVertex();
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/PipperConfig.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/PipperConfig.java
new file mode 100644
index 0000000..7e6810f
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/PipperConfig.java
@@ -0,0 +1,44 @@
+package dev.lumareth.improvedAerodynamics.client.pipper;
+
+import net.minecraftforge.common.ForgeConfigSpec;
+
+/** Client side settings of the gun pipper. Lives in improved_aerodynamics-client.toml. */
+public final class PipperConfig {
+ public static final ForgeConfigSpec SPEC;
+
+ public static final ForgeConfigSpec.BooleanValue ENABLED;
+ 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.DoubleValue MUZZLE_SPEED_OVERRIDE;
+ public static final ForgeConfigSpec.DoubleValue GRAVITY;
+ public static final ForgeConfigSpec.DoubleValue DRAG;
+ public static final ForgeConfigSpec.DoubleValue MARKER_SIZE;
+
+ static {
+ ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder();
+ builder.comment("Gun pipper for Warium light autocannons, drawn while seated in a Warium seat").push("pipper");
+ ENABLED = builder.comment("Master switch")
+ .define("enabled", true);
+ SHOW_IMPACT = builder.comment("Show where the shells of the gun come down on the ground (orange ring)")
+ .define("showImpact", true);
+ SHOW_LEAD = builder.comment("Show the lead pipper for the radar target you selected (green ring)")
+ .define("showLead", true);
+ 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);
+ MUZZLE_SPEED_OVERRIDE = builder.comment("Muzzle speed in blocks per tick, 0 works it out from the barrel length like Warium does")
+ .defineInRange("muzzleSpeedOverride", 0.0, 0.0, 100.0);
+ GRAVITY = builder.comment("Gravity of the shells in blocks per tick squared")
+ .defineInRange("gravity", 0.05, 0.0, 1.0);
+ DRAG = builder.comment("Speed the shells keep each tick, 0.99 is a vanilla arrow")
+ .defineInRange("drag", 0.99, 0.5, 1.0);
+ MARKER_SIZE = builder.comment("Radius of the markers on screen")
+ .defineInRange("markerSize", 0.03, 0.005, 0.2);
+ builder.pop();
+ SPEC = builder.build();
+ }
+
+ private PipperConfig() {
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/TargetTracker.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/TargetTracker.java
new file mode 100644
index 0000000..97fcff0
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/TargetTracker.java
@@ -0,0 +1,87 @@
+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.
+ */
+final class TargetTracker {
+ private static final long STALE_TICKS = 60;
+ private static final double SMOOTHING = 0.5;
+ private static final double JUMP_BLOCKS_PER_TICK = 15.0;
+
+ private int index = -1;
+ private double px;
+ private double py;
+ private double pz;
+ private long sampleTick;
+ private double vx;
+ private double vy;
+ private double vz;
+ private boolean hasVelocity;
+
+ void update(int selected, double x, double y, double z, long tick) {
+ if (selected != index || tick < sampleTick) {
+ index = selected;
+ px = x;
+ py = y;
+ pz = z;
+ sampleTick = tick;
+ hasVelocity = false;
+ vx = 0.0;
+ vy = 0.0;
+ vz = 0.0;
+ return;
+ }
+ if (x != px || y != py || z != pz) {
+ long dt = Math.max(1L, tick - sampleTick);
+ double nx = (x - px) / dt;
+ double ny = (y - py) / dt;
+ double nz = (z - pz) / dt;
+ if (Math.sqrt(nx * nx + ny * ny + nz * nz) > JUMP_BLOCKS_PER_TICK) {
+ // Teleport or a different contact reusing the slot
+ hasVelocity = false;
+ } else if (hasVelocity) {
+ vx += (nx - vx) * SMOOTHING;
+ vy += (ny - vy) * SMOOTHING;
+ vz += (nz - vz) * SMOOTHING;
+ } else {
+ vx = nx;
+ vy = ny;
+ vz = nz;
+ hasVelocity = true;
+ }
+ px = x;
+ 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;
+ }
+
+ /** Velocity in blocks per tick, zero when unknown. */
+ double vx() {
+ return vx;
+ }
+
+ double vy() {
+ return vy;
+ }
+
+ double vz() {
+ return vz;
+ }
+
+ boolean hasVelocity() {
+ return hasVelocity;
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/WariumSeat.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/WariumSeat.java
new file mode 100644
index 0000000..eefc814
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/WariumSeat.java
@@ -0,0 +1,20 @@
+package dev.lumareth.improvedAerodynamics.client.pipper;
+
+import net.mcreator.valkyrienwarium.entity.SeatEntityEntity;
+import net.minecraft.world.entity.Entity;
+
+/** Everything that touches Warium VS classes, so nothing else loads them when Warium is missing. */
+final class WariumSeat {
+ private WariumSeat() {
+ }
+
+ static Entity seatOf(Entity rider) {
+ Entity root = rider.getRootVehicle();
+ return root instanceof SeatEntityEntity ? root : null;
+ }
+
+ /** Index of the radar target selected on the seat, 1 based, 0 for none. */
+ static int selectedTarget(Entity seat) {
+ return seat.getEntityData().get(SeatEntityEntity.DATA_SelectedTarget);
+ }
+}