Author SHA1 Message Date
Claude 6a8a7ead04 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 12:20:48 +01:00
Claude 9e7aae5773 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 12:02:47 +01:00
8 changed files with 872 additions and 0 deletions

No files matched your search

@@ -1,6 +1,7 @@
package dev.lumareth.improvedAerodynamics; package dev.lumareth.improvedAerodynamics;
import com.mojang.logging.LogUtils; import com.mojang.logging.LogUtils;
import dev.lumareth.improvedAerodynamics.client.pipper.PipperConfig;
import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge; import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge;
import dev.lumareth.improvedAerodynamics.network.Network; import dev.lumareth.improvedAerodynamics.network.Network;
import dev.lumareth.improvedAerodynamics.terrain.LookaheadLoader; import dev.lumareth.improvedAerodynamics.terrain.LookaheadLoader;
@@ -21,6 +22,7 @@ public class ImprovedAerodynamics {
MinecraftForge.EVENT_BUS.register(PhysicsBridge.class); MinecraftForge.EVENT_BUS.register(PhysicsBridge.class);
MinecraftForge.EVENT_BUS.register(ConfigCommands.class); MinecraftForge.EVENT_BUS.register(ConfigCommands.class);
ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC); ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC);
ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, PipperConfig.SPEC);
Network.register(); Network.register();
} }
} }
@@ -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
* <pre>M + D(n) * v0 - (0, G(n), 0)</pre>
* 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.
* <p>
* 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;
}
}
@@ -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<Block> BARREL = TagKey.create(Registries.BLOCK, new ResourceLocation("crusty_chunks", "acbarrel"));
private static final TagKey<Block> 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<BlockPos> 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);
}
}
@@ -0,0 +1,251 @@
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): 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 TargetTrack TRACK = new TargetTrack();
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) {
TRACK.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, ship.getId(), table, muzzle, shot.speed(), svx, svy, svz);
} else {
TRACK.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,
long ownShipId, Ballistics table, Vector3d muzzle, double speed, double svx, double svy, double svz) {
int selected = WariumSeat.selectedTarget(seat);
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 null;
}
var seatData = seatEntity.getPersistentData();
BlockEntity node = level.getBlockEntity(BlockPos.containing(seatData.getDouble("ControlX"), seatData.getDouble("ControlY"), seatData.getDouble("ControlZ")));
if (node == null) {
return null;
}
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 null;
}
return new double[] {tx, ty, tz};
}
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();
}
}
@@ -0,0 +1,53 @@
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.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;
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);
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);
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() {
}
}
@@ -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.
* <p>
* 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;
}
}
@@ -0,0 +1,113 @@
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. When the radar stops updating the last velocity is kept so the track can be extrapolated.
*/
final class TargetTracker {
/** 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;
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;
}
}
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. */
double vx() {
return vx;
}
double vy() {
return vy;
}
double vz() {
return vz;
}
boolean hasVelocity() {
return hasVelocity;
}
}
@@ -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);
}
}