From da74c2950c65c723d99d43121425d9bd09ed779b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 12:02:47 +0100 Subject: [PATCH] feat(client): gun pipper with ground impact point and radar lead marker Draws where the shells of the first light autocannon come down and, for the selected radar target, a lead pipper. Uses the same ballistics as Warium's LACFireScript (muzzle speed, barrels, drag 0.99, gravity 0.05). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh --- .../ImprovedAerodynamics.java | 3 + .../client/pipper/Ballistics.java | 147 +++++++++++ .../client/pipper/GunFinder.java | 155 ++++++++++++ .../client/pipper/GunPipperRenderer.java | 237 ++++++++++++++++++ .../client/pipper/PipperConfig.java | 44 ++++ .../client/pipper/TargetTracker.java | 87 +++++++ .../client/pipper/WariumSeat.java | 20 ++ 7 files changed, 693 insertions(+) create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/Ballistics.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunFinder.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunPipperRenderer.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/PipperConfig.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/TargetTracker.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/WariumSeat.java diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java index 706cb4c..c01788f 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.exhaust.ExhaustSync; import dev.lumareth.improvedAerodynamics.exhaust.NozzleContent; import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge; @@ -26,6 +27,8 @@ public class ImprovedAerodynamics { MinecraftForge.EVENT_BUS.register(ExhaustSync.class); ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC); ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, Config.CLIENT_SPEC); + // Own file name: the default one is taken by Config.CLIENT_SPEC + ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, PipperConfig.SPEC, "improved_aerodynamics-pipper-client.toml"); NozzleContent.register(FMLJavaModLoadingContext.get().getModEventBus()); 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); + } +}