From 44a4390f694a36bab6a303a0fba732eefed4a3cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 13:38:11 +0100 Subject: [PATCH] Add Metalstorm-style cursor aim Virtual stick cursor steered through the gun reticle, server-side virtual pilot using measured control responses, client cursor/camera handling with look-ahead, free look and scroll zoom. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh --- .../ImprovedAerodynamics.java | 4 + .../client/aim/AimKeys.java | 34 ++ .../client/aim/AimSeat.java | 15 + .../client/aim/CursorAimClient.java | 444 ++++++++++++++++++ .../client/aim/CursorAimClientConfig.java | 45 ++ .../client/pipper/GunPipperRenderer.java | 4 +- .../flight/AeroState.java | 93 ++-- .../flight/AeroSurface.java | 6 +- .../improvedAerodynamics/flight/AimBasis.java | 101 ++++ .../flight/CursorAimConfig.java | 52 ++ .../flight/CursorAimRegistry.java | 44 ++ .../flight/CursorAimServer.java | 30 ++ .../flight/CursorPilot.java | 136 ++++++ .../network/AimPacket.java | 45 ++ .../improvedAerodynamics/network/Network.java | 6 + .../improved_aerodynamics/lang/de_de.json | 8 +- .../improved_aerodynamics/lang/en_us.json | 8 +- 17 files changed, 1037 insertions(+), 38 deletions(-) create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/client/aim/AimKeys.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/client/aim/AimSeat.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClient.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClientConfig.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/flight/AimBasis.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimConfig.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimRegistry.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimServer.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorPilot.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/network/AimPacket.java diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java index 0b08e39..d145e48 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java @@ -1,7 +1,9 @@ package dev.lumareth.improvedAerodynamics; import com.mojang.logging.LogUtils; +import dev.lumareth.improvedAerodynamics.client.aim.CursorAimClientConfig; import dev.lumareth.improvedAerodynamics.client.pipper.PipperConfig; +import dev.lumareth.improvedAerodynamics.flight.CursorAimConfig; import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge; import dev.lumareth.improvedAerodynamics.network.Network; import dev.lumareth.improvedAerodynamics.terrain.LookaheadLoader; @@ -23,6 +25,8 @@ public class ImprovedAerodynamics { MinecraftForge.EVENT_BUS.register(ConfigCommands.class); ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC); ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, PipperConfig.SPEC); + ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, CursorAimConfig.SPEC, "improved_aerodynamics-cursor-aim.toml"); + ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, CursorAimClientConfig.SPEC, "improved_aerodynamics-cursor-aim-client.toml"); Network.register(); } } diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/AimKeys.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/AimKeys.java new file mode 100644 index 0000000..f97a8d9 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/AimKeys.java @@ -0,0 +1,34 @@ +package dev.lumareth.improvedAerodynamics.client.aim; + +import com.mojang.blaze3d.platform.InputConstants; +import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics; +import net.minecraft.client.KeyMapping; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.RegisterKeyMappingsEvent; +import net.minecraftforge.client.settings.KeyConflictContext; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import org.lwjgl.glfw.GLFW; + +/** Keys of cursor aim. The free look key is not shift: in a seat, shift gets the player out of it. */ +public final class AimKeys { + public static final KeyMapping TOGGLE = new KeyMapping("key.improved_aerodynamics.toggle_cursor_aim", + KeyConflictContext.IN_GAME, InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_K, "key.categories.improved_aerodynamics"); + public static final KeyMapping FREE_LOOK = new KeyMapping("key.improved_aerodynamics.cursor_aim_free_look", + KeyConflictContext.IN_GAME, InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_LEFT_ALT, "key.categories.improved_aerodynamics"); + + private AimKeys() { + } + + @Mod.EventBusSubscriber(modid = ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT, bus = Mod.EventBusSubscriber.Bus.MOD) + public static final class Registration { + private Registration() { + } + + @SubscribeEvent + public static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) { + event.register(TOGGLE); + event.register(FREE_LOOK); + } + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/AimSeat.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/AimSeat.java new file mode 100644 index 0000000..3b39bcc --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/AimSeat.java @@ -0,0 +1,15 @@ +package dev.lumareth.improvedAerodynamics.client.aim; + +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 AimSeat { + private AimSeat() { + } + + static Entity seatOf(Entity rider) { + Entity root = rider.getRootVehicle(); + return root instanceof SeatEntityEntity ? root : null; + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClient.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClient.java new file mode 100644 index 0000000..a62ae84 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClient.java @@ -0,0 +1,444 @@ +package dev.lumareth.improvedAerodynamics.client.aim; + +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 dev.lumareth.improvedAerodynamics.client.pipper.GunPipperRenderer; +import dev.lumareth.improvedAerodynamics.flight.AimBasis; +import dev.lumareth.improvedAerodynamics.network.AimPacket; +import dev.lumareth.improvedAerodynamics.network.Network; +import net.minecraft.client.Camera; +import net.minecraft.client.CameraType; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.InputEvent; +import net.minecraftforge.client.event.RenderLevelStageEvent; +import net.minecraftforge.client.event.ViewportEvent; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.util.ObfuscationReflectionHelper; +import org.joml.Matrix4f; +import org.joml.Quaterniondc; +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; + +import java.lang.reflect.Method; + +/** + * Cursor aim, the client side. While it is on: + * + * The camera is driven through the rotation of the player, which for a rider of a Valkyrien Skies ship is relative to + * the ship, so the camera follows the ship's attitude like Valkyrien Skies does for any mounted player. Mouse + * movement shows up as a change of that rotation, which is taken as the movement of the cursor and put back. + */ +@Mod.EventBusSubscriber(modid = ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT) +public final class CursorAimClient { + private static final int CURSOR_COLOR = 0xFFFFE040; + private static final int RETICLE_COLOR = 0xFFFFFFFF; + private static final float MARKER_DISTANCE = 2.0F; + private static final double LOOK_AHEAD_RATE = 6.0; + private static final double FREE_LOOK_RETURN_RATE = 10.0; + + private static boolean active; + // Cursor as components along the craft's forward (gun reticle), right and up axes + private static double cf = 1.0; + private static double cr; + private static double cu; + // Camera direction in ship space and the free look offset on top of it, degrees + private static double[] cameraDirection; + private static double offsetYaw; + private static double offsetPitch; + // Rotation the player was given last frame, to see what the mouse did since + private static boolean haveLast; + private static float lastYaw; + private static float lastPitch; + private static long lastNanos; + private static double zoom; + private static CameraType previousCamera; + private static Method cameraMove; + + private CursorAimClient() { + } + + // ---- Toggling and the packet to the server ---- + + @SubscribeEvent + public static void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) { + return; + } + Minecraft minecraft = Minecraft.getInstance(); + LocalPlayer player = minecraft.player; + while (AimKeys.TOGGLE.consumeClick()) { + if (active) { + deactivate(minecraft); + } else if (player != null) { + activate(minecraft, player); + } + } + if (!active) { + return; + } + if (player == null || !seated(player)) { + deactivate(minecraft); + return; + } + send(minecraft, true); + } + + private static boolean seated(LocalPlayer player) { + return ModList.get().isLoaded("valkyrien_warium") && AimSeat.seatOf(player) != null; + } + + private static boolean serverHasMod(Minecraft minecraft) { + return minecraft.getConnection() != null && Network.CHANNEL.isRemotePresent(minecraft.getConnection().getConnection()); + } + + private static void activate(Minecraft minecraft, LocalPlayer player) { + if (!seated(player)) { + player.displayClientMessage(Component.translatable("message.improved_aerodynamics.cursor_aim.no_seat"), true); + return; + } + if (!serverHasMod(minecraft)) { + player.displayClientMessage(Component.translatable("message.improved_aerodynamics.cursor_aim.no_server"), true); + return; + } + active = true; + cf = 1.0; + cr = 0.0; + cu = 0.0; + cameraDirection = null; + offsetYaw = 0.0; + offsetPitch = 0.0; + haveLast = false; + lastNanos = System.nanoTime(); + previousCamera = minecraft.options.getCameraType(); + minecraft.options.setCameraType(CameraType.THIRD_PERSON_BACK); + player.displayClientMessage(Component.translatable("message.improved_aerodynamics.cursor_aim.on"), true); + } + + private static void deactivate(Minecraft minecraft) { + if (!active) { + return; + } + active = false; + send(minecraft, false); + if (previousCamera != null) { + minecraft.options.setCameraType(previousCamera); + previousCamera = null; + } + if (minecraft.player != null) { + minecraft.player.displayClientMessage(Component.translatable("message.improved_aerodynamics.cursor_aim.off"), true); + } + } + + private static void send(Minecraft minecraft, boolean on) { + if (serverHasMod(minecraft)) { + Network.CHANNEL.sendToServer(new AimPacket(on, (float) cf, (float) cr, (float) cu)); + } + } + + // ---- Mouse and camera, once per frame before the world is rendered ---- + + @SubscribeEvent + public static void onRenderTick(TickEvent.RenderTickEvent event) { + if (!active || event.phase != TickEvent.Phase.START) { + return; + } + Minecraft minecraft = Minecraft.getInstance(); + LocalPlayer player = minecraft.player; + ClientLevel level = minecraft.level; + if (player == null || level == null || !seated(player)) { + return; + } + Entity seat = AimSeat.seatOf(player); + AimBasis basis = AimBasis.of(level, seat.blockPosition()); + if (basis == null) { + return; + } + if (minecraft.options.getCameraType() != CameraType.THIRD_PERSON_BACK) { + minecraft.options.setCameraType(CameraType.THIRD_PERSON_BACK); + } + + long now = System.nanoTime(); + double dt = Mth.clamp((now - lastNanos) / 1.0E9, 0.0, 0.1); + lastNanos = now; + + // What the mouse did: the player's rotation moved away from what it was set to + double deltaYaw = 0.0; + double deltaPitch = 0.0; + if (haveLast && minecraft.screen == null) { + deltaYaw = Mth.wrapDegrees(player.getYRot() - lastYaw); + deltaPitch = player.getXRot() - lastPitch; + if (Math.abs(deltaYaw) > 90.0 || Math.abs(deltaPitch) > 90.0) { + // Not the mouse, something reset the rotation + deltaYaw = 0.0; + deltaPitch = 0.0; + } + } + double sensitivity = CursorAimClientConfig.MOUSE_SENSITIVITY.get(); + boolean freeLook = minecraft.screen == null && AimKeys.FREE_LOOK.isDown(); + + double spring = CursorAimClientConfig.SPRING_BACK_SECONDS.get(); + if (spring > 0.0) { + shrinkCursor(Math.exp(-dt / spring)); + } + if (freeLook) { + offsetYaw += deltaYaw * sensitivity; + offsetPitch = Mth.clamp(offsetPitch - deltaPitch * sensitivity, -89.0, 89.0); + } else { + moveCursor(Math.toRadians(deltaYaw * sensitivity), Math.toRadians(-deltaPitch * sensitivity)); + double decay = Math.exp(-dt * FREE_LOOK_RETURN_RATE); + offsetYaw *= decay; + offsetPitch *= decay; + } + + // Camera: along the reticle, or turned towards the cursor by the look ahead setting + double lookAhead = CursorAimClientConfig.LOOK_AHEAD.get(); + double[] forward = basis.forward(); + if (cameraDirection == null || lookAhead <= 0.0) { + cameraDirection = forward.clone(); + } else { + double[] target = basis.toShip(cf, cr, cu); + cameraDirection = turnTowards(cameraDirection, target, 1.0 - Math.exp(-lookAhead * LOOK_AHEAD_RATE * dt)); + } + double[] direction = rotate(cameraDirection, basis.up(), -Math.toRadians(offsetYaw)); + direction = rotate(direction, rotate(basis.right(), basis.up(), -Math.toRadians(offsetYaw)), Math.toRadians(offsetPitch)); + + // Valkyrien Skies reads the view of a mounted player in ship space: yaw 0 looks along +Z + float yaw = (float) Math.toDegrees(Math.atan2(-direction[0], direction[2])); + float pitch = (float) Math.toDegrees(Math.atan2(-direction[1], Math.hypot(direction[0], direction[2]))); + player.setYRot(yaw); + player.setXRot(pitch); + player.yRotO = yaw; + player.xRotO = pitch; + player.yHeadRot = yaw; + player.yHeadRotO = yaw; + lastYaw = yaw; + lastPitch = pitch; + haveLast = true; + } + + /** Moves the cursor like a look direction: yaw towards the right, pitch up, radians. */ + private static void moveCursor(double yaw, double pitch) { + double nf = cf * Math.cos(yaw) - cr * Math.sin(yaw); + double nr = cf * Math.sin(yaw) + cr * Math.cos(yaw); + cf = nf; + cr = nr; + nf = cf * Math.cos(pitch) - cu * Math.sin(pitch); + double nu = cf * Math.sin(pitch) + cu * Math.cos(pitch); + cf = nf; + cu = nu; + normalizeCursor(); + limitCursor(); + } + + /** Brings the cursor back towards the reticle along the shortest way, keeping only this fraction of its angle. */ + private static void shrinkCursor(double keep) { + double angle = Math.acos(Mth.clamp(cf, -1.0, 1.0)); + double lateral = Math.sqrt(cr * cr + cu * cu); + if (angle < 1.0E-6 || lateral < 1.0E-9) { + cf = 1.0; + cr = 0.0; + cu = 0.0; + return; + } + double newAngle = angle * keep; + double scale = Math.sin(newAngle) / lateral; + cf = Math.cos(newAngle); + cr *= scale; + cu *= scale; + } + + private static void normalizeCursor() { + double length = Math.sqrt(cf * cf + cr * cr + cu * cu); + if (!(length > 1.0E-9)) { + cf = 1.0; + cr = 0.0; + cu = 0.0; + return; + } + cf /= length; + cr /= length; + cu /= length; + } + + private static void limitCursor() { + double limit = Math.toRadians(CursorAimClientConfig.MAX_CURSOR_ANGLE_DEGREES.get()); + double angle = Math.acos(Mth.clamp(cf, -1.0, 1.0)); + if (angle <= limit) { + return; + } + double lateral = Math.sqrt(cr * cr + cu * cu); + if (lateral < 1.0E-9) { + return; + } + double scale = Math.sin(limit) / lateral; + cf = Math.cos(limit); + cr *= scale; + cu *= scale; + } + + // ---- Mouse wheel and camera distance ---- + + @SubscribeEvent + public static void onScroll(InputEvent.MouseScrollingEvent event) { + Minecraft minecraft = Minecraft.getInstance(); + if (!active || minecraft.screen != null) { + return; + } + zoom = Mth.clamp(zoom - event.getScrollDelta() * CursorAimClientConfig.ZOOM_STEP.get(), -3.0, CursorAimClientConfig.ZOOM_MAX.get()); + event.setCanceled(true); + } + + @SubscribeEvent + public static void onCameraAngles(ViewportEvent.ComputeCameraAngles event) { + if (!active || Math.abs(zoom) < 1.0E-3) { + return; + } + try { + if (cameraMove == null) { + // Camera.move(double, double, double), moves along the camera's own axes + cameraMove = ObfuscationReflectionHelper.findMethod(Camera.class, "m_90568_", double.class, double.class, double.class); + } + cameraMove.invoke(event.getCamera(), -zoom, 0.0, 0.0); + } catch (ReflectiveOperationException | RuntimeException exception) { + ImprovedAerodynamics.LOGGER.warn("Cursor aim camera zoom is not available", exception); + zoom = 0.0; + } + } + + // ---- Drawing the cursor ---- + + @SubscribeEvent + public static void onRenderLevel(RenderLevelStageEvent event) { + if (!active || event.getStage() != RenderLevelStageEvent.Stage.AFTER_PARTICLES) { + return; + } + Minecraft minecraft = Minecraft.getInstance(); + ClientLevel level = minecraft.level; + LocalPlayer player = minecraft.player; + if (level == null || player == null || !seated(player)) { + return; + } + Entity seat = AimSeat.seatOf(player); + AimBasis basis = AimBasis.of(level, seat.blockPosition()); + Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, BlockPos.containing(seat.getX(), seat.getY(), seat.getZ())); + if (basis == null || ship == null) { + return; + } + Quaterniondc rotation = ship instanceof ClientShip clientShip + ? clientShip.getRenderTransform().getShipToWorldRotation() : ship.getTransform().getShipToWorldRotation(); + double[] cursorShip = basis.toShip(cf, cr, cu); + Vector3d cursorWorld = rotation.transform(new Vector3d(cursorShip[0], cursorShip[1], cursorShip[2])); + double[] forward = basis.forward(); + Vector3d reticleWorld = rotation.transform(new Vector3d(forward[0], forward[1], forward[2])); + + 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); + + float size = (float) (CursorAimClientConfig.MARKER_SIZE.get() * MARKER_DISTANCE); + if (CursorAimClientConfig.SHOW_RETICLE.get()) { + plus(builder, matrix, camera, reticleWorld, size * 0.35F, RETICLE_COLOR); + } + ring(builder, matrix, camera, cursorWorld, size, CURSOR_COLOR); + BufferUploader.drawWithShader(builder.end()); + + RenderSystem.enableCull(); + RenderSystem.enableDepthTest(); + RenderSystem.disableBlend(); + } + + private static Vector3f screenPoint(Camera camera, Vector3d direction) { + Vector3f center = new Vector3f((float) direction.x, (float) direction.y, (float) direction.z); + if (center.lengthSquared() < 1.0E-9F) { + return null; + } + return center.normalize().mul(MARKER_DISTANCE); + } + + private static void ring(BufferBuilder builder, Matrix4f matrix, Camera camera, Vector3d direction, float radius, int color) { + Vector3f center = screenPoint(camera, direction); + if (center == null) { + return; + } + Vector3f left = new Vector3f(camera.getLeftVector()); + Vector3f up = new Vector3f(camera.getUpVector()); + float thickness = radius * 0.12F; + GunPipperRenderer.ring(builder, matrix, center, left, up, radius, thickness, color); + GunPipperRenderer.quad(builder, matrix, center, left, up, thickness * 1.4F, thickness * 1.4F, color); + } + + private static void plus(BufferBuilder builder, Matrix4f matrix, Camera camera, Vector3d direction, float arm, int color) { + Vector3f center = screenPoint(camera, direction); + if (center == null) { + return; + } + Vector3f left = new Vector3f(camera.getLeftVector()); + Vector3f up = new Vector3f(camera.getUpVector()); + float thickness = arm * 0.14F; + GunPipperRenderer.quad(builder, matrix, center, left, up, arm, thickness, color); + GunPipperRenderer.quad(builder, matrix, center, left, up, thickness, arm, color); + } + + // ---- Vector helpers on arrays of three ---- + + /** Rotates v about the unit axis by an angle in radians (Rodrigues). */ + private static double[] rotate(double[] v, double[] axis, double angle) { + double c = Math.cos(angle); + double s = Math.sin(angle); + double dot = v[0] * axis[0] + v[1] * axis[1] + v[2] * axis[2]; + double cx = axis[1] * v[2] - axis[2] * v[1]; + double cy = axis[2] * v[0] - axis[0] * v[2]; + double cz = axis[0] * v[1] - axis[1] * v[0]; + return new double[]{ + v[0] * c + cx * s + axis[0] * dot * (1.0 - c), + v[1] * c + cy * s + axis[1] * dot * (1.0 - c), + v[2] * c + cz * s + axis[2] * dot * (1.0 - c)}; + } + + /** Turns unit vector a towards unit vector b by a fraction of the angle between them. */ + private static double[] turnTowards(double[] a, double[] b, double fraction) { + double dot = Mth.clamp(a[0] * b[0] + a[1] * b[1] + a[2] * b[2], -1.0, 1.0); + double cx = a[1] * b[2] - a[2] * b[1]; + double cy = a[2] * b[0] - a[0] * b[2]; + double cz = a[0] * b[1] - a[1] * b[0]; + double sin = Math.sqrt(cx * cx + cy * cy + cz * cz); + if (sin < 1.0E-9) { + return dot > 0.0 ? b.clone() : a.clone(); + } + double[] axis = {cx / sin, cy / sin, cz / sin}; + return rotate(a, axis, Math.acos(dot) * fraction); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClientConfig.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClientConfig.java new file mode 100644 index 0000000..3a92576 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClientConfig.java @@ -0,0 +1,45 @@ +package dev.lumareth.improvedAerodynamics.client.aim; + +import net.minecraftforge.common.ForgeConfigSpec; + +/** Client side settings of cursor aim. Lives in improved_aerodynamics-cursor-aim-client.toml. */ +public final class CursorAimClientConfig { + public static final ForgeConfigSpec SPEC; + + public static final ForgeConfigSpec.DoubleValue LOOK_AHEAD; + public static final ForgeConfigSpec.DoubleValue MOUSE_SENSITIVITY; + public static final ForgeConfigSpec.DoubleValue SPRING_BACK_SECONDS; + public static final ForgeConfigSpec.DoubleValue MAX_CURSOR_ANGLE_DEGREES; + public static final ForgeConfigSpec.DoubleValue ZOOM_STEP; + public static final ForgeConfigSpec.DoubleValue ZOOM_MAX; + public static final ForgeConfigSpec.DoubleValue MARKER_SIZE; + public static final ForgeConfigSpec.BooleanValue SHOW_RETICLE; + + static { + ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder(); + builder.comment("Cursor aim, client side").push("cursor_aim"); + LOOK_AHEAD = builder.comment("How strongly the camera looks into the direction of the turn. 0 never moves it, 1 (100%) turns it quickly", + "to look where the cursor points. In between it follows more slowly") + .defineInRange("lookAhead", 0.0, 0.0, 1.0); + MOUSE_SENSITIVITY = builder.comment("Multiplier for how far the mouse moves the cursor (or the camera while the free look key is held)") + .defineInRange("mouseSensitivity", 1.0, 0.05, 10.0); + SPRING_BACK_SECONDS = builder.comment("The cursor returns to the gun reticle with this time constant in seconds when you stop moving the mouse.", + "0 makes it stay where you left it") + .defineInRange("springBackSeconds", 1.0, 0.0, 10.0); + MAX_CURSOR_ANGLE_DEGREES = builder.comment("Furthest the cursor can go from the gun reticle, 135 is a 270 degree cone") + .defineInRange("maxCursorAngleDegrees", 135.0, 10.0, 180.0); + ZOOM_STEP = builder.comment("Blocks the camera moves per mouse wheel notch") + .defineInRange("zoomStep", 2.0, 0.1, 20.0); + ZOOM_MAX = builder.comment("Furthest the camera can be pulled back from its normal distance, in blocks") + .defineInRange("zoomMax", 80.0, 0.0, 500.0); + MARKER_SIZE = builder.comment("Radius of the cursor ring on screen") + .defineInRange("markerSize", 0.04, 0.01, 0.3); + SHOW_RETICLE = builder.comment("Draw a small cross where the gun reticle points, the zero of the cursor, so you can see it when the camera looks away") + .define("showReticle", true); + builder.pop(); + SPEC = builder.build(); + } + + private CursorAimClientConfig() { + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunPipperRenderer.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunPipperRenderer.java index bade3e2..ff65bac 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunPipperRenderer.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/pipper/GunPipperRenderer.java @@ -218,7 +218,7 @@ public final class GunPipperRenderer { return true; } - private static void ring(BufferBuilder builder, Matrix4f matrix, Vector3f center, Vector3f left, Vector3f up, float radius, float thickness, int color) { + public 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; @@ -232,7 +232,7 @@ public final class GunPipperRenderer { } } - private static void quad(BufferBuilder builder, Matrix4f matrix, Vector3f center, Vector3f left, Vector3f up, float halfX, float halfY, int color) { + public 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); diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java index 632203a..eccd695 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java @@ -27,6 +27,7 @@ public final class AeroState implements PhysicsBridge.Publishable { private static final double INPUT_RATE_PER_TICK = 0.2; private static final double VELOCITY_EPSILON = 0.05; private static final double ANGULAR_EPSILON = 0.005; + private static final ControlAxis[] MEASURED_AXES = {ControlAxis.PITCH, ControlAxis.YAW, ControlAxis.ROLL}; private final SubstepClock clock; @@ -57,9 +58,22 @@ public final class AeroState implements PhysicsBridge.Publishable { // Torque (ship axes) one unit of pitch input on this node's pitch surfaces makes, measured every substep final Vector3d pitchResponse = new Vector3d(); final Vector3d pitchResponseBuilding = new Vector3d(); + // The same for yaw and roll, measured while cursor aim flies the node + final Vector3d yawResponse = new Vector3d(); + final Vector3d yawResponseBuilding = new Vector3d(); + final Vector3d rollResponse = new Vector3d(); + final Vector3d rollResponseBuilding = new Vector3d(); final AutoTrim.Targets trimTargets = new AutoTrim.Targets(); volatile double savedTrim; + Vector3d responseBuilding(ControlAxis axis) { + return switch (axis) { + case YAW -> yawResponseBuilding; + case ROLL -> rollResponseBuilding; + default -> pitchResponseBuilding; + }; + } + NodeState(NodeData data) { pitch = data.pitch(); yaw = data.yaw(); @@ -211,6 +225,8 @@ public final class AeroState implements PhysicsBridge.Publishable { wingStalled = false; for (NodeState state : nodeStates.values()) { state.pitchResponseBuilding.zero(); + state.yawResponseBuilding.zero(); + state.rollResponseBuilding.zero(); } } @@ -221,7 +237,7 @@ public final class AeroState implements PhysicsBridge.Publishable { r[2] = s.z() - centerOfMass.z(); NodeData node = s.node() != AeroSurface.NO_NODE ? frame.nodes.get(s.node()) : null; NodeState state = node != null ? nodeStates.get(node.key()) : null; - double input = s.type().controllable ? input(s, node, state, 0.0) : 0.0; + double input = s.type().controllable ? input(s, node, state, ControlAxis.NONE, 0.0) : 0.0; if (improved) { boolean nowStalled = ImprovedAero.evaluate(s, v, w, r, input, weight, tuning, stalled[i], out); @@ -233,53 +249,62 @@ public final class AeroState implements PhysicsBridge.Publishable { WariumAero.evaluate(s, v, w, r, input, weight, out); } - // How much a little more pitch input on this node would change the torque, for auto trim - if (commit && node != null && node.autoTrim() && s.type().controllable && s.listensToPitch()) { - double delta = 0.05; - probe.clear(); - double base = 0.0; - if (improved) { - ImprovedAero.evaluate(s, v, w, r, input, weight, tuning, stalled[i], probe); - } else { - WariumAero.evaluate(s, v, w, r, input, weight, probe); + // How much a little more input on an axis would change the torque: pitch for auto trim, all three for + // cursor aim + if (commit && node != null && s.type().controllable) { + boolean aimed = CursorAimRegistry.isActive(node.key()); + for (ControlAxis axis : MEASURED_AXES) { + if (!s.listensTo(axis) || !(aimed || (axis == ControlAxis.PITCH && node.autoTrim()))) { + continue; + } + double delta = 0.05; + probe.clear(); + if (improved) { + ImprovedAero.evaluate(s, v, w, r, input, weight, tuning, stalled[i], probe); + } else { + WariumAero.evaluate(s, v, w, r, input, weight, probe); + } + double[] before = {probe.totalTorque(0), probe.totalTorque(1), probe.totalTorque(2)}; + probe.clear(); + double nudged = input(s, node, state, axis, delta); + if (improved) { + ImprovedAero.evaluate(s, v, w, r, nudged, weight, tuning, stalled[i], probe); + } else { + WariumAero.evaluate(s, v, w, r, nudged, weight, probe); + } + state.responseBuilding(axis).add( + (probe.totalTorque(0) - before[0]) / delta, + (probe.totalTorque(1) - before[1]) / delta, + (probe.totalTorque(2) - before[2]) / delta); } - double[] before = {probe.totalTorque(0), probe.totalTorque(1), probe.totalTorque(2)}; - probe.clear(); - double nudged = input(s, node, state, delta); - if (improved) { - ImprovedAero.evaluate(s, v, w, r, nudged, weight, tuning, stalled[i], probe); - } else { - WariumAero.evaluate(s, v, w, r, nudged, weight, probe); - } - state.pitchResponseBuilding.add( - (probe.totalTorque(0) - before[0]) / delta, - (probe.totalTorque(1) - before[1]) / delta, - (probe.totalTorque(2) - before[2]) / delta); } } if (commit) { for (NodeState state : nodeStates.values()) { state.pitchResponse.set(state.pitchResponseBuilding); + state.yawResponse.set(state.yawResponseBuilding); + state.rollResponse.set(state.rollResponseBuilding); } } } /** A control surface's input, combined from its two keys and clamped like Warium does. */ - private static double input(AeroSurface s, NodeData node, NodeState state, double extraPitch) { + private static double input(AeroSurface s, NodeData node, NodeState state, ControlAxis nudged, double extra) { if (node == null || state == null) { return 0.0; } - double value = axisValue(s.axis1(), node, state, extraPitch) * s.multiplier1() - + axisValue(s.axis2(), node, state, extraPitch) * s.multiplier2(); + double value = axisValue(s.axis1(), node, state, nudged, extra) * s.multiplier1() + + axisValue(s.axis2(), node, state, nudged, extra) * s.multiplier2(); return Math.max(-1.0, Math.min(1.0, value)); } - private static double axisValue(ControlAxis axis, NodeData node, NodeState state, double extraPitch) { + private static double axisValue(ControlAxis axis, NodeData node, NodeState state, ControlAxis nudged, double extra) { + double nudge = axis == nudged ? extra : 0.0; return switch (axis) { - case PITCH -> state.pitch + state.trimOutput + extraPitch; - case YAW -> state.yaw; - case ROLL -> state.roll; + case PITCH -> state.pitch + state.trimOutput + nudge; + case YAW -> state.yaw + nudge; + case ROLL -> state.roll + nudge; case THROTTLE -> node.throttle(); case NONE -> 0.0; }; @@ -293,10 +318,12 @@ public final class AeroState implements PhysicsBridge.Publishable { if (state == null) { continue; } - state.pitch = moveTowards(state.pitch, node.pitch(), step); - state.yaw = moveTowards(state.yaw, node.yaw(), step); - state.roll = moveTowards(state.roll, node.roll(), step); - AutoTrim.update(state, node, worldVelocity, altitude, rotation, inertia, w, dt, improved && wingStalled); + // Cursor aim, if the pilot has it on, supplies the inputs the pilot is not giving with the keys + NodeData effective = CursorPilot.apply(state, node, rotation, inertia, w, worldVelocity.length(), wingStalled); + state.pitch = moveTowards(state.pitch, effective.pitch(), step); + state.yaw = moveTowards(state.yaw, effective.yaw(), step); + state.roll = moveTowards(state.roll, effective.roll(), step); + AutoTrim.update(state, effective, worldVelocity, altitude, rotation, inertia, w, dt, improved && wingStalled); } } diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroSurface.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroSurface.java index 712388f..db4acab 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroSurface.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroSurface.java @@ -36,6 +36,10 @@ public record AeroSurface(long key, SurfaceType type, boolean slab, double x, do } boolean listensToPitch() { - return node != NO_NODE && (axis1 == ControlAxis.PITCH || axis2 == ControlAxis.PITCH); + return listensTo(ControlAxis.PITCH); + } + + boolean listensTo(ControlAxis axis) { + return node != NO_NODE && (axis1 == axis || axis2 == axis); } } diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AimBasis.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AimBasis.java new file mode 100644 index 0000000..3c5cd3a --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AimBasis.java @@ -0,0 +1,101 @@ +package dev.lumareth.improvedAerodynamics.flight; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.Level; +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; + +/** + * The frame cursor aim steers in, in ship coordinates: forward is the boresight (what Warium's gun reticle points + * along, from the HUD block in front of the seat, else the way the seat faces), up is the ship's up axis and right + * completes it. Server and client both build it from the same block data, so a cursor sent as components in this + * frame means the same on both. + * + * @param nodePos the Vehicle Control Node the seat is linked to + */ +public record AimBasis(BlockPos seatPos, BlockPos nodePos, double[] forward, double[] right, double[] up) { + + /** + * @param entityPos block position of the seat entity. Warium's control seat block is at that position or just + * below it. + * @return null if there is no seat block linked to a control node there + */ + public static AimBasis of(Level level, BlockPos entityPos) { + for (BlockPos pos : new BlockPos[]{entityPos, entityPos.below()}) { + BlockEntity seat = level.getBlockEntity(pos); + if (seat == null || !seat.getPersistentData().contains("ControlX")) { + continue; + } + CompoundTag tag = seat.getPersistentData(); + BlockPos nodePos = BlockPos.containing(tag.getDouble("ControlX"), tag.getDouble("ControlY"), tag.getDouble("ControlZ")); + return new AimBasis(pos, nodePos, null, null, null).withAxes(level, pos, nodePos); + } + return null; + } + + private AimBasis withAxes(Level level, BlockPos seatPos, BlockPos nodePos) { + BlockState state = level.getBlockState(seatPos); + Direction facing = Direction.NORTH; + Property property = state.getBlock().getStateDefinition().getProperty("facing"); + if (property instanceof DirectionProperty directionProperty) { + facing = state.getValue(directionProperty); + } + double fx = facing.getStepX(); + double fy = facing.getStepY(); + double fz = facing.getStepZ(); + + // Warium's gun reticle: the HUD block in front of the seat holds the aim vector of its guns + BlockEntity hud = level.getBlockEntity(seatPos.relative(facing)); + if (hud != null && hud.getPersistentData().contains("Pitch")) { + CompoundTag hudTag = hud.getPersistentData(); + double bx = hudTag.getDouble("X") + facing.getStepX(); + double by = hudTag.getDouble("Pitch"); + double bz = hudTag.getDouble("Z") + facing.getStepZ(); + if (bx * bx + by * by + bz * bz > 1.0E-6) { + fx = bx; + fy = by; + fz = bz; + } + } + double length = Math.sqrt(fx * fx + fy * fy + fz * fz); + if (length < 1.0E-6) { + fx = 0.0; + fy = 0.0; + fz = -1.0; + length = 1.0; + } + fx /= length; + fy /= length; + fz /= length; + + // right = forward x up(0,1,0) + double rx = -fz; + double ry = 0.0; + double rz = fx; + double rl = Math.sqrt(rx * rx + rz * rz); + if (rl < 1.0E-6) { + rx = 1.0; + rz = 0.0; + rl = 1.0; + } + rx /= rl; + rz /= rl; + // up = right x forward + double ux = ry * fz - rz * fy; + double uy = rz * fx - rx * fz; + double uz = rx * fy - ry * fx; + return new AimBasis(seatPos, nodePos, new double[]{fx, fy, fz}, new double[]{rx, ry, rz}, new double[]{ux, uy, uz}); + } + + /** Ship-space direction of a cursor given by its components along forward, right and up. */ + public double[] toShip(double cf, double cr, double cu) { + return new double[]{ + forward[0] * cf + right[0] * cr + up[0] * cu, + forward[1] * cf + right[1] * cr + up[1] * cu, + forward[2] * cf + right[2] * cr + up[2] * cu}; + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimConfig.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimConfig.java new file mode 100644 index 0000000..6478b73 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimConfig.java @@ -0,0 +1,52 @@ +package dev.lumareth.improvedAerodynamics.flight; + +import net.minecraftforge.common.ForgeConfigSpec; + +/** Server side settings of the cursor aim virtual pilot. Lives in improved_aerodynamics-cursor-aim.toml. */ +public final class CursorAimConfig { + public static final ForgeConfigSpec SPEC; + + public static final ForgeConfigSpec.BooleanValue ENABLED; + public static final ForgeConfigSpec.DoubleValue FULL_DEFLECTION_DEGREES; + public static final ForgeConfigSpec.DoubleValue MAX_TURN_RATE_DEGREES; + public static final ForgeConfigSpec.DoubleValue MAX_ROLL_RATE_DEGREES; + public static final ForgeConfigSpec.DoubleValue MAX_BANK_DEGREES; + public static final ForgeConfigSpec.DoubleValue BANK_GAIN; + public static final ForgeConfigSpec.DoubleValue RATE_GAIN; + public static final ForgeConfigSpec.DoubleValue PUSH_FACTOR; + public static final ForgeConfigSpec.DoubleValue STALLED_PITCH_FACTOR; + public static final ForgeConfigSpec.DoubleValue MIN_SPEED; + + static { + ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder(); + builder.comment("Cursor aim: the mouse moves a cursor on screen, a virtual pilot flies the control node's craft by it").push("cursor_aim"); + ENABLED = builder.comment("Whether pilots may use cursor aim on this server") + .define("enabled", true); + FULL_DEFLECTION_DEGREES = builder.comment("Angle of the cursor from the gun reticle at which the virtual pilot asks for full deflection: the maximum bank", + "sideways, the full pitch rate up or down") + .defineInRange("fullDeflectionDegrees", 30.0, 5.0, 90.0); + MAX_TURN_RATE_DEGREES = builder.comment("Turn rate in degrees per second the pilot asks for at full deflection, both for the turn at maximum bank and for pitching") + .defineInRange("maxTurnRateDegrees", 40.0, 5.0, 360.0); + MAX_ROLL_RATE_DEGREES = builder.comment("Fastest roll in degrees per second the pilot asks for when it rolls to a bank angle") + .defineInRange("maxRollRateDegrees", 150.0, 10.0, 720.0); + MAX_BANK_DEGREES = builder.comment("Bank angle the pilot rolls to when the cursor is at full sideways deflection") + .defineInRange("maxBankDegrees", 70.0, 10.0, 90.0); + BANK_GAIN = builder.comment("Roll rate in rad/s per radian of bank angle error, how eagerly the pilot rolls to the wanted bank and levels the wings") + .defineInRange("bankGain", 3.0, 0.2, 20.0); + RATE_GAIN = builder.comment("How hard the pilot corrects its turn rate, in angular acceleration per rad/s of rate error. Lower is smoother,", + "higher follows the cursor tighter and can start to oscillate") + .defineInRange("rateGain", 5.0, 0.5, 40.0); + PUSH_FACTOR = builder.comment("A cursor below the gun reticle pushes the nose down. This is how much of the pull rate that may be, since", + "negative g is harder on the craft and the pilot") + .defineInRange("pushFactor", 0.4, 0.0, 1.0); + STALLED_PITCH_FACTOR = builder.comment("Multiplier for the pitch rate the pilot asks for while the wings are stalled, so it does not keep pulling") + .defineInRange("stalledPitchFactor", 0.3, 0.0, 1.0); + MIN_SPEED = builder.comment("Below this speed in blocks per second the pilot does nothing, the surfaces have no effect anyway") + .defineInRange("minSpeed", 15.0, 0.0, 200.0); + builder.pop(); + SPEC = builder.build(); + } + + private CursorAimConfig() { + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimRegistry.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimRegistry.java new file mode 100644 index 0000000..f2ee571 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimRegistry.java @@ -0,0 +1,44 @@ +package dev.lumareth.improvedAerodynamics.flight; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * Where each control node's pilot currently holds the cursor of cursor aim. Written by the server thread when the + * client's packet arrives, read by the physics thread. An entry that is not refreshed for a moment (the player left + * the seat, lost the connection) counts as gone. + */ +public final class CursorAimRegistry { + private static final long EXPIRE_NANOS = 500_000_000L; + private static final ConcurrentHashMap AIMS = new ConcurrentHashMap<>(); + + /** Cursor as components along the basis axes (unit length), and the basis itself, in ship space. */ + public record Aim(double cf, double cr, double cu, double[] forward, double[] right, double[] up, long nanos) { + } + + private CursorAimRegistry() { + } + + public static void set(long nodeKey, Aim aim) { + AIMS.put(nodeKey, aim); + } + + public static void clear(long nodeKey) { + AIMS.remove(nodeKey); + } + + public static Aim get(long nodeKey) { + Aim aim = AIMS.get(nodeKey); + if (aim == null) { + return null; + } + if (System.nanoTime() - aim.nanos() > EXPIRE_NANOS) { + AIMS.remove(nodeKey, aim); + return null; + } + return aim; + } + + public static boolean isActive(long nodeKey) { + return get(nodeKey) != null; + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimServer.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimServer.java new file mode 100644 index 0000000..32bbb47 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimServer.java @@ -0,0 +1,30 @@ +package dev.lumareth.improvedAerodynamics.flight; + +import net.mcreator.valkyrienwarium.entity.SeatEntityEntity; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; + +/** Server side of cursor aim: takes the cursor a pilot sends and hands it to the control node's virtual pilot. */ +public final class CursorAimServer { + private CursorAimServer() { + } + + public static void accept(ServerPlayer player, boolean active, double cf, double cr, double cu) { + Entity vehicle = player.getVehicle(); + if (!(vehicle instanceof SeatEntityEntity)) { + return; + } + AimBasis basis = AimBasis.of(vehicle.level(), vehicle.blockPosition()); + if (basis == null) { + return; + } + long nodeKey = basis.nodePos().asLong(); + double length = Math.sqrt(cf * cf + cr * cr + cu * cu); + if (!active || !CursorAimConfig.ENABLED.get() || !(length > 1.0E-6) || !Double.isFinite(length)) { + CursorAimRegistry.clear(nodeKey); + return; + } + CursorAimRegistry.set(nodeKey, new CursorAimRegistry.Aim(cf / length, cr / length, cu / length, + basis.forward(), basis.right(), basis.up(), System.nanoTime())); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorPilot.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorPilot.java new file mode 100644 index 0000000..dcf7fb9 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorPilot.java @@ -0,0 +1,136 @@ +package dev.lumareth.improvedAerodynamics.flight; + +import org.joml.Matrix3d; +import org.joml.Matrix3dc; +import org.joml.Quaterniondc; +import org.joml.Vector3d; + +/** + * The virtual pilot of cursor aim. The cursor is a stick that springs back to the gun reticle: + *
    + *
  • sideways it commands a bank angle and turn: the further out, the steeper the bank and the harder the pull + * that comes with it. Centered it levels the wings, so a centered cursor is straight flight;
  • + *
  • up and down it commands a pitch rate;
  • + *
  • close to the reticle it steers with yaw and pitch directly instead of banking, so aiming does not rock the + * wings.
  • + *
+ * The wanted rotation is turned into stick inputs by the measured effect of each input on the craft (torque per unit + * of input, see {@link AeroState}), so it works for any craft however its surfaces are mixed or oriented: angular + * acceleration wanted times inertia, solved for the three inputs with a little regularization for axes that have no + * surfaces. Keys the pilot holds win over it, per axis. + */ +final class CursorPilot { + private static final double FINE_START = Math.toRadians(4.0); + private static final double FINE_END = Math.toRadians(16.0); + private static final double MANUAL_OVERRIDE = 0.05; + private static final double MAX_PULL_BANK = Math.toRadians(90.0); + + private CursorPilot() { + } + + /** + * @return the node with the pilot's wanted inputs in place of the keys, or the node itself if cursor aim is off + */ + static NodeData apply(AeroState.NodeState state, NodeData node, Quaterniondc rotation, Matrix3dc inertia, double[] w, + double speed, boolean wingStalled) { + if (!CursorAimConfig.ENABLED.get()) { + return node; + } + CursorAimRegistry.Aim aim = CursorAimRegistry.get(node.key()); + if (aim == null || speed < CursorAimConfig.MIN_SPEED.get()) { + return node; + } + + double fullAngle = Math.toRadians(CursorAimConfig.FULL_DEFLECTION_DEGREES.get()); + double maxTurn = Math.toRadians(CursorAimConfig.MAX_TURN_RATE_DEGREES.get()); + double maxRoll = Math.toRadians(CursorAimConfig.MAX_ROLL_RATE_DEGREES.get()); + double maxBank = Math.toRadians(CursorAimConfig.MAX_BANK_DEGREES.get()); + + double[] f = aim.forward(); + double[] r = aim.right(); + double[] u = aim.up(); + + // Cursor as angles from the reticle: sideways (positive right) and up, both through any 270 degree cone + double sideways = Math.atan2(aim.cr(), aim.cf()); + double vertical = Math.atan2(aim.cu(), Math.hypot(aim.cf(), aim.cr())); + double lateral = saturate(sideways / fullAngle); + double blend = smoothstep(Math.abs(sideways), FINE_START, FINE_END); + + // Bank of the craft: positive when the right wing is down, through upside down + Vector3d rightWorld = rotation.transform(new Vector3d(r[0], r[1], r[2])); + Vector3d upWorld = rotation.transform(new Vector3d(u[0], u[1], u[2])); + double bank = Math.atan2(-rightWorld.y, upWorld.y); + + double bankCommand = maxBank * lateral * blend; + double rollRate = clamp(CursorAimConfig.BANK_GAIN.get() * (bankCommand - bank), -maxRoll, maxRoll); + + // Pitch: the stick, plus the pull that carries the turn while banked + double pitchStick = saturate(vertical / fullAngle); + double pitchRate = maxTurn * (pitchStick >= 0.0 ? pitchStick : pitchStick * CursorAimConfig.PUSH_FACTOR.get()); + if (Math.abs(bank) < MAX_PULL_BANK) { + pitchRate += maxTurn * Math.abs(lateral) * blend * Math.sin(Math.abs(bank)); + } + if (wingStalled && pitchRate > 0.0) { + pitchRate *= CursorAimConfig.STALLED_PITCH_FACTOR.get(); + } + // Close to the reticle the nose follows the cursor with the rudder + double yawRate = maxTurn * lateral * (1.0 - blend); + + // Wanted angular velocity in ship axes: roll about forward, pitch about right, yaw (nose right) about -up + Vector3d wanted = new Vector3d( + rollRate * f[0] + pitchRate * r[0] - yawRate * u[0], + rollRate * f[1] + pitchRate * r[1] - yawRate * u[1], + rollRate * f[2] + pitchRate * r[2] - yawRate * u[2]); + Vector3d error = wanted.sub(w[0], w[1], w[2]); + Vector3d acceleration = error.mul(CursorAimConfig.RATE_GAIN.get()); + Vector3d torque = inertia.transform(acceleration, new Vector3d()); + + double[] inputs = solve(state.pitchResponse, state.yawResponse, state.rollResponse, torque); + if (inputs == null) { + return node; + } + + double pitch = clamp(inputs[0] - state.trimOutput, -1.0, 1.0); + double yaw = clamp(inputs[1], -1.0, 1.0); + double roll = clamp(inputs[2], -1.0, 1.0); + return new NodeData(node.key(), + Math.abs(node.pitch()) > MANUAL_OVERRIDE ? node.pitch() : pitch, + Math.abs(node.yaw()) > MANUAL_OVERRIDE ? node.yaw() : yaw, + Math.abs(node.roll()) > MANUAL_OVERRIDE ? node.roll() : roll, + node.throttle(), node.autoTrim(), node.storedTrim()); + } + + /** Least squares for the three inputs whose responses add up to the torque, ridge-regularized so unused axes stay at zero. */ + private static double[] solve(Vector3d pitch, Vector3d yaw, Vector3d roll, Vector3d torque) { + Matrix3d responses = new Matrix3d(pitch, yaw, roll); + Matrix3d transposed = responses.transpose(new Matrix3d()); + Matrix3d normal = transposed.mul(responses, new Matrix3d()); + double trace = normal.m00() + normal.m11() + normal.m22(); + if (!(trace > 1.0E-9) || !Double.isFinite(trace)) { + return null; + } + double ridge = trace / 3.0 * 1.0E-3; + normal.m00(normal.m00() + ridge); + normal.m11(normal.m11() + ridge); + normal.m22(normal.m22() + ridge); + Vector3d right = transposed.transform(torque, new Vector3d()); + Vector3d x = normal.invert().transform(right); + if (!x.isFinite()) { + return null; + } + return new double[]{x.x, x.y, x.z}; + } + + private static double saturate(double value) { + return clamp(value, -1.0, 1.0); + } + + private static double smoothstep(double value, double from, double to) { + double t = clamp((value - from) / (to - from), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); + } + + private static double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/network/AimPacket.java b/src/main/java/dev/lumareth/improvedAerodynamics/network/AimPacket.java new file mode 100644 index 0000000..1d5690c --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/network/AimPacket.java @@ -0,0 +1,45 @@ +package dev.lumareth.improvedAerodynamics.network; + +import dev.lumareth.improvedAerodynamics.flight.CursorAimServer; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.network.NetworkEvent; + +import java.util.function.Supplier; + +/** + * Sent every tick while a pilot has cursor aim on, and once when it goes off. The cursor is given as components along + * the craft's boresight, right and up axes, so it does not depend on the camera. + */ +public final class AimPacket { + private final boolean active; + private final float forward; + private final float right; + private final float up; + + public AimPacket(boolean active, float forward, float right, float up) { + this.active = active; + this.forward = forward; + this.right = right; + this.up = up; + } + + public void encode(FriendlyByteBuf buffer) { + buffer.writeBoolean(active); + buffer.writeFloat(forward); + buffer.writeFloat(right); + buffer.writeFloat(up); + } + + public static AimPacket decode(FriendlyByteBuf buffer) { + return new AimPacket(buffer.readBoolean(), buffer.readFloat(), buffer.readFloat(), buffer.readFloat()); + } + + public void handle(Supplier context) { + ServerPlayer player = context.get().getSender(); + if (player != null && ModList.get().isLoaded("valkyrien_warium")) { + CursorAimServer.accept(player, active, forward, right, up); + } + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java b/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java index 5249949..c0fb8fc 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java @@ -23,5 +23,11 @@ public final class Network { .decoder(ToggleAutoTrimPacket::decode) .consumerMainThread(ToggleAutoTrimPacket::handle) .add(); + // High id so it cannot collide with packets added on other branches + CHANNEL.messageBuilder(AimPacket.class, 50, NetworkDirection.PLAY_TO_SERVER) + .encoder(AimPacket::encode) + .decoder(AimPacket::decode) + .consumerMainThread(AimPacket::handle) + .add(); } } diff --git a/src/main/resources/assets/improved_aerodynamics/lang/de_de.json b/src/main/resources/assets/improved_aerodynamics/lang/de_de.json index e2274bb..b165ec4 100644 --- a/src/main/resources/assets/improved_aerodynamics/lang/de_de.json +++ b/src/main/resources/assets/improved_aerodynamics/lang/de_de.json @@ -12,5 +12,11 @@ "config.improved_aerodynamics.supersonicDrag": "Multiplikator für den Nullauftriebswiderstand bei vollem Überschall, ab Mach 1,3", "config.improved_aerodynamics.vortexLift": "Wirbelauftrieb: Flügel mit kleiner Streckung (Deltaflügel) überziehen später und liefern bei großen Anstellwinkeln mehr Auftrieb (verbessertes Flugmodell)", "config.improved_aerodynamics.vortexStallBonus": "Zusätzlicher Überziehwinkel der Flügel mit der kleinsten Streckung, als Anteil des normalen Überziehwinkels", - "config.improved_aerodynamics.vortexLiftStrength": "Multiplikator für den zusätzlichen Auftrieb der Vorderkantenwirbel" + "config.improved_aerodynamics.vortexLiftStrength": "Multiplikator für den zusätzlichen Auftrieb der Vorderkantenwirbel", + "key.improved_aerodynamics.toggle_cursor_aim": "Cursor-Steuerung umschalten", + "key.improved_aerodynamics.cursor_aim_free_look": "Cursor-Steuerung: Freie Sicht (halten)", + "message.improved_aerodynamics.cursor_aim.on": "Cursor-Steuerung an", + "message.improved_aerodynamics.cursor_aim.off": "Cursor-Steuerung aus", + "message.improved_aerodynamics.cursor_aim.no_seat": "Setz dich in einen Warium-Steuersitz, um die Cursor-Steuerung zu nutzen", + "message.improved_aerodynamics.cursor_aim.no_server": "Dieser Server hat Improved Aerodynamics nicht, die Cursor-Steuerung braucht es" } diff --git a/src/main/resources/assets/improved_aerodynamics/lang/en_us.json b/src/main/resources/assets/improved_aerodynamics/lang/en_us.json index 12dbf29..783e848 100644 --- a/src/main/resources/assets/improved_aerodynamics/lang/en_us.json +++ b/src/main/resources/assets/improved_aerodynamics/lang/en_us.json @@ -12,5 +12,11 @@ "config.improved_aerodynamics.supersonicDrag": "Zero-lift drag multiplier once fully supersonic, from Mach 1.3", "config.improved_aerodynamics.vortexLift": "Vortex lift: low aspect ratio (delta) wings stall later and lift more at high angles of attack (improved flight model)", "config.improved_aerodynamics.vortexStallBonus": "Extra stall angle of the lowest aspect ratio wings, as a fraction of the normal stall angle", - "config.improved_aerodynamics.vortexLiftStrength": "Multiplier for the extra lift the leading-edge vortices make" + "config.improved_aerodynamics.vortexLiftStrength": "Multiplier for the extra lift the leading-edge vortices make", + "key.improved_aerodynamics.toggle_cursor_aim": "Toggle cursor aim", + "key.improved_aerodynamics.cursor_aim_free_look": "Cursor aim: free look (hold)", + "message.improved_aerodynamics.cursor_aim.on": "Cursor aim on", + "message.improved_aerodynamics.cursor_aim.off": "Cursor aim off", + "message.improved_aerodynamics.cursor_aim.no_seat": "Sit in a Warium control seat to use cursor aim", + "message.improved_aerodynamics.cursor_aim.no_server": "This server does not have Improved Aerodynamics, cursor aim needs it" }