Author SHA1 Message Date
Claude 143adc020e Turn auto trim into a fly-by-wire flight computer
Pitch input becomes a rate command; the computer measures the pitching torque
without its own surfaces (airframe stability, thrust line, weight offset) and
cancels it with dynamic inversion, so relaxed-stability craft stay controllable.
Replaces trimAuthority with flightComputer* config values.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 18:05:44 +01:00
Claude 433311a1c9 Only cursor aim players may set or clear a node's cursor
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 17:57:43 +01:00
Claude 0c8af898ae Choose cursor aim with /aero controls instead of a keybind
/aero controls keyboard|cursor_aim is open to every player, stored per player
and synced to the client, which switches on automatically when seated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 17:55:52 +01:00
Claude 3c7889796b Add configurable cursor deadzone (0-15 degrees)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 14:06:27 +01:00
Claude 44a4390f69 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 13:38:11 +01:00
22 changed files with 1269 additions and 74 deletions

No files matched your search

@@ -123,12 +123,31 @@ public final class Config {
.comment("Improved flight model: multiplier for the extra lift the vortices make at high angles of attack") .comment("Improved flight model: multiplier for the extra lift the vortices make at high angles of attack")
.defineInRange("vortexLiftStrength", 1.0D, 0.0D, 3.0D); .defineInRange("vortexLiftStrength", 1.0D, 0.0D, 3.0D);
public static final ForgeConfigSpec.DoubleValue TRIM_AUTHORITY = BUILDER public static final ForgeConfigSpec.DoubleValue FBW_AUTHORITY = BUILDER
.comment("Auto trim: largest pitch input (fraction of full deflection) the trim may hold") .comment("Flight computer (auto trim): largest total pitch input, as a fraction of full deflection, the computer may use.",
.defineInRange("trimAuthority", 0.5D, 0.0D, 1.0D); "A craft with strongly relaxed stability needs close to 1")
.defineInRange("flightComputerAuthority", 1.0D, 0.0D, 1.0D);
public static final ForgeConfigSpec.DoubleValue FBW_RATE_GAIN = BUILDER
.comment("Flight computer: how hard it corrects a pitch rate that differs from the command, in rad/s² of angular",
"acceleration per rad/s of error. Higher holds the nose tighter, too high makes slow surfaces oscillate")
.defineInRange("flightComputerRateGain", 8.0D, 0.5D, 40.0D);
public static final ForgeConfigSpec.DoubleValue FBW_INTEGRAL_GAIN = BUILDER
.comment("Flight computer: integral gain that removes the steady error left by the torque model")
.defineInRange("flightComputerIntegralGain", 4.0D, 0.0D, 40.0D);
public static final ForgeConfigSpec.DoubleValue FBW_STICK_RATE_DEGREES = BUILDER
.comment("Flight computer: pitch rate in degrees per second the pilot commands with full pitch input")
.defineInRange("flightComputerStickRateDegrees", 40.0D, 5.0D, 120.0D);
public static final ForgeConfigSpec.DoubleValue FBW_ACTUATOR_RATE = BUILDER
.comment("Flight computer: fastest the computer moves the pitch surfaces, in fraction of full deflection per second.",
"Unstable craft need it high enough to catch a divergence, 4 or more")
.defineInRange("flightComputerActuatorRate", 6.0D, 0.5D, 50.0D);
public static final ForgeConfigSpec.DoubleValue TRIM_MIN_SPEED = BUILDER public static final ForgeConfigSpec.DoubleValue TRIM_MIN_SPEED = BUILDER
.comment("Auto trim: below this airspeed (blocks per second) the trim holds its current value") .comment("Flight computer (auto trim): below this airspeed (blocks per second) the computer holds its current output")
.defineInRange("trimMinSpeed", 10.0D, 0.0D, 100.0D); .defineInRange("trimMinSpeed", 10.0D, 0.0D, 100.0D);
static { static {
@@ -5,6 +5,7 @@ import com.mojang.brigadier.arguments.BoolArgumentType;
import com.mojang.brigadier.arguments.DoubleArgumentType; import com.mojang.brigadier.arguments.DoubleArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.tree.LiteralCommandNode; import com.mojang.brigadier.tree.LiteralCommandNode;
import dev.lumareth.improvedAerodynamics.flight.ControlMode;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands; import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
@@ -18,7 +19,8 @@ import java.util.List;
/** /**
* /improvedaerodynamics (or /aero) to read and change the most important server config values in game. * /improvedaerodynamics (or /aero) to read and change the most important server config values in game.
* {@code /aero} lists them, {@code /aero <option>} shows one, {@code /aero <option> <value>} sets and saves it. * {@code /aero} lists them, {@code /aero <option>} shows one, {@code /aero <option> <value>} sets and saves it.
* Needs operator rights, except for the owner of a singleplayer world, who may use them without cheats. * The config options need operator rights, except for the owner of a singleplayer world, who may use them without cheats.
* {@code /aero controls keyboard|cursor_aim} is open to every player and picks how they fly.
* All of them are read live, so changes apply immediately. * All of them are read live, so changes apply immediately.
*/ */
public final class ConfigCommands { public final class ConfigCommands {
@@ -41,7 +43,11 @@ public final class ConfigCommands {
new Option<>("vortex_lift", Config.VORTEX_LIFT, BoolArgumentType.bool(), Boolean.class), new Option<>("vortex_lift", Config.VORTEX_LIFT, BoolArgumentType.bool(), Boolean.class),
new Option<>("vortex_stall_bonus", Config.VORTEX_STALL_BONUS, DoubleArgumentType.doubleArg(), Double.class), new Option<>("vortex_stall_bonus", Config.VORTEX_STALL_BONUS, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("vortex_lift_strength", Config.VORTEX_LIFT_STRENGTH, DoubleArgumentType.doubleArg(), Double.class), new Option<>("vortex_lift_strength", Config.VORTEX_LIFT_STRENGTH, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("trim_authority", Config.TRIM_AUTHORITY, DoubleArgumentType.doubleArg(), Double.class), new Option<>("flight_computer_authority", Config.FBW_AUTHORITY, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("flight_computer_rate_gain", Config.FBW_RATE_GAIN, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("flight_computer_integral_gain", Config.FBW_INTEGRAL_GAIN, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("flight_computer_stick_rate_degrees", Config.FBW_STICK_RATE_DEGREES, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("flight_computer_actuator_rate", Config.FBW_ACTUATOR_RATE, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("trim_min_speed", Config.TRIM_MIN_SPEED, DoubleArgumentType.doubleArg(), Double.class), new Option<>("trim_min_speed", Config.TRIM_MIN_SPEED, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("air_placeholder", Config.ENABLE_AIR_PLACEHOLDER, BoolArgumentType.bool(), Boolean.class), new Option<>("air_placeholder", Config.ENABLE_AIR_PLACEHOLDER, BoolArgumentType.bool(), Boolean.class),
new Option<>("lookahead_seconds", Config.LOOKAHEAD_SECONDS, DoubleArgumentType.doubleArg(), Double.class)); new Option<>("lookahead_seconds", Config.LOOKAHEAD_SECONDS, DoubleArgumentType.doubleArg(), Double.class));
@@ -60,19 +66,45 @@ public final class ConfigCommands {
@SubscribeEvent @SubscribeEvent
public static void onRegisterCommands(RegisterCommandsEvent event) { public static void onRegisterCommands(RegisterCommandsEvent event) {
// The root is open to everybody for /aero controls, the config options check for rights themselves
LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal("improvedaerodynamics") LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal("improvedaerodynamics")
.requires(ConfigCommands::mayUse) .executes(context -> mayUse(context.getSource()) ? list(context.getSource()) : denied(context.getSource()));
.executes(context -> list(context.getSource()));
for (Option<?> option : OPTIONS) { for (Option<?> option : OPTIONS) {
root.then(optionNode(option)); root.then(optionNode(option).requires(ConfigCommands::mayUse));
} }
root.then(controlsNode());
LiteralCommandNode<CommandSourceStack> node = event.getDispatcher().register(root); LiteralCommandNode<CommandSourceStack> node = event.getDispatcher().register(root);
event.getDispatcher().register(Commands.literal("aero") event.getDispatcher().register(Commands.literal("aero")
.requires(ConfigCommands::mayUse) .executes(context -> mayUse(context.getSource()) ? list(context.getSource()) : denied(context.getSource()))
.executes(context -> list(context.getSource()))
.redirect(node)); .redirect(node));
} }
private static int denied(CommandSourceStack source) {
source.sendFailure(Component.literal("Use /aero controls keyboard or /aero controls cursor_aim"));
return 0;
}
/** /aero controls [keyboard|cursor_aim], for every player (permission level 0). */
private static LiteralArgumentBuilder<CommandSourceStack> controlsNode() {
return Commands.literal("controls")
.requires(source -> source.getEntity() instanceof ServerPlayer)
.executes(context -> {
ServerPlayer player = context.getSource().getPlayerOrException();
boolean cursorAim = ControlMode.isCursorAim(player);
context.getSource().sendSuccess(() -> Component.literal("Controls: " + (cursorAim ? "cursor_aim" : "keyboard")), false);
return 1;
})
.then(Commands.literal("keyboard").executes(context -> setControls(context.getSource(), false)))
.then(Commands.literal("cursor_aim").executes(context -> setControls(context.getSource(), true)));
}
private static int setControls(CommandSourceStack source, boolean cursorAim) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayer player = source.getPlayerOrException();
ControlMode.set(player, cursorAim);
source.sendSuccess(() -> Component.literal("Controls set to " + (cursorAim ? "cursor_aim" : "keyboard")), false);
return 1;
}
private static <T> LiteralArgumentBuilder<CommandSourceStack> optionNode(Option<T> option) { private static <T> LiteralArgumentBuilder<CommandSourceStack> optionNode(Option<T> option) {
return Commands.literal(option.name()) return Commands.literal(option.name())
.executes(context -> { .executes(context -> {
@@ -1,7 +1,9 @@
package dev.lumareth.improvedAerodynamics; package dev.lumareth.improvedAerodynamics;
import com.mojang.logging.LogUtils; import com.mojang.logging.LogUtils;
import dev.lumareth.improvedAerodynamics.client.aim.CursorAimClientConfig;
import dev.lumareth.improvedAerodynamics.client.pipper.PipperConfig; import dev.lumareth.improvedAerodynamics.client.pipper.PipperConfig;
import dev.lumareth.improvedAerodynamics.flight.CursorAimConfig;
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,8 +23,11 @@ public class ImprovedAerodynamics {
MinecraftForge.EVENT_BUS.register(LookaheadLoader.class); MinecraftForge.EVENT_BUS.register(LookaheadLoader.class);
MinecraftForge.EVENT_BUS.register(PhysicsBridge.class); MinecraftForge.EVENT_BUS.register(PhysicsBridge.class);
MinecraftForge.EVENT_BUS.register(ConfigCommands.class); MinecraftForge.EVENT_BUS.register(ConfigCommands.class);
MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.ControlMode.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); 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(); Network.register();
} }
} }
@@ -0,0 +1,31 @@
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, which is switched on with /aero controls 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 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(FREE_LOOK);
}
}
}
@@ -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;
}
}
@@ -0,0 +1,451 @@
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:
* <ul>
* <li>the mouse moves a cursor instead of the camera. The cursor is a direction relative to the gun reticle (the
* craft's boresight), at most {@code maxCursorAngleDegrees} away from it, and springs back to it when the mouse
* rests. It is sent to the server every tick, where a virtual pilot flies the craft by it;</li>
* <li>the camera is a chase view, looking along the reticle, or, with look ahead, towards the cursor;</li>
* <li>holding the free look key moves the camera instead of the cursor, it eases back when released;</li>
* <li>the mouse wheel changes how far the camera is behind the craft.</li>
* </ul>
* 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;
// Chosen with /aero controls, told by the server
private static boolean cursorAimMode;
// 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;
if (!active) {
if (cursorAimMode && player != null && seated(player) && serverHasMod(minecraft)) {
activate(minecraft, player);
}
return;
}
if (!cursorAimMode) {
deactivate(minecraft);
return;
}
if (player == null || !seated(player)) {
deactivate(minecraft);
return;
}
send(minecraft, true);
}
/** Called when the server tells the client how its player flies. */
public static void setMode(boolean cursorAim) {
cursorAimMode = cursorAim;
}
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);
}
}
@@ -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() {
}
}
@@ -218,7 +218,7 @@ public final class GunPipperRenderer {
return true; 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; final int segments = 32;
float inner = radius - thickness; float inner = radius - thickness;
float outer = 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); 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);
@@ -19,7 +19,7 @@ import java.util.concurrent.ConcurrentHashMap;
* to; at the end of the tick that set is handed to the physics thread, which computes the forces from the ship's * to; at the end of the tick that set is handed to the physics thread, which computes the forces from the ship's
* motion in each substep until the next tick arrives. * motion in each substep until the next tick arrives.
* <p> * <p>
* The physics side also smooths pilot input, runs auto trim, and integrates damping implicitly so stiff damping * The physics side also smooths pilot input, runs the flight computer (auto trim), and integrates damping implicitly so stiff damping
* cannot overshoot. * cannot overshoot.
*/ */
public final class AeroState implements PhysicsBridge.Publishable { public final class AeroState implements PhysicsBridge.Publishable {
@@ -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 INPUT_RATE_PER_TICK = 0.2;
private static final double VELOCITY_EPSILON = 0.05; private static final double VELOCITY_EPSILON = 0.05;
private static final double ANGULAR_EPSILON = 0.005; private static final double ANGULAR_EPSILON = 0.005;
private static final ControlAxis[] MEASURED_AXES = {ControlAxis.PITCH, ControlAxis.YAW, ControlAxis.ROLL};
private final SubstepClock clock; private final SubstepClock clock;
@@ -54,19 +55,35 @@ public final class AeroState implements PhysicsBridge.Publishable {
double roll; double roll;
double trimIntegral; double trimIntegral;
double trimOutput; double trimOutput;
// Total pitch input the flight computer applied last substep, pilot and trim together
double computerOutput;
// Torque (ship axes) one unit of pitch input on this node's pitch surfaces makes, measured every substep // 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 pitchResponse = new Vector3d();
final Vector3d pitchResponseBuilding = 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(); final AutoTrim.Targets trimTargets = new AutoTrim.Targets();
volatile double savedTrim; volatile double savedTrim;
Vector3d responseBuilding(ControlAxis axis) {
return switch (axis) {
case YAW -> yawResponseBuilding;
case ROLL -> rollResponseBuilding;
default -> pitchResponseBuilding;
};
}
NodeState(NodeData data) { NodeState(NodeData data) {
pitch = data.pitch(); pitch = data.pitch();
yaw = data.yaw(); yaw = data.yaw();
roll = data.roll(); roll = data.roll();
trimIntegral = data.autoTrim() ? data.storedTrim() : 0.0; trimIntegral = data.autoTrim() ? data.storedTrim() : 0.0;
trimOutput = trimIntegral; trimOutput = data.autoTrim() ? 0.0 : trimIntegral;
savedTrim = trimIntegral; savedTrim = trimIntegral;
computerOutput = pitch + trimOutput;
} }
} }
@@ -211,6 +228,8 @@ public final class AeroState implements PhysicsBridge.Publishable {
wingStalled = false; wingStalled = false;
for (NodeState state : nodeStates.values()) { for (NodeState state : nodeStates.values()) {
state.pitchResponseBuilding.zero(); state.pitchResponseBuilding.zero();
state.yawResponseBuilding.zero();
state.rollResponseBuilding.zero();
} }
} }
@@ -221,7 +240,7 @@ public final class AeroState implements PhysicsBridge.Publishable {
r[2] = s.z() - centerOfMass.z(); r[2] = s.z() - centerOfMass.z();
NodeData node = s.node() != AeroSurface.NO_NODE ? frame.nodes.get(s.node()) : null; NodeData node = s.node() != AeroSurface.NO_NODE ? frame.nodes.get(s.node()) : null;
NodeState state = node != null ? nodeStates.get(node.key()) : 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) { if (improved) {
boolean nowStalled = ImprovedAero.evaluate(s, v, w, r, input, weight, tuning, stalled[i], out); boolean nowStalled = ImprovedAero.evaluate(s, v, w, r, input, weight, tuning, stalled[i], out);
@@ -233,11 +252,16 @@ public final class AeroState implements PhysicsBridge.Publishable {
WariumAero.evaluate(s, v, w, r, input, weight, out); 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 // How much a little more input on an axis would change the torque: pitch for auto trim, all three for
if (commit && node != null && node.autoTrim() && s.type().controllable && s.listensToPitch()) { // 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; double delta = 0.05;
probe.clear(); probe.clear();
double base = 0.0;
if (improved) { if (improved) {
ImprovedAero.evaluate(s, v, w, r, input, weight, tuning, stalled[i], probe); ImprovedAero.evaluate(s, v, w, r, input, weight, tuning, stalled[i], probe);
} else { } else {
@@ -245,41 +269,45 @@ public final class AeroState implements PhysicsBridge.Publishable {
} }
double[] before = {probe.totalTorque(0), probe.totalTorque(1), probe.totalTorque(2)}; double[] before = {probe.totalTorque(0), probe.totalTorque(1), probe.totalTorque(2)};
probe.clear(); probe.clear();
double nudged = input(s, node, state, delta); double nudged = input(s, node, state, axis, delta);
if (improved) { if (improved) {
ImprovedAero.evaluate(s, v, w, r, nudged, weight, tuning, stalled[i], probe); ImprovedAero.evaluate(s, v, w, r, nudged, weight, tuning, stalled[i], probe);
} else { } else {
WariumAero.evaluate(s, v, w, r, nudged, weight, probe); WariumAero.evaluate(s, v, w, r, nudged, weight, probe);
} }
state.pitchResponseBuilding.add( state.responseBuilding(axis).add(
(probe.totalTorque(0) - before[0]) / delta, (probe.totalTorque(0) - before[0]) / delta,
(probe.totalTorque(1) - before[1]) / delta, (probe.totalTorque(1) - before[1]) / delta,
(probe.totalTorque(2) - before[2]) / delta); (probe.totalTorque(2) - before[2]) / delta);
} }
} }
}
if (commit) { if (commit) {
for (NodeState state : nodeStates.values()) { for (NodeState state : nodeStates.values()) {
state.pitchResponse.set(state.pitchResponseBuilding); 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. */ /** 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) { if (node == null || state == null) {
return 0.0; return 0.0;
} }
double value = axisValue(s.axis1(), node, state, extraPitch) * s.multiplier1() double value = axisValue(s.axis1(), node, state, nudged, extra) * s.multiplier1()
+ axisValue(s.axis2(), node, state, extraPitch) * s.multiplier2(); + axisValue(s.axis2(), node, state, nudged, extra) * s.multiplier2();
return Math.max(-1.0, Math.min(1.0, value)); 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) { return switch (axis) {
case PITCH -> state.pitch + state.trimOutput + extraPitch; case PITCH -> state.pitch + state.trimOutput + nudge;
case YAW -> state.yaw; case YAW -> state.yaw + nudge;
case ROLL -> state.roll; case ROLL -> state.roll + nudge;
case THROTTLE -> node.throttle(); case THROTTLE -> node.throttle();
case NONE -> 0.0; case NONE -> 0.0;
}; };
@@ -288,15 +316,19 @@ public final class AeroState implements PhysicsBridge.Publishable {
private void updateNodes(Frame frame, Vector3dc worldVelocity, double altitude, Quaterniondc rotation, Matrix3dc inertia, private void updateNodes(Frame frame, Vector3dc worldVelocity, double altitude, Quaterniondc rotation, Matrix3dc inertia,
double[] w, double dt, boolean improved) { double[] w, double dt, boolean improved) {
double step = Config.CONTROL_SMOOTHING.get() ? INPUT_RATE_PER_TICK * clock.weight() : Double.POSITIVE_INFINITY; double step = Config.CONTROL_SMOOTHING.get() ? INPUT_RATE_PER_TICK * clock.weight() : Double.POSITIVE_INFINITY;
// Torque of the last substep, in ship axes, which the flight computer separates from its own surfaces' share
double[] torque = {wrench.totalTorque(0), wrench.totalTorque(1), wrench.totalTorque(2)};
for (NodeData node : frame.nodes.values()) { for (NodeData node : frame.nodes.values()) {
NodeState state = nodeStates.get(node.key()); NodeState state = nodeStates.get(node.key());
if (state == null) { if (state == null) {
continue; continue;
} }
state.pitch = moveTowards(state.pitch, node.pitch(), step); // Cursor aim, if the pilot has it on, supplies the inputs the pilot is not giving with the keys
state.yaw = moveTowards(state.yaw, node.yaw(), step); NodeData effective = CursorPilot.apply(state, node, rotation, inertia, w, worldVelocity.length(), wingStalled);
state.roll = moveTowards(state.roll, node.roll(), step); state.pitch = moveTowards(state.pitch, effective.pitch(), step);
AutoTrim.update(state, node, worldVelocity, altitude, rotation, inertia, w, dt, improved && wingStalled); 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, torque, dt, improved && wingStalled);
} }
} }
@@ -36,6 +36,10 @@ public record AeroSurface(long key, SurfaceType type, boolean slab, double x, do
} }
boolean listensToPitch() { 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);
} }
} }
@@ -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};
}
}
@@ -7,19 +7,28 @@ import org.joml.Vector3d;
import org.joml.Vector3dc; import org.joml.Vector3dc;
/** /**
* Auto trim for the pitch control surfaces of one control node: while the pilot gives no pitch input it holds the * The flight computer of one control node, switched on and off with the auto trim key. It is a fly-by-wire pitch
* altitude the craft had when the pilot let go, cancelling whatever pitching moment the craft has (off-center weight, * system: the pilot's pitch input is a pitch rate command, not a surface deflection, and the computer moves the
* thrust line, speed changes). * surfaces to make the craft follow it, whatever the airframe does on its own.
* <p> * <p>
* Three nested loops: altitude error sets a gentle climb rate, climb rate error sets a nose attitude (never more than * Every physics substep it measures the pitching torque the craft makes with the pitch surfaces taken out
* {@link #MAX_ATTITUDE} from level), attitude error sets a pitch rate, and a PI controller moves the trim to reach * ({@code external}: weight offset, thrust line, and above all the airframe's own stability or instability), and the
* that rate. Bounding the attitude matters: in Warium's physics the flight path follows the nose only slowly, so * torque one unit of pitch input makes ({@code response}). It then picks the input that makes the craft's total
* steering the flight path directly with pitch rate keeps rotating the nose until the craft dives. * pitch torque equal the angular acceleration it wants, {@code rateGain * (rate command - pitch rate)} plus a small
* integral for what the model gets wrong. That cancels a craft that is statically unstable, or trimmed badly, as
* fast as the surfaces can move, so a jet built with relaxed stability can be flown like a stable one.
* <p> * <p>
* Attitude is measured from the ship's own up axis, so it assumes the craft is built level, as Warium craft are. The * Without pilot input the rate command comes from three nested loops that hold the altitude the craft had when the
* trim's effect is measured every substep (torque per unit of pitch input), which gives the pitch axis and lets the * pilot let go: altitude error sets a gentle climb rate, climb rate error sets a nose attitude (never more than
* inner gains be expressed as angular acceleration, so the same tuning works for any craft, speed and altitude. * {@link #MAX_ATTITUDE} from level), attitude error sets a pitch rate. Bounding the attitude matters: in Warium's
* The trim holds still while the pilot pitches, when too slow, and when wings stall, so it never fights maneuvers. * physics the flight path follows the nose only slowly, so steering the flight path directly with pitch rate keeps
* rotating the nose until the craft dives.
* <p>
* Attitude is measured from the ship's own up axis, so it assumes the craft is built level, as Warium craft are.
* Torques are measured per unit of input, so the same tuning works for any craft, speed and altitude. Below the
* minimum speed the computer holds its output, the surfaces have no authority there anyway. Stalled wings cut the
* pilot's rate command and the altitude hold, so the computer only keeps the nose where it is. While cursor aim flies
* the node, that is in charge instead.
*/ */
final class AutoTrim { final class AutoTrim {
// Climb rate (blocks/s) per block of altitude error, and its limit // Climb rate (blocks/s) per block of altitude error, and its limit
@@ -32,12 +41,16 @@ final class AutoTrim {
// Pitch rate (rad/s) per radian of attitude error, and its limit // Pitch rate (rad/s) per radian of attitude error, and its limit
private static final double ATTITUDE_GAIN = 1.0; private static final double ATTITUDE_GAIN = 1.0;
private static final double MAX_PITCH_RATE = 0.15; private static final double MAX_PITCH_RATE = 0.15;
// Angular acceleration per rad/s of pitch rate error, and integral gain // Slow trim movement when the computer is switched off
private static final double PROPORTIONAL_GAIN = 3.0;
private static final double INTEGRAL_GAIN = 1.5;
// Fastest the trim moves, in input per second
private static final double TRIM_RATE = 1.0; private static final double TRIM_RATE = 1.0;
private static final double PILOT_DEADBAND = 0.01; private static final double PILOT_DEADBAND = 0.01;
// The computer assumes the surfaces do this much of what was measured, so a response measured too high (stalled
// surfaces, clamped inputs) makes it command a little too much rather than too little, which stays stable
private static final double RESPONSE_MARGIN = 0.7;
// Pitch rate command while stalled, as a fraction of what the pilot asked
private static final double STALLED_COMMAND = 0.3;
// The integral may make up for this much rate error worth of acceleration
private static final double MAX_ERROR_FOR_INTEGRAL = 0.5;
private AutoTrim() { private AutoTrim() {
} }
@@ -49,29 +62,29 @@ final class AutoTrim {
} }
static void update(AeroState.NodeState state, NodeData node, Vector3dc worldVelocity, double altitude, static void update(AeroState.NodeState state, NodeData node, Vector3dc worldVelocity, double altitude,
Quaterniondc rotation, Matrix3dc inertia, double[] w, double dt, boolean stalled) { Quaterniondc rotation, Matrix3dc inertia, double[] w, double[] torque, double dt, boolean stalled) {
Targets targets = state.trimTargets; Targets targets = state.trimTargets;
boolean aimed = CursorAimRegistry.isActive(node.key());
if (!node.autoTrim()) { if (!node.autoTrim()) {
state.trimIntegral = 0.0; state.trimIntegral = 0.0;
state.trimOutput = AeroState.moveTowards(state.trimOutput, 0.0, TRIM_RATE * dt); state.trimOutput = AeroState.moveTowards(state.trimOutput, 0.0, TRIM_RATE * dt);
state.savedTrim = 0.0; state.savedTrim = 0.0;
state.computerOutput = state.pitch + state.trimOutput;
targets.altitude = Double.NaN; targets.altitude = Double.NaN;
targets.attitudeIntegral = 0.0; targets.attitudeIntegral = 0.0;
return; return;
} }
double authority = Config.TRIM_AUTHORITY.get();
double response = state.pitchResponse.length(); double response = state.pitchResponse.length();
double speed = worldVelocity.length(); double speed = worldVelocity.length();
boolean hold = Math.abs(node.pitch()) > PILOT_DEADBAND || speed < Config.TRIM_MIN_SPEED.get() || stalled if (aimed || speed < Config.TRIM_MIN_SPEED.get() || response < 1.0E-6) {
|| response < 1.0E-6; // Cursor aim, no airflow or no pitch surfaces: the computer holds what it has
if (hold || Double.isNaN(targets.altitude)) { targets.altitude = Double.NaN;
// Hold whatever altitude the craft is at once the pilot lets go
targets.altitude = altitude;
targets.attitudeIntegral = 0.0; targets.attitudeIntegral = 0.0;
state.computerOutput = state.pitch + state.trimOutput;
state.savedTrim = state.trimIntegral;
return;
} }
double target = state.trimIntegral;
if (!hold) {
// Pitch axis in ship axes, oriented so that positive rotation is what more pitch input does // Pitch axis in ship axes, oriented so that positive rotation is what more pitch input does
Vector3d axis = state.pitchResponse.div(response, new Vector3d()); Vector3d axis = state.pitchResponse.div(response, new Vector3d());
double axisInertia = axis.dot(inertia.transform(axis, new Vector3d())); double axisInertia = axis.dot(inertia.transform(axis, new Vector3d()));
@@ -82,24 +95,50 @@ final class AutoTrim {
Vector3d direction = worldVelocity.normalize(new Vector3d()); Vector3d direction = worldVelocity.normalize(new Vector3d());
double noseUp = Math.signum(worldAxis.cross(direction, new Vector3d()).y); double noseUp = Math.signum(worldAxis.cross(direction, new Vector3d()).y);
double attitude = attitude(axis, rotation); double attitude = attitude(axis, rotation);
if (noseUp == 0.0 || Double.isNaN(attitude)) {
return;
}
double pilot = node.pitch();
boolean pilotActive = Math.abs(pilot) > PILOT_DEADBAND;
double rateCommand;
if (pilotActive || stalled || noseUp == 0.0 || Double.isNaN(attitude)) {
// Hold whatever altitude the craft is at once the pilot lets go
targets.altitude = altitude;
targets.attitudeIntegral = 0.0;
rateCommand = pilotActive
? pilot * Math.toRadians(Config.FBW_STICK_RATE_DEGREES.get()) * (stalled ? STALLED_COMMAND : 1.0)
: 0.0;
} else {
if (Double.isNaN(targets.altitude)) {
targets.altitude = altitude;
}
double climbTarget = clamp(ALTITUDE_GAIN * (targets.altitude - altitude), MAX_CLIMB_RATE); double climbTarget = clamp(ALTITUDE_GAIN * (targets.altitude - altitude), MAX_CLIMB_RATE);
double climbError = climbTarget - worldVelocity.y(); double climbError = climbTarget - worldVelocity.y();
targets.attitudeIntegral = clamp(targets.attitudeIntegral + CLIMB_INTEGRAL_GAIN * climbError * dt, MAX_ATTITUDE); targets.attitudeIntegral = clamp(targets.attitudeIntegral + CLIMB_INTEGRAL_GAIN * climbError * dt, MAX_ATTITUDE);
// Nose elevation wanted, turned into rotation about the axis // Nose elevation wanted, turned into rotation about the axis
double attitudeTarget = noseUp * clamp(targets.attitudeIntegral + CLIMB_GAIN * climbError, MAX_ATTITUDE); double attitudeTarget = noseUp * clamp(targets.attitudeIntegral + CLIMB_GAIN * climbError, MAX_ATTITUDE);
double rateTarget = clamp(ATTITUDE_GAIN * (attitudeTarget - attitude), MAX_PITCH_RATE); rateCommand = clamp(ATTITUDE_GAIN * (attitudeTarget - attitude), MAX_PITCH_RATE);
double error = rateTarget - pitchRate;
// Pitch input needed per rad/s² of angular acceleration
double gain = axisInertia / response;
state.trimIntegral = clamp(state.trimIntegral + INTEGRAL_GAIN * error * dt * gain, authority);
target = clamp(state.trimIntegral + PROPORTIONAL_GAIN * error * gain, authority);
} }
state.trimOutput = AeroState.moveTowards(state.trimOutput, target, TRIM_RATE * dt);
// Pitching torque about the axis the pitch surfaces are not making: total of the last substep minus what the
// input of that substep made
double total = axis.x * torque[0] + axis.y * torque[1] + axis.z * torque[2];
double external = total - response * state.computerOutput;
double authority = Config.FBW_AUTHORITY.get();
double rateError = rateCommand - pitchRate;
double acceleration = Config.FBW_RATE_GAIN.get() * rateError + state.trimIntegral;
double wanted = (axisInertia * acceleration - external) / (response * RESPONSE_MARGIN);
boolean saturated = Math.abs(wanted) > authority;
wanted = clamp(wanted, authority);
if (!saturated) {
// Integral of the acceleration, bounded so it cannot run away if the model is far off
double limit = Config.FBW_RATE_GAIN.get() * MAX_ERROR_FOR_INTEGRAL;
state.trimIntegral = clamp(state.trimIntegral + Config.FBW_INTEGRAL_GAIN.get() * rateError * dt, limit);
}
double applied = AeroState.moveTowards(state.computerOutput, wanted, Config.FBW_ACTUATOR_RATE.get() * dt);
state.computerOutput = applied;
// The surfaces get the pilot's smoothed input plus the trim, so the trim is what makes up the difference
state.trimOutput = applied - state.pitch;
state.savedTrim = state.trimIntegral; state.savedTrim = state.trimIntegral;
} }
@@ -0,0 +1,45 @@
package dev.lumareth.improvedAerodynamics.flight;
import dev.lumareth.improvedAerodynamics.network.ControlModePacket;
import dev.lumareth.improvedAerodynamics.network.Network;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.network.PacketDistributor;
/**
* How a player flies: with the keyboard (Warium's own seat controls) or with cursor aim. Chosen per player with
* {@code /aero controls}, kept in the player's persistent data and told to their client, which does the rest.
*/
public final class ControlMode {
private static final String KEY = "improved_aerodynamics_cursor_aim";
private ControlMode() {
}
public static boolean isCursorAim(ServerPlayer player) {
CompoundTag persisted = player.getPersistentData().getCompound(Player.PERSISTED_NBT_TAG);
return persisted.getBoolean(KEY);
}
public static void set(ServerPlayer player, boolean cursorAim) {
CompoundTag data = player.getPersistentData();
CompoundTag persisted = data.getCompound(Player.PERSISTED_NBT_TAG);
persisted.putBoolean(KEY, cursorAim);
data.put(Player.PERSISTED_NBT_TAG, persisted);
sync(player);
}
public static void sync(ServerPlayer player) {
Network.CHANNEL.send(PacketDistributor.PLAYER.with(() -> player), new ControlModePacket(isCursorAim(player)));
}
@SubscribeEvent
public static void onLogin(PlayerEvent.PlayerLoggedInEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
sync(player);
}
}
}
@@ -0,0 +1,56 @@
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 DEADZONE_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);
DEADZONE_DEGREES = builder.comment("Radius around the gun reticle in degrees inside which the cursor commands nothing, so the craft flies straight.",
"Outside it the command grows from zero, there is no jump at the edge")
.defineInRange("deadzoneDegrees", 3.0, 0.0, 15.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() {
}
}
@@ -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<Long, Aim> 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;
}
}
@@ -0,0 +1,34 @@
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) {
// Only a player who chose cursor aim may set or clear the cursor of a node
if (!ControlMode.isCursorAim(player)) {
return;
}
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()));
}
}
@@ -0,0 +1,144 @@
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:
* <ul>
* <li>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;</li>
* <li>up and down it commands a pitch rate;</li>
* <li>close to the reticle it steers with yaw and pitch directly instead of banking, so aiming does not rock the
* wings.</li>
* </ul>
* 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()));
// Deadzone around the reticle: shrink the cursor offset by its radius, direction kept
double offset = Math.hypot(sideways, vertical);
double deadzone = Math.toRadians(CursorAimConfig.DEADZONE_DEGREES.get());
if (offset > 1.0E-9) {
double scale = Math.max(0.0, offset - deadzone) / offset;
sideways *= scale;
vertical *= scale;
}
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));
}
}
@@ -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<NetworkEvent.Context> context) {
ServerPlayer player = context.get().getSender();
if (player != null && ModList.get().isLoaded("valkyrien_warium")) {
CursorAimServer.accept(player, active, forward, right, up);
}
}
}
@@ -0,0 +1,32 @@
package dev.lumareth.improvedAerodynamics.network;
import dev.lumareth.improvedAerodynamics.client.aim.CursorAimClient;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.fml.DistExecutor;
import net.minecraftforge.network.NetworkEvent;
import java.util.function.Supplier;
/** Tells a client whether its player flies with cursor aim, see {@code /aero controls}. */
public final class ControlModePacket {
private final boolean cursorAim;
public ControlModePacket(boolean cursorAim) {
this.cursorAim = cursorAim;
}
public void encode(FriendlyByteBuf buffer) {
buffer.writeBoolean(cursorAim);
}
public static ControlModePacket decode(FriendlyByteBuf buffer) {
return new ControlModePacket(buffer.readBoolean());
}
public void handle(Supplier<NetworkEvent.Context> context) {
boolean value = cursorAim;
DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> () -> CursorAimClient.setMode(value));
context.get().setPacketHandled(true);
}
}
@@ -23,5 +23,16 @@ public final class Network {
.decoder(ToggleAutoTrimPacket::decode) .decoder(ToggleAutoTrimPacket::decode)
.consumerMainThread(ToggleAutoTrimPacket::handle) .consumerMainThread(ToggleAutoTrimPacket::handle)
.add(); .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();
CHANNEL.messageBuilder(ControlModePacket.class, 51, NetworkDirection.PLAY_TO_CLIENT)
.encoder(ControlModePacket::encode)
.decoder(ControlModePacket::decode)
.consumerMainThread(ControlModePacket::handle)
.add();
} }
} }
@@ -1,8 +1,8 @@
{ {
"key.categories.improved_aerodynamics": "Improved Aerodynamics", "key.categories.improved_aerodynamics": "Improved Aerodynamics",
"key.improved_aerodynamics.toggle_auto_trim": "Automatische Trimmung umschalten", "key.improved_aerodynamics.toggle_auto_trim": "Automatische Trimmung umschalten",
"message.improved_aerodynamics.auto_trim.on": "Automatische Trimmung an", "message.improved_aerodynamics.auto_trim.on": "Flugcomputer an (automatische Trimmung)",
"message.improved_aerodynamics.auto_trim.off": "Automatische Trimmung aus", "message.improved_aerodynamics.auto_trim.off": "Flugcomputer aus (automatische Trimmung)",
"message.improved_aerodynamics.auto_trim.unavailable": "Automatische Trimmung an, sie braucht aber physicsRateAerodynamics in der Serverkonfiguration", "message.improved_aerodynamics.auto_trim.unavailable": "Automatische Trimmung an, sie braucht aber physicsRateAerodynamics in der Serverkonfiguration",
"message.improved_aerodynamics.auto_trim.no_seat": "Setz dich in einen Warium-Steuersitz, um die automatische Trimmung zu nutzen", "message.improved_aerodynamics.auto_trim.no_seat": "Setz dich in einen Warium-Steuersitz, um die automatische Trimmung zu nutzen",
"message.improved_aerodynamics.auto_trim.no_node": "Dieser Sitz ist mit keinem Vehicle Control Node verbunden", "message.improved_aerodynamics.auto_trim.no_node": "Dieser Sitz ist mit keinem Vehicle Control Node verbunden",
@@ -12,5 +12,10 @@
"config.improved_aerodynamics.supersonicDrag": "Multiplikator für den Nullauftriebswiderstand bei vollem Überschall, ab Mach 1,3", "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.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.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.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"
} }
@@ -1,8 +1,8 @@
{ {
"key.categories.improved_aerodynamics": "Improved Aerodynamics", "key.categories.improved_aerodynamics": "Improved Aerodynamics",
"key.improved_aerodynamics.toggle_auto_trim": "Toggle auto trim", "key.improved_aerodynamics.toggle_auto_trim": "Toggle auto trim",
"message.improved_aerodynamics.auto_trim.on": "Auto trim on", "message.improved_aerodynamics.auto_trim.on": "Flight computer on (auto trim)",
"message.improved_aerodynamics.auto_trim.off": "Auto trim off", "message.improved_aerodynamics.auto_trim.off": "Flight computer off (auto trim)",
"message.improved_aerodynamics.auto_trim.unavailable": "Auto trim on, but it needs physicsRateAerodynamics enabled in the server config", "message.improved_aerodynamics.auto_trim.unavailable": "Auto trim on, but it needs physicsRateAerodynamics enabled in the server config",
"message.improved_aerodynamics.auto_trim.no_seat": "Sit in a Warium control seat to use auto trim", "message.improved_aerodynamics.auto_trim.no_seat": "Sit in a Warium control seat to use auto trim",
"message.improved_aerodynamics.auto_trim.no_node": "This seat is not linked to a vehicle control node", "message.improved_aerodynamics.auto_trim.no_node": "This seat is not linked to a vehicle control node",
@@ -12,5 +12,10 @@
"config.improved_aerodynamics.supersonicDrag": "Zero-lift drag multiplier once fully supersonic, from Mach 1.3", "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.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.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.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"
} }