Author SHA1 Message Date
richard 80bb63d44f Add LiveInputs so nozzles and Futurism afterburners follow cursor aim
- Cursor aim reverses below the nose and eases off in stalls, with pitch and yaw signs learned from the measured responses
- Cursor aim takes over at low speed and flies the surfaces directly while taxiing
- Nozzles and Futurism vectored afterburners follow the live flight inputs instead of the raw keys
- Warframes vertical surfaces and Futurism vectored afterburners are animated from their real deflection
2026-11-13 12:29:23 +01:00
richard 3478a04885 Rework cursor aim: cursor is world-fixed, pilot turns until reticle meets it
- Move cursor state to a world direction so the offset updates as the craft turns, not on mouse input alone
- Move cursor aim tunables from separate TOML files into Config, under cursor_aim sections
- Pilot banks by a level-wings cursor angle and pulls a level-turn rate, capped by live lift and measured control rate
- Measure each cursor-flown node's steady pitch rate from control torque over pitch damping
- Accumulate maximum stall-limited lift per tick for the pilot's turn-rate and bank limits
- Bump mod version to 0.4.2
2026-11-11 19:54:24 +01:00
Claude 23f0f91ede Animate control surfaces and flaps from the real deflection
A GeckoLib GeoModel mixin sets the Fin bone of Warium's control surfaces to the
deflection the flight physics uses, synced to nearby clients per ship.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 18:44:58 +01:00
23 changed files with 984 additions and 186 deletions

No files matched your search

+4
View File
@@ -84,6 +84,10 @@ dependencies {
compileOnly fg.deobf("blank:WariumVS:1.1.7")
compileOnly fg.deobf("blank:futurism:0.0.4")
// GeckoLib 4.8.4, which Warium VS animates its control surfaces with (optional dependency, remapped, the surface animation mixin
// only applies when it and Warium VS are present)
compileOnly fg.deobf("maven.modrinth:geckolib:aC5KMoNg")
// AzureLib 3.2.7 for the animated block models (optional dependency, the common jar uses obfuscated names, so it is remapped)
compileOnly fg.deobf("mod.azure.azurelib:azurelib-common-${minecraft_version}:${azurelib_version}")
}
+1 -1
View File
@@ -12,7 +12,7 @@ mapping_version=2023.09.03-1.20.1
mod_id=improved_aerodynamics
mod_name=VS: Improved Aerodynamics
mod_license=MIT
mod_version=0.4.0
mod_version=0.4.3
azurelib_version=3.2.7
mod_group_id=dev.lumareth.improvedaerodynamics
mod_authors=Lumareth
@@ -204,6 +204,52 @@ public final class Config {
.comment("The nozzle gives off light while its afterburner is lit")
.define("lightEnabled", true);
static {
BUILDER.pop();
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");
}
public static final ForgeConfigSpec.BooleanValue CURSOR_AIM_ENABLED = BUILDER.comment("Whether pilots may use cursor aim on this server")
.define("enabled", true);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_FULL_DEFLECTION_DEGREES = BUILDER.comment("Angle of the cursor from the gun reticle at which the virtual pilot turns as hard as it can: the maximum bank",
"sideways, the full pitch rate up or down. Closer than that it turns more gently, so the reticle settles onto",
"the cursor. How fast the craft can turn is worked out from the craft itself")
.defineInRange("fullDeflectionDegrees", 30.0, 5.0, 90.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_DEADZONE_DEGREES = BUILDER.comment("Radius around the gun reticle in degrees inside which the pilot stops steering towards the cursor. The reticle",
"then settles this far from the cursor. Outside it the command grows from zero, there is no jump at the edge")
.defineInRange("deadzoneDegrees", 0.0, 0.0, 15.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_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);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MAX_BANK_DEGREES = BUILDER.comment("Bank angle the pilot rolls to when the cursor is at full sideways deflection, at most. Craft whose wings",
"cannot hold that bank in a level turn get less")
.defineInRange("maxBankDegrees", 70.0, 10.0, 90.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_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);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_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);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_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);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_REVERSE_TURN_DEGREES = BUILDER.comment("How far below the nose (wings level) the cursor has to be before the pilot rolls over and pulls towards it",
"instead of pushing the nose down. Less than that is flown with the elevators alone, like for a landing")
.defineInRange("reverseTurnDegrees", 40.0, 10.0, 135.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_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);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MIN_SPEED = BUILDER.comment("Below this speed in blocks per second the pilot does not fly the craft: the cursor moves the elevators and",
"rudder directly instead, so they follow it while taxiing and rotating for takeoff")
.defineInRange("minSpeed", 15.0, 0.0, 200.0);
static {
BUILDER.pop();
}
@@ -220,6 +266,45 @@ public final class Config {
.comment("Vectored afterburner nozzle: afterburner ignition and heavy afterburner sound")
.define("soundEnabled", true);
public static final ForgeConfigSpec.BooleanValue ANIMATE_SURFACES = CLIENT_BUILDER
.comment("Control surfaces and flaps visibly deflect by as much as the flight physics deflects them, instead of Warium's",
"fixed 20 degree steps. Needs physicsRateAerodynamics on the server")
.define("animateControlSurfaces", true);
static {
CLIENT_BUILDER.comment("Cursor aim, client side").push("cursor_aim");
}
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_LOOK_AHEAD = CLIENT_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);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MOUSE_SENSITIVITY = CLIENT_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);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_SPRING_BACK_SECONDS = CLIENT_BUILDER.comment("The cursor drifts to the gun reticle with this time constant in seconds, which ends the turn by itself.",
"0, the default, keeps it where you put it in the world, so the craft turns until the reticle is on it")
.defineInRange("springBackSeconds", 0.0, 0.0, 10.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MAX_ANGLE_DEGREES = CLIENT_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);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_ZOOM_STEP = CLIENT_BUILDER.comment("Blocks the camera moves per mouse wheel notch")
.defineInRange("zoomStep", 2.0, 0.1, 20.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_ZOOM_MAX = CLIENT_BUILDER.comment("Furthest the camera can be pulled back from its normal distance, in blocks")
.defineInRange("zoomMax", 80.0, 0.0, 500.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MARKER_SIZE = CLIENT_BUILDER.comment("Radius of the cursor ring on screen")
.defineInRange("markerSize", 0.04, 0.01, 0.3);
public static final ForgeConfigSpec.BooleanValue CURSOR_AIM_SHOW_RETICLE = CLIENT_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);
static {
CLIENT_BUILDER.pop();
}
static final ForgeConfigSpec CLIENT_SPEC = CLIENT_BUILDER.build();
private Config() {
@@ -1,11 +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.exhaust.ExhaustSync;
import dev.lumareth.improvedAerodynamics.exhaust.NozzleContent;
import dev.lumareth.improvedAerodynamics.flight.CursorAimConfig;
import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge;
import dev.lumareth.improvedAerodynamics.network.Network;
import dev.lumareth.improvedAerodynamics.terrain.LookaheadLoader;
@@ -29,12 +27,11 @@ public class ImprovedAerodynamics {
MinecraftForge.EVENT_BUS.register(ExhaustSync.class);
MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.ControlMode.class);
MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.FlapInteraction.class);
MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.DeflectionSync.class);
ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC);
ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, Config.CLIENT_SPEC);
// Own file name: the default one is taken by Config.CLIENT_SPEC
ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, PipperConfig.SPEC, "improved_aerodynamics-pipper-client.toml");
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");
NozzleContent.register(FMLJavaModLoadingContext.get().getModEventBus());
Network.register();
}
@@ -1,5 +1,6 @@
package dev.lumareth.improvedAerodynamics.client.aim;
import dev.lumareth.improvedAerodynamics.Config;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.BufferUploader;
@@ -32,6 +33,7 @@ import net.minecraftforge.fml.ModList;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.util.ObfuscationReflectionHelper;
import org.joml.Matrix4f;
import org.joml.primitives.AABBdc;
import org.joml.Quaterniondc;
import org.joml.Vector3d;
import org.joml.Vector3f;
@@ -44,9 +46,10 @@ 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 mouse moves a cursor instead of the camera. The cursor is a fixed direction in the world: the craft
* turning does not drag it along, so the virtual pilot on the server steers until the gun reticle (the craft's
* boresight) sits on the cursor again. It is kept at most {@code maxCursorAngleDegrees} from the reticle and, if
* spring back is set, drifts back to it. Every tick it is sent to the server as an offset from the reticle;</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>
@@ -62,16 +65,23 @@ public final class CursorAimClient {
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;
// Blocks under the craft's bounding box that count as still on the ground, and how long it has to be off it
private static final double GROUND_CLEARANCE = 1.5;
private static final int AIRBORNE_TICKS = 20;
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
// Cursor as a world direction, null until the first frame puts it on the reticle
private static Vector3d cursorWorld;
// The same cursor as components along the craft's forward (gun reticle), right and up axes, as of the last update
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;
// Client ticks the craft has been off the ground, up to AIRBORNE_TICKS
private static int airborneTicks;
private static double offsetYaw;
private static double offsetPitch;
// Rotation the player was given last frame, to see what the mouse did since
@@ -109,9 +119,91 @@ public final class CursorAimClient {
deactivate(minecraft);
return;
}
// The craft has turned since the last frame: the cursor stays put in the world, its offset changes
Entity seat = AimSeat.seatOf(player);
AimBasis basis = minecraft.level != null ? AimBasis.of(minecraft.level, seat.blockPosition()) : null;
Quaterniondc rotation = basis != null ? shipRotation(minecraft.level, seat, false) : null;
if (rotation != null) {
cursorFromWorld(basis, rotation);
}
updateAirborne(minecraft.level, seat);
send(minecraft, true);
}
/**
* Whether the craft is off the ground, for look ahead: on the ground (and for a moment after leaving it) the camera
* stays behind the reticle, so pulling the nose up to take off does not swing it down under the craft.
*/
private static void updateAirborne(ClientLevel level, Entity seat) {
Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, BlockPos.containing(seat.getX(), seat.getY(), seat.getZ()));
if (ship == null || onGround(level, ship.getWorldAABB())) {
airborneTicks = 0;
} else if (airborneTicks < AIRBORNE_TICKS) {
airborneTicks++;
}
}
/**
* Whether there is solid ground right under the craft: any block with a collision shape in a layer just below the
* bottom of its world bounding box. The craft's own blocks are not in the world, so they never count.
*/
private static boolean onGround(ClientLevel level, AABBdc box) {
int minX = Mth.floor(box.minX());
int maxX = Mth.floor(box.maxX());
int minZ = Mth.floor(box.minZ());
int maxZ = Mth.floor(box.maxZ());
int fromY = Mth.floor(box.minY() - GROUND_CLEARANCE);
int toY = Mth.floor(box.minY() + 0.5);
// At most about 16 x 16 columns, a big craft is sampled on a coarser grid
int stepX = Math.max(1, (maxX - minX) / 16);
int stepZ = Math.max(1, (maxZ - minZ) / 16);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int x = minX; x <= maxX; x += stepX) {
for (int z = minZ; z <= maxZ; z += stepZ) {
for (int y = fromY; y <= toY; y++) {
pos.set(x, y, z);
if (!level.getBlockState(pos).getCollisionShape(level, pos).isEmpty()) {
return true;
}
}
}
}
return false;
}
/** Rotation of the ship the seat is on, its render transform when drawing, null if it is not on a ship. */
private static Quaterniondc shipRotation(ClientLevel level, Entity seat, boolean render) {
Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, BlockPos.containing(seat.getX(), seat.getY(), seat.getZ()));
if (ship == null) {
return null;
}
return render && ship instanceof ClientShip clientShip
? clientShip.getRenderTransform().getShipToWorldRotation() : ship.getTransform().getShipToWorldRotation();
}
/** Sets the cursor's components from its world direction, putting it on the reticle the first time. */
private static void cursorFromWorld(AimBasis basis, Quaterniondc rotation) {
if (cursorWorld == null) {
double[] forward = basis.forward();
cursorWorld = rotation.transform(new Vector3d(forward[0], forward[1], forward[2]));
}
Vector3d ship = rotation.transformInverse(cursorWorld, new Vector3d());
double[] f = basis.forward();
double[] r = basis.right();
double[] u = basis.up();
cf = ship.x * f[0] + ship.y * f[1] + ship.z * f[2];
cr = ship.x * r[0] + ship.y * r[1] + ship.z * r[2];
cu = ship.x * u[0] + ship.y * u[1] + ship.z * u[2];
normalizeCursor();
limitCursor();
}
/** Stores the cursor's components back as its world direction. */
private static void cursorToWorld(AimBasis basis, Quaterniondc rotation) {
double[] ship = basis.toShip(cf, cr, cu);
cursorWorld = rotation.transform(new Vector3d(ship[0], ship[1], ship[2]));
}
/** Called when the server tells the client how its player flies. */
public static void setMode(boolean cursorAim) {
cursorAimMode = cursorAim;
@@ -135,10 +227,12 @@ public final class CursorAimClient {
return;
}
active = true;
cursorWorld = null;
cf = 1.0;
cr = 0.0;
cu = 0.0;
cameraDirection = null;
airborneTicks = 0;
offsetYaw = 0.0;
offsetPitch = 0.0;
haveLast = false;
@@ -184,7 +278,8 @@ public final class CursorAimClient {
}
Entity seat = AimSeat.seatOf(player);
AimBasis basis = AimBasis.of(level, seat.blockPosition());
if (basis == null) {
Quaterniondc rotation = basis != null ? shipRotation(level, seat, true) : null;
if (basis == null || rotation == null) {
return;
}
if (minecraft.options.getCameraType() != CameraType.THIRD_PERSON_BACK) {
@@ -207,10 +302,11 @@ public final class CursorAimClient {
deltaPitch = 0.0;
}
}
double sensitivity = CursorAimClientConfig.MOUSE_SENSITIVITY.get();
double sensitivity = Config.CURSOR_AIM_MOUSE_SENSITIVITY.get();
boolean freeLook = minecraft.screen == null && AimKeys.FREE_LOOK.isDown();
double spring = CursorAimClientConfig.SPRING_BACK_SECONDS.get();
cursorFromWorld(basis, rotation);
double spring = Config.CURSOR_AIM_SPRING_BACK_SECONDS.get();
if (spring > 0.0) {
shrinkCursor(Math.exp(-dt / spring));
}
@@ -223,14 +319,16 @@ public final class CursorAimClient {
offsetYaw *= decay;
offsetPitch *= decay;
}
cursorToWorld(basis, rotation);
// Camera: along the reticle, or turned towards the cursor by the look ahead setting
double lookAhead = CursorAimClientConfig.LOOK_AHEAD.get();
// Camera: along the reticle, or turned towards the cursor by the look ahead setting once the craft is flying
double lookAhead = Config.CURSOR_AIM_LOOK_AHEAD.get();
double[] forward = basis.forward();
if (cameraDirection == null || lookAhead <= 0.0) {
cameraDirection = forward.clone();
} else {
double[] target = basis.toShip(cf, cr, cu);
// On the ground it eases back behind the reticle rather than snapping there
double[] target = airborneTicks >= AIRBORNE_TICKS ? basis.toShip(cf, cr, cu) : forward;
cameraDirection = turnTowards(cameraDirection, target, 1.0 - Math.exp(-lookAhead * LOOK_AHEAD_RATE * dt));
}
double[] direction = rotate(cameraDirection, basis.up(), -Math.toRadians(offsetYaw));
@@ -295,7 +393,7 @@ public final class CursorAimClient {
}
private static void limitCursor() {
double limit = Math.toRadians(CursorAimClientConfig.MAX_CURSOR_ANGLE_DEGREES.get());
double limit = Math.toRadians(Config.CURSOR_AIM_MAX_ANGLE_DEGREES.get());
double angle = Math.acos(Mth.clamp(cf, -1.0, 1.0));
if (angle <= limit) {
return;
@@ -318,7 +416,7 @@ public final class CursorAimClient {
if (!active || minecraft.screen != null) {
return;
}
zoom = Mth.clamp(zoom - event.getScrollDelta() * CursorAimClientConfig.ZOOM_STEP.get(), -3.0, CursorAimClientConfig.ZOOM_MAX.get());
zoom = Mth.clamp(zoom - event.getScrollDelta() * Config.CURSOR_AIM_ZOOM_STEP.get(), -3.0, Config.CURSOR_AIM_ZOOM_MAX.get());
event.setCanceled(true);
}
@@ -354,14 +452,10 @@ public final class CursorAimClient {
}
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) {
Quaterniondc rotation = basis != null ? shipRotation(level, seat, true) : null;
if (rotation == null || cursorWorld == 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]));
@@ -375,8 +469,8 @@ public final class CursorAimClient {
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()) {
float size = (float) (Config.CURSOR_AIM_MARKER_SIZE.get() * MARKER_DISTANCE);
if (Config.CURSOR_AIM_SHOW_RETICLE.get()) {
plus(builder, matrix, camera, reticleWorld, size * 0.35F, RETICLE_COLOR);
}
ring(builder, matrix, camera, cursorWorld, size, CURSOR_COLOR);
@@ -1,45 +0,0 @@
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() {
}
}
@@ -0,0 +1,138 @@
package dev.lumareth.improvedAerodynamics.client.animation;
import dev.lumareth.improvedAerodynamics.Config;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.ClientPlayerNetworkEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import software.bernie.geckolib.core.animatable.model.CoreGeoBone;
import software.bernie.geckolib.model.GeoModel;
/**
* Client side of the control surface animation. The server tells the clients how far each control surface is deflected
* (see {@code DeflectionSync}); this class keeps those values, eases towards them, and sets the rotation of the "Fin"
* bone of Warium's control surface models to them after GeckoLib has played Warium's own fixed animation.
* <p>
* Warium's animations tilt the Fin bone by 20 degrees about X for a positive input, which GeckoLib turns into a
* rotation of -20 degrees (it negates X and Y for rotations), and the same sign is used here, so a surface moves the
* way Warium's own animation moves it, only by the amount the physics uses. Warframes' vertical control surfaces are
* modelled differently: their Fin turns about Y, by -20 degrees in the animation file (+20 after GeckoLib) for the same
* input.
* <p>
* Futurism's vectored afterburner is shown the same way: its own animation only swings the Nozzle bone all the way (20
* degrees about X) up or down, here it follows the tilt the block actually has, which Futurism keeps in the block's
* synced data as "Vectorpitch" (minus a third of the input).
*/
@Mod.EventBusSubscriber(modid = dev.lumareth.improvedAerodynamics.ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT)
public final class SurfaceAnimation {
private static final String BONE = "Fin";
private static final String WARFRAMES_VERTICAL = "net.evo_mc.warframes.block.entity.WarframeVerticalControlSurfaceBlockEntity";
private static final String FUTURISM_VECTORED = "net.mcreator.futurism.block.entity.VectoredAfterburnerTileEntity";
private static final String NOZZLE_BONE = "Nozzle";
// Futurism's animation swing at full input, degrees
private static final float FUTURISM_FULL_DEGREES = 20.0F;
// A value that has not been refreshed for this long is dropped, Warium's own animation takes over again
private static final long STALE_NANOS = 3_000_000_000L;
// Time in seconds the visible deflection takes to close most of the way to the latest value
private static final double EASE_SECONDS = 0.07;
private static final class Entry {
float target;
float shown;
long updated;
long drawn;
boolean placed;
}
private static final Long2ObjectOpenHashMap<Entry> ENTRIES = new Long2ObjectOpenHashMap<>();
// Shown angle (degrees) of each Futurism vectored afterburner
private static final Long2ObjectOpenHashMap<Entry> VECTORED = new Long2ObjectOpenHashMap<>();
private SurfaceAnimation() {
}
/** New deflection of a control surface in degrees, in Warium's animation sign (positive is its "input up" tilt). */
public static void set(long pos, float degrees) {
Entry entry = ENTRIES.computeIfAbsent(pos, key -> new Entry());
entry.target = degrees;
entry.updated = System.nanoTime();
if (!entry.placed) {
// First value of a surface: show it right away, it may have been there before the player came near
entry.shown = degrees;
entry.placed = true;
}
}
public static void apply(BlockEntity blockEntity, GeoModel<?> model) {
if (!Config.ANIMATE_SURFACES.get()) {
return;
}
if (blockEntity.getClass().getName().equals(FUTURISM_VECTORED)) {
applyVectored(blockEntity, model);
return;
}
if (ENTRIES.isEmpty()) {
return;
}
Entry entry = ENTRIES.get(blockEntity.getBlockPos().asLong());
if (entry == null) {
return;
}
long now = System.nanoTime();
if (now - entry.updated > STALE_NANOS) {
ENTRIES.remove(blockEntity.getBlockPos().asLong());
return;
}
ease(entry, now);
CoreGeoBone bone = model.getAnimationProcessor().getBone(BONE);
if (bone == null) {
return;
}
float radians = (float) Math.toRadians(entry.shown);
if (blockEntity.getClass().getName().equals(WARFRAMES_VERTICAL)) {
bone.setRotX(0.0F);
bone.setRotY(radians);
} else {
bone.setRotX(-radians);
}
// Setting a rotation flags the bone as animated, and GeckoLib cleared its flags before this runs. Left set, the
// flag would make GeckoLib skip resetting this bone for the next block drawn with the same model (all blocks of
// a model share its bones), so that block would keep this block's deflection
bone.resetStateChanges();
}
/** Futurism's vectored afterburner: the Nozzle bone at the block's real tilt, Futurism's sign. */
private static void applyVectored(BlockEntity blockEntity, GeoModel<?> model) {
float input = (float) (-3.0 * blockEntity.getPersistentData().getDouble("Vectorpitch"));
float degrees = FUTURISM_FULL_DEGREES * Math.max(-1.0F, Math.min(1.0F, input));
Entry entry = VECTORED.computeIfAbsent(blockEntity.getBlockPos().asLong(), key -> new Entry());
entry.target = degrees;
if (!entry.placed) {
entry.shown = degrees;
entry.placed = true;
}
ease(entry, System.nanoTime());
CoreGeoBone bone = model.getAnimationProcessor().getBone(NOZZLE_BONE);
if (bone != null) {
bone.setRotX((float) -Math.toRadians(entry.shown));
bone.resetStateChanges();
}
}
/** Moves the shown value towards the target. The same block can be drawn more than once a frame (shadows, other passes), the easing steps once per frame. */
private static void ease(Entry entry, long now) {
if (entry.drawn != 0L && now != entry.drawn) {
double dt = (now - entry.drawn) / 1.0E9;
entry.shown += (entry.target - entry.shown) * (float) (1.0 - Math.exp(-dt / EASE_SECONDS));
}
entry.drawn = now;
}
@SubscribeEvent
public static void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) {
ENTRIES.clear();
VECTORED.clear();
}
}
@@ -1,5 +1,7 @@
package dev.lumareth.improvedAerodynamics.exhaust;
import dev.lumareth.improvedAerodynamics.flight.ControlAxis;
import dev.lumareth.improvedAerodynamics.flight.LiveInputs;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
@@ -13,7 +15,8 @@ import org.jetbrains.annotations.Nullable;
* with Warium's two key configuration screen, are read. Either key can name any axis: Pitch+ means W raises the nose and
* Pitch- means S does, Roll+ or Roll- says which roll input rolls the plane right, Yaw+ or Yaw- which yaw input yaws it
* right. An axis no key names is off, except that a nozzle with no key at all pitches with W, and yaw follows D without
* a key.
* a key. While the ship flies, the inputs are the ones the flight physics uses ({@link LiveInputs}), so the nozzles follow
* cursor aim and the flight computer like the control surfaces do.
*/
final class NozzleControl {
private static final ResourceLocation NODE_BLOCK = new ResourceLocation("valkyrien_warium", "vehicle_control_node");
@@ -43,9 +46,10 @@ final class NozzleControl {
String key = own.getString("Key");
String key2 = own.getString("Key2");
boolean unset = (key.isEmpty() || key.equals("Null")) && (key2.isEmpty() || key2.equals("Null"));
double pitch = clamp(tag.getDouble("Pitch")) * sign(key, key2, "Pitch", unset ? 1.0 : 0.0);
double roll = clamp(tag.getDouble("Roll")) * sign(key, key2, "Roll", 0.0);
double yaw = clamp(tag.getDouble("Yaw")) * sign(key, key2, "Yaw", 1.0);
long nodeKey = nodePos.asLong();
double pitch = clamp(LiveInputs.value(nodeKey, tag, ControlAxis.PITCH)) * sign(key, key2, "Pitch", unset ? 1.0 : 0.0);
double roll = clamp(LiveInputs.value(nodeKey, tag, ControlAxis.ROLL)) * sign(key, key2, "Roll", 0.0);
double yaw = clamp(LiveInputs.value(nodeKey, tag, ControlAxis.YAW)) * sign(key, key2, "Yaw", 1.0);
return new Inputs(node, pitch, roll, yaw, Math.max(0.0, tag.getDouble("Throttle")), tag.getBoolean(AfterburnerToggle.TAG));
}
@@ -27,6 +27,8 @@ 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;
// Time constant of the smoothing of measured control rates
private static final double RATE_SMOOTHING_SECONDS = 0.3;
private static final ControlAxis[] MEASURED_AXES = {ControlAxis.PITCH, ControlAxis.YAW, ControlAxis.ROLL};
private final SubstepClock clock;
@@ -41,7 +43,12 @@ public final class AeroState implements PhysicsBridge.Publishable {
private Frame current;
private boolean[] stalled = new boolean[0];
private boolean wingStalled;
// Lift the wings could make at the edge of the stall in the airflow of the last substep, NaN without the improved
// flight model, which has no stall
private double maxLift = Double.NaN;
private final Map<Long, NodeState> nodeStates = new ConcurrentHashMap<>();
// Every ship's node states by node position, for the control surface animation on the server thread
private static final Map<Long, NodeState> ALL_NODE_STATES = new ConcurrentHashMap<>();
private final Wrench wrench = new Wrench();
private final Wrench probe = new Wrench();
@@ -57,6 +64,8 @@ public final class AeroState implements PhysicsBridge.Publishable {
double trimOutput;
// Deflection of this node's flaps in radians, moves towards what the flap lever and airspeed allow
double flapAngle;
// Wall clock time of the last physics substep that ran this node, to tell whether its ship is flying
volatile long updatedMillis;
// 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
@@ -67,6 +76,17 @@ public final class AeroState implements PhysicsBridge.Publishable {
final Vector3d yawResponseBuilding = new Vector3d();
final Vector3d rollResponse = new Vector3d();
final Vector3d rollResponseBuilding = new Vector3d();
// Fastest steady pitch rate (rad/s) the pitch surfaces can hold against the craft's pitch damping, smoothed,
// measured while cursor aim flies the node. NaN until measured
double controlRate = Double.NaN;
// Cursor aim is rolling the craft over to reach a cursor far below the nose
boolean reversing;
// Share of the pull cursor aim keeps, eased down while the wings are stalled
double stallEase = 1.0;
// Which way pitch and yaw input turn the craft (+1 or -1 for nose up and nose right), learned from the measured
// responses. Until then Warium's keys: W (Pitch+) is usually nose down, D (Yaw+) nose right
double pitchSign = -1.0;
double yawSign = 1.0;
final AutoTrim.Targets trimTargets = new AutoTrim.Targets();
volatile double savedTrim;
@@ -151,9 +171,14 @@ public final class AeroState implements PhysicsBridge.Publishable {
double dt = clock.substepSeconds();
boolean improved = Config.IMPROVED_FLIGHT_MODEL.get();
Matrix3dc inertia = ship.getMomentOfInertia();
// Same frames and scaling as Warium applies its forces in
double scale = transform.getShipToWorldScaling().x();
double forceScale = autoScale ? scale * scale * scale : 1.0;
double liftAcceleration = maxLift * forceScale / ship.getMass();
updateNodes(frame, worldVelocity, transform.getPositionInWorld().y(), rotation, inertia, w, dt, improved);
updateNodes(frame, worldVelocity, transform.getPositionInWorld().y(), rotation, inertia, w, dt, improved, liftAcceleration);
evaluate(frame, centerOfMass, v, w, improved, true, wrench);
measureControlRates(frame, centerOfMass, v, w, improved, dt);
if (Config.IMPLICIT_DAMPING.get()) {
// Implicit Euler for the damping part: scale each axis by 1 / (1 + dt * damping / inertia), using the
@@ -180,9 +205,6 @@ public final class AeroState implements PhysicsBridge.Publishable {
}
}
// Same frames and scaling as Warium applies its forces in
double scale = transform.getShipToWorldScaling().x();
double forceScale = autoScale ? scale * scale * scale : 1.0;
Vector3d force = new Vector3d(wrench.force[0], wrench.force[1], wrench.force[2]).mul(forceScale);
Vector3d forceTorque = new Vector3d(wrench.forceTorque[0], wrench.forceTorque[1], wrench.forceTorque[2]).mul(forceScale);
if (force.isFinite() && forceTorque.isFinite()) {
@@ -209,9 +231,15 @@ public final class AeroState implements PhysicsBridge.Publishable {
for (int i = 0; i < stalled.length; i++) {
stalled[i] = previous.get(frame.surfaces[i].key());
}
nodeStates.keySet().removeIf(key -> !frame.nodes.containsKey((long) key));
nodeStates.keySet().removeIf(key -> {
boolean gone = !frame.nodes.containsKey((long) key);
if (gone) {
ALL_NODE_STATES.remove(key);
}
return gone;
});
for (NodeData node : frame.nodes.values()) {
nodeStates.computeIfAbsent(node.key(), key -> new NodeState(node));
ALL_NODE_STATES.put(node.key(), nodeStates.computeIfAbsent(node.key(), key -> new NodeState(node)));
}
current = frame;
}
@@ -229,6 +257,7 @@ public final class AeroState implements PhysicsBridge.Publishable {
double[] r = new double[3];
if (commit) {
wingStalled = false;
maxLift = improved ? 0.0 : Double.NaN;
for (NodeState state : nodeStates.values()) {
state.pitchResponseBuilding.zero();
state.yawResponseBuilding.zero();
@@ -251,6 +280,9 @@ public final class AeroState implements PhysicsBridge.Publishable {
if (commit) {
stalled[i] = nowStalled;
wingStalled |= nowStalled && s.type() == SurfaceType.WING;
if (s.type() == SurfaceType.WING || s.type() == SurfaceType.CONTROL_SURFACE && s.wingGroup()) {
maxLift += ImprovedAero.maxLift(s, v, w, r, flap, weight, tuning);
}
}
} else {
WariumAero.evaluate(s, v, w, r, input, weight, out);
@@ -296,6 +328,37 @@ public final class AeroState implements PhysicsBridge.Publishable {
}
}
/**
* For every node cursor aim flies: the steady pitch rate its pitch surfaces can hold, full deflection torque over
* the craft's pitch damping, both measured about the axis the pitch surfaces turn the craft about.
*/
private void measureControlRates(Frame frame, Vector3dc centerOfMass, double[] v, double[] w, boolean improved, double dt) {
for (NodeData node : frame.nodes.values()) {
NodeState state = nodeStates.get(node.key());
if (state == null || !CursorAimRegistry.isActive(node.key())) {
continue;
}
double response = state.pitchResponse.length();
if (response < 1.0E-9) {
continue;
}
Vector3d axis = state.pitchResponse.div(response, new Vector3d());
double[] turned = {w[0] + ANGULAR_EPSILON * axis.x, w[1] + ANGULAR_EPSILON * axis.y, w[2] + ANGULAR_EPSILON * axis.z};
evaluate(frame, centerOfMass, v, turned, improved, false, probe);
double damping = 0.0;
for (int i = 0; i < 3; i++) {
damping -= (probe.totalTorque(i) - wrench.totalTorque(i)) * axis.get(i);
}
damping /= ANGULAR_EPSILON;
if (!(damping > 1.0E-9)) {
continue;
}
double rate = response / damping;
state.controlRate = Double.isNaN(state.controlRate) ? rate
: state.controlRate + (rate - state.controlRate) * Math.min(1.0, dt / RATE_SMOOTHING_SECONDS);
}
}
/** 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, ControlAxis nudged, double extra) {
if (node == null || state == null) {
@@ -318,7 +381,7 @@ public final class AeroState implements PhysicsBridge.Publishable {
}
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 liftAcceleration) {
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)};
@@ -328,11 +391,13 @@ public final class AeroState implements PhysicsBridge.Publishable {
continue;
}
// 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);
NodeData effective = CursorPilot.apply(state, node, rotation, inertia, w, worldVelocity.length(), wingStalled,
liftAcceleration, dt);
state.pitch = moveTowards(state.pitch, effective.pitch(), step);
state.yaw = moveTowards(state.yaw, effective.yaw(), step);
state.roll = moveTowards(state.roll, effective.roll(), step);
updateFlaps(state, node, worldVelocity.length(), dt);
state.updatedMillis = System.currentTimeMillis();
AutoTrim.update(state, effective, worldVelocity, altitude, rotation, inertia, w, torque, dt, improved && wingStalled);
}
}
@@ -351,6 +416,11 @@ public final class AeroState implements PhysicsBridge.Publishable {
state.flapAngle = moveTowards(state.flapAngle, target, Math.toRadians(Config.FLAP_SLEW_DEGREES.get()) * dt);
}
/** The physics state of a control node, null if no physics runs for it. */
static NodeState nodeState(long nodeKey) {
return ALL_NODE_STATES.get(nodeKey);
}
static double moveTowards(double value, double target, double maxStep) {
if (Math.abs(target - value) <= maxStep) {
return target;
@@ -1,56 +0,0 @@
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() {
}
}
@@ -1,5 +1,6 @@
package dev.lumareth.improvedAerodynamics.flight;
import dev.lumareth.improvedAerodynamics.Config;
import net.mcreator.valkyrienwarium.entity.SeatEntityEntity;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
@@ -24,7 +25,7 @@ public final class CursorAimServer {
}
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)) {
if (!active || !Config.CURSOR_AIM_ENABLED.get() || !(length > 1.0E-6) || !Double.isFinite(length)) {
CursorAimRegistry.clear(nodeKey);
return;
}
@@ -1,29 +1,54 @@
package dev.lumareth.improvedAerodynamics.flight;
import dev.lumareth.improvedAerodynamics.Config;
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:
* The virtual pilot of cursor aim. The cursor is a direction fixed in the world, and the pilot turns the craft until
* the gun reticle sits on it, so the offset it gets shrinks to nothing as the turn completes:
* <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>
* <li>it banks towards the cursor, judged with the wings level so the bank does not hide where the cursor is: the
* further the cursor is to the side, the steeper the bank. While banked it pulls as much as a level turn at that
* bank needs, so the craft turns without losing height;</li>
* <li>it pitches towards the cursor as seen from the cockpit, so once banked the cursor is above the nose and the
* pull brings the nose round onto it;</li>
* <li>a cursor far below the nose (more than {@code reverseTurnDegrees}, judged with the wings level) is not reached
* by pushing: the pilot rolls until the cursor is above the canopy and pulls, a reversal or split-S. It keeps at it
* until the reticle is close to the cursor, or the cursor is moved above the nose again. A cursor only a little below
* the nose is reached with the elevators alone, gently, so the craft can still be flown down to a landing;</li>
* <li>below {@code minSpeed} there is too little airflow to fly by: the cursor moves elevators and rudder directly;</li>
* <li>close to the reticle it levels the wings and puts the reticle on the cursor with rudder and elevator, 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.
* <p>
* How hard the craft can turn is worked out live, not configured: the lift its wings can make at the edge of the stall
* at the current speed and altitude, over its mass and speed, and the steady pitch rate its pitch surfaces can hold
* against its pitch damping, whichever is lower. The same lift limit caps the bank to what the craft can hold level, so
* a heavy bomber banks gently and turns wide while a light fighter racks into a steep, tight turn.
*/
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);
// Gravity of VS
private static final double GRAVITY = 10.0;
// Share of the stall limited lift the pilot plans with, so it does not ride the stall
private static final double LIFT_MARGIN = 0.85;
// Bank the pilot rolls to at full sideways deflection even when the wings cannot hold the turn level
private static final double MIN_BANK = Math.toRadians(20.0);
// Smallest pull allowed, as a share of the turn rate, when gravity takes most of the lift
private static final double MIN_PULL_SHARE = 0.2;
// Time constant of easing the pull back in and out of a stall
private static final double STALL_EASE_SECONDS = 0.4;
// Turn rate when neither limit can be measured, like the first moments of a flight
private static final double FALLBACK_TURN_RATE = Math.toRadians(30.0);
private CursorPilot() {
}
@@ -32,57 +57,128 @@ final class 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()) {
double speed, boolean wingStalled, double liftAcceleration, double dt) {
if (!Config.CURSOR_AIM_ENABLED.get()) {
return node;
}
CursorAimRegistry.Aim aim = CursorAimRegistry.get(node.key());
if (aim == null || speed < CursorAimConfig.MIN_SPEED.get()) {
if (aim == null) {
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 fullAngle = Math.toRadians(Config.CURSOR_AIM_FULL_DEFLECTION_DEGREES.get());
double maxRoll = Math.toRadians(Config.CURSOR_AIM_MAX_ROLL_RATE_DEGREES.get());
double maxBank = Math.toRadians(Config.CURSOR_AIM_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);
learnSigns(state, f, r, u);
// 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));
// Deadzone around the reticle: shrink the cursor's offset by its radius, direction kept
double cf = aim.cf();
double cr = aim.cr();
double cu = aim.cu();
double deadzone = Math.toRadians(Config.CURSOR_AIM_DEADZONE_DEGREES.get());
double offset = Math.acos(clamp(cf, -1.0, 1.0));
double across = Math.hypot(cr, cu);
if (deadzone > 0.0 && offset > 1.0E-9 && across > 1.0E-9) {
double shrunk = Math.max(0.0, offset - deadzone);
cf = Math.cos(shrunk);
cr *= Math.sin(shrunk) / across;
cu *= Math.sin(shrunk) / across;
}
if (wingStalled && pitchRate > 0.0) {
pitchRate *= CursorAimConfig.STALLED_PITCH_FACTOR.get();
// Cursor as angles from the reticle as seen from the cockpit: sideways (positive right) and up, through any
// 270 degree cone
double sideways = Math.atan2(cr, cf);
double vertical = Math.atan2(cu, Math.hypot(cf, cr));
// The cursor as it would be with the wings level: what the bank is chosen by
double levelRight = cr * Math.cos(bank) + cu * Math.sin(bank);
double levelUp = cu * Math.cos(bank) - cr * Math.sin(bank);
double levelVertical = Math.atan2(levelUp, Math.hypot(cf, levelRight));
if (speed < Config.CURSOR_AIM_MIN_SPEED.get()) {
// Too slow for the rate loop: the cursor moves the surfaces directly, rudder towards it (which also steers on
// the ground) and elevator up or down, so the controls follow the cursor while taxiing and rotating
state.reversing = false;
double pitch = clamp(state.pitchSign * saturate(vertical / fullAngle) - state.trimOutput, -1.0, 1.0);
double yaw = state.yawSign * saturate(sideways / fullAngle);
return withInputs(node, pitch, yaw, 0.0);
}
// What this craft can do right now. Lift: pitch rate at the highest usable lift (a = L / m, rate = a / v), and
// the bank whose level turn needs that load factor. Without a stall model only the control limit applies
double usableLift = LIFT_MARGIN * liftAcceleration;
double liftRate = usableLift > 0.0 ? usableLift / speed : Double.POSITIVE_INFINITY;
double controlRate = state.controlRate > 0.0 ? state.controlRate : Double.POSITIVE_INFINITY;
double maxTurn = Math.min(liftRate, controlRate);
if (!Double.isFinite(maxTurn)) {
maxTurn = FALLBACK_TURN_RATE;
}
// Fastest pull: part of the lift already holds the craft up against gravity, only the rest curves the flight
// path, so asking for the full lift rate in a steep bank would drag the wings into the stall
double maxPull = maxTurn;
if (Double.isFinite(liftAcceleration)) {
double loadFactor = usableLift / GRAVITY;
double levelBank = loadFactor > 1.0 ? Math.acos(1.0 / loadFactor) : 0.0;
maxBank = Math.max(Math.min(MIN_BANK, maxBank), Math.min(maxBank, levelBank));
double pullLift = (usableLift - GRAVITY * upWorld.y) / speed;
maxPull = clamp(Math.min(pullLift, controlRate), MIN_PULL_SHARE * maxTurn, maxTurn);
}
// Reversal: entered when the cursor is far below the nose, left once the reticle is close to it or the cursor
// is above the nose again
double offsetAngle = Math.acos(clamp(cf, -1.0, 1.0));
if (state.reversing) {
state.reversing = offsetAngle > FINE_END && levelVertical < 0.0;
} else {
state.reversing = levelVertical < -Math.toRadians(Config.CURSOR_AIM_REVERSE_TURN_DEGREES.get());
}
// Bank: roll so the cursor is above the canopy, which is where a pull takes the nose. Judged by the direction of
// the cursor around the nose, which stays well defined wherever the cursor is, even 90 degrees off the nose
// straight above or to the side; the angle of the cursor from the nose only sets how much of that bank is used,
// so the wings come level as the reticle closes on the cursor
double blend = state.reversing ? 1.0 : smoothstep(offsetAngle, FINE_START, FINE_END);
double bankError;
if (state.reversing) {
// All the way over, the shorter way round
bankError = wrap(Math.atan2(levelRight, levelUp) - bank);
} else {
// A cursor below the nose counts as if it were as far above it: only its sideways part banks the craft, and
// the craft is never rolled upside down for a slight descent
double overhead = Math.atan2(levelRight, Math.abs(levelUp));
double bankCommand = clamp(overhead, -maxBank, maxBank) * saturate(offsetAngle / fullAngle) * blend;
bankError = bankCommand - bank;
}
double rollRate = clamp(Config.CURSOR_AIM_BANK_GAIN.get() * bankError, -maxRoll, maxRoll);
// Pitch: the pull of a level turn at the current bank, the turn rate g tan(bank) / v seen about the pitch axis,
// plus a pull towards the cursor. Past 90 degrees of bank the level turn pull would only dive, so it is left out
double pitchStick = saturate(vertical / fullAngle);
double push = Config.CURSOR_AIM_PUSH_FACTOR.get();
// While rolling into a reversal the cursor is still below the nose, pushing there would only work against it
double pitchRate = pitchStick >= 0.0 ? maxPull * pitchStick : state.reversing ? 0.0 : maxTurn * pitchStick * push;
double cosBank = Math.cos(bank);
if (cosBank > 0.0) {
double sinBank = Math.sin(bank);
pitchRate += GRAVITY * sinBank * sinBank / (speed * Math.max(cosBank, 0.1));
}
// Stalled wings ease the pull back, and it comes back as gently once they fly again: switching it at once with
// the stall would make the craft buck in and out of the stall
double stallTarget = wingStalled ? Config.CURSOR_AIM_STALLED_PITCH_FACTOR.get() : 1.0;
state.stallEase += (stallTarget - state.stallEase) * Math.min(1.0, dt / STALL_EASE_SECONDS);
pitchRate = clamp(pitchRate, -maxTurn * push, maxPull);
if (pitchRate > 0.0) {
pitchRate *= state.stallEase;
}
// Close to the reticle the nose follows the cursor with the rudder
double yawRate = maxTurn * lateral * (1.0 - blend);
double yawRate = maxTurn * saturate(sideways / fullAngle) * (1.0 - blend);
// Wanted angular velocity in ship axes: roll about forward, pitch about right, yaw (nose right) about -up
Vector3d wanted = new Vector3d(
@@ -90,7 +186,7 @@ final class CursorPilot {
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 acceleration = error.mul(Config.CURSOR_AIM_RATE_GAIN.get());
Vector3d torque = inertia.transform(acceleration, new Vector3d());
double[] inputs = solve(state.pitchResponse, state.yawResponse, state.rollResponse, torque);
@@ -101,6 +197,11 @@ final class CursorPilot {
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 withInputs(node, pitch, yaw, roll);
}
/** The node with these inputs on the axes the pilot is not holding a key for. */
private static NodeData withInputs(NodeData node, double pitch, double yaw, double roll) {
return new NodeData(node.key(),
Math.abs(node.pitch()) > MANUAL_OVERRIDE ? node.pitch() : pitch,
Math.abs(node.yaw()) > MANUAL_OVERRIDE ? node.yaw() : yaw,
@@ -108,6 +209,25 @@ final class CursorPilot {
node.throttle(), node.autoTrim(), node.storedTrim(), node.flapSetting());
}
/**
* Remembers which way each input turns the craft, from the measured torque per unit of input: + nose up for pitch,
* nose right for yaw. Kept from the last time it was measurable, since with no airflow nothing can be measured.
*/
private static void learnSigns(AeroState.NodeState state, double[] f, double[] r, double[] u) {
state.pitchSign = sign(state.pitchResponse, r, 1.0, state.pitchSign);
state.yawSign = sign(state.yawResponse, u, -1.0, state.yawSign);
}
private static double sign(Vector3d response, double[] axis, double direction, double previous) {
double length = response.length();
if (!(length > 1.0E-6)) {
return previous;
}
double along = direction * (response.x * axis[0] + response.y * axis[1] + response.z * axis[2]) / length;
// Only trusted while the input clearly turns the craft about that axis
return Math.abs(along) > 0.3 ? Math.signum(along) : previous;
}
/** 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);
@@ -129,6 +249,11 @@ final class CursorPilot {
return new double[]{x.x, x.y, x.z};
}
/** The angle brought into -pi to pi. */
private static double wrap(double angle) {
return Math.atan2(Math.sin(angle), Math.cos(angle));
}
private static double saturate(double value) {
return clamp(value, -1.0, 1.0);
}
@@ -0,0 +1,145 @@
package dev.lumareth.improvedAerodynamics.flight;
import dev.lumareth.improvedAerodynamics.Config;
import dev.lumareth.improvedAerodynamics.network.DeflectionPacket;
import dev.lumareth.improvedAerodynamics.network.Network;
import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ShortOpenHashMap;
import it.unimi.dsi.fastutil.longs.LongArrayList;
import it.unimi.dsi.fastutil.shorts.ShortArrayList;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.server.ServerStoppedEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.network.PacketDistributor;
import org.joml.Vector3d;
import org.valkyrienskies.core.api.ships.Ship;
import org.valkyrienskies.mod.common.VSGameUtilsKt;
import java.util.HashMap;
import java.util.Map;
/**
* Tells the clients near a ship how far its control surfaces and flaps are deflected, so they can show it, see
* {@code SurfaceAnimation}. Every tick each Warium control surface block reports its deflection from the procedure that
* runs it ({@link WariumCapture#begin}); at the end of the tick what changed goes out, in one packet per ship, and
* everything again once a second so players who arrive later get it too.
* <p>
* The deflection is what the physics uses: the smoothed pilot input plus trim and flap angle of the surface's control
* node. While the ship is not flying (no physics, nothing is moving) the node's raw input and flap lever are used, and the
* flaps are moved towards their angle here, at the configured speed.
*/
public final class DeflectionSync {
/** Degrees of deflection the physics gives a surface at full input, see ImprovedAero.MAX_DEFLECTION. */
private static final double FULL_INPUT_DEGREES = 20.0;
private static final long RESEND_TICKS = 20;
private static final double RANGE = 256.0;
private record Batch(ServerLevel level, double x, double y, double z, LongArrayList positions, ShortArrayList tenths) {
}
private static final Map<ResourceKey<Level>, Long2ShortOpenHashMap> LAST_VALUE = new HashMap<>();
private static final Map<ResourceKey<Level>, Long2LongOpenHashMap> LAST_SENT = new HashMap<>();
// Flap angle (radians) shown while a node is not flying, and the tick it was last moved
private static final Map<Long, double[]> STATIC_FLAPS = new HashMap<>();
private static final Map<Long, Batch> BATCHES = new HashMap<>();
private DeflectionSync() {
}
/** Called by the procedure of every control surface block each tick, before it adds its forces. */
static void report(ServerLevel level, BlockPos pos, SurfaceType type) {
if (!type.controllable) {
return;
}
BlockEntity blockEntity = level.getBlockEntity(pos);
if (blockEntity == null) {
return;
}
CompoundTag tag = blockEntity.getPersistentData();
BlockPos nodePos = BlockPos.containing(tag.getDouble("ControlX"), tag.getDouble("ControlY"), tag.getDouble("ControlZ"));
BlockEntity nodeEntity = level.getBlockEntity(nodePos);
if (nodeEntity == null || !tag.contains("ControlX")) {
return;
}
long nodeKey = nodePos.asLong();
CompoundTag nodeTag = nodeEntity.getPersistentData();
AeroState.NodeState state = LiveInputs.flying(nodeKey);
boolean flying = state != null;
double input = axisInput(nodeKey, nodeTag, tag.getString("Key")) + axisInput(nodeKey, nodeTag, tag.getString("Key2"));
double degrees = FULL_INPUT_DEGREES * Math.max(-1.0, Math.min(1.0, input));
if (type == SurfaceType.CONTROL_SURFACE && tag.getBoolean(WariumCapture.FLAP_TAG)) {
degrees += Math.toDegrees(flying ? state.flapAngle : staticFlap(level, nodeKey, nodeTag));
}
short tenths = (short) Math.round(Math.max(-90.0, Math.min(90.0, degrees)) * 10.0);
ResourceKey<Level> dimension = level.dimension();
Long2ShortOpenHashMap lastValue = LAST_VALUE.computeIfAbsent(dimension, key -> new Long2ShortOpenHashMap());
Long2LongOpenHashMap lastSent = LAST_SENT.computeIfAbsent(dimension, key -> new Long2LongOpenHashMap());
long posKey = pos.asLong();
long now = level.getGameTime();
if (lastSent.containsKey(posKey) && lastValue.get(posKey) == tenths && now - lastSent.get(posKey) < RESEND_TICKS) {
return;
}
lastValue.put(posKey, tenths);
lastSent.put(posKey, now);
Ship ship = VSGameUtilsKt.getShipManagingPos(level, pos);
long shipId = ship != null ? ship.getId() : Long.MIN_VALUE;
Batch batch = BATCHES.get(shipId);
if (batch == null) {
Vector3d world = new Vector3d(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
if (ship != null) {
ship.getShipToWorld().transformPosition(world);
}
batch = new Batch(level, world.x, world.y, world.z, new LongArrayList(), new ShortArrayList());
BATCHES.put(shipId, batch);
}
batch.positions.add(posKey);
batch.tenths.add(tenths);
}
/** One key's share of a surface's input, like Warium reads "Pitch+" / "Roll-". Throttle keys do not deflect it. */
private static double axisInput(long nodeKey, CompoundTag nodeTag, String key) {
return ControlAxis.of(key) == ControlAxis.THROTTLE ? 0.0 : LiveInputs.key(nodeKey, nodeTag, key);
}
/** Flap angle of a node that is not flying: moves towards the lever's angle, once per tick however many flaps ask. */
private static double staticFlap(ServerLevel level, long nodeKey, CompoundTag nodeTag) {
double target = Math.toRadians(FlapSetting.of(nodeTag.getInt(WariumCapture.FLAP_SETTING_TAG)).degrees());
double[] entry = STATIC_FLAPS.computeIfAbsent(nodeKey, key -> new double[]{target, Double.NaN});
long now = level.getGameTime();
if (entry[1] != now) {
entry[1] = now;
entry[0] = AeroState.moveTowards(entry[0], target, Math.toRadians(Config.FLAP_SLEW_DEGREES.get()) / 20.0);
}
return entry[0];
}
@SubscribeEvent
public static void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END || BATCHES.isEmpty()) {
return;
}
for (Batch batch : BATCHES.values()) {
DeflectionPacket packet = new DeflectionPacket(batch.positions.toLongArray(), batch.tenths.toShortArray());
Network.CHANNEL.send(PacketDistributor.NEAR.with(() -> new PacketDistributor.TargetPoint(batch.x, batch.y, batch.z,
RANGE, batch.level.dimension())), packet);
}
BATCHES.clear();
}
@SubscribeEvent
public static void onServerStopped(ServerStoppedEvent event) {
BATCHES.clear();
LAST_VALUE.clear();
LAST_SENT.clear();
STATIC_FLAPS.clear();
}
}
@@ -129,11 +129,8 @@ final class ImprovedAero {
// Low aspect ratio wings keep their flow attached longer thanks to leading-edge vortices. Control surfaces
// in a wing's group stall with the wing instead of before it
double vortexFactor = tuning.vortex() && (s.type() == SurfaceType.WING || s.wingGroup() && s.type().controllable)
? VortexLift.factor(s.aspectRatio()) : 0.0;
double baseStall = s.slab() ? SurfaceType.SLAB_STALL_ANGLE
: s.wingGroup() && s.type().controllable ? SurfaceType.WING.stallAngle : s.type().stallAngle;
double critical = Math.toRadians(baseStall) * (1.0 + tuning.vortexStallBonus() * vortexFactor) * tuning.stallAngleScale();
double vortexFactor = vortexFactor(s, tuning);
double critical = criticalAngle(s, tuning, vortexFactor);
double absAlpha = Math.abs(alpha);
if (alpha >= 0.0) {
critical += FLAP_STALL_GAIN * flapShift;
@@ -149,9 +146,8 @@ final class ImprovedAero {
// Finite wing theory: lift slope and induced drag follow from the wing's aspect ratio
double aspectRatio = s.aspectRatio();
double slope = aspectRatio > 0.0 ? 2.0 * Math.PI / (1.0 + 2.0 / aspectRatio) : LIFT_SLOPE;
double inducedDrag = aspectRatio > 0.0 ? 1.0 / (Math.PI * EFFICIENCY * aspectRatio) : INDUCED_DRAG;
double attachedLift = slope * alpha;
double attachedLift = liftSlope(s) * alpha;
if (s.type() == SurfaceType.WING) {
attachedLift += VortexLift.liftCoefficient(alpha, vortexFactor, tuning.vortexLiftStrength());
}
@@ -175,6 +171,44 @@ final class ImprovedAero {
return stalled;
}
/**
* Lift (along the surface's normal) the surface would make in the airflow it sees now at the highest angle of
* attack it can fly without starting to stall, flaps included. Only meaningful for horizontal surfaces.
*/
static double maxLift(AeroSurface s, double[] v, double[] w, double[] r, double flap, double weight, Tuning tuning) {
double vx = v[0] + w[1] * r[2] - w[2] * r[1];
double vy = v[1] + w[2] * r[0] - w[0] * r[2];
double vz = v[2] + w[0] * r[1] - w[1] * r[0];
double u = vx * s.facingX() + vz * s.facingZ();
double planeSpeedSq = u * u + vy * vy;
double vortexFactor = vortexFactor(s, tuning);
double flapShift = FLAP_ALPHA_FACTOR * flap;
double alpha = criticalAngle(s, tuning, vortexFactor) + FLAP_STALL_GAIN * flapShift - STALL_BLEND;
double lift = liftSlope(s) * alpha;
if (s.type() == SurfaceType.WING) {
lift += VortexLift.liftCoefficient(alpha, vortexFactor, tuning.vortexLiftStrength());
}
return LIFT_CONSTANT * s.pressure() * weight * s.fluidFactor() * planeSpeedSq * lift * tuning.liftScale();
}
private static double vortexFactor(AeroSurface s, Tuning tuning) {
return tuning.vortex() && (s.type() == SurfaceType.WING || s.wingGroup() && s.type().controllable)
? VortexLift.factor(s.aspectRatio()) : 0.0;
}
/** Angle of attack (radians, camber and flaps included) at which the surface starts to stall, without flaps. */
private static double criticalAngle(AeroSurface s, Tuning tuning, double vortexFactor) {
double baseStall = s.slab() ? SurfaceType.SLAB_STALL_ANGLE
: s.wingGroup() && s.type().controllable ? SurfaceType.WING.stallAngle : s.type().stallAngle;
return Math.toRadians(baseStall) * (1.0 + tuning.vortexStallBonus() * vortexFactor) * tuning.stallAngleScale();
}
/** Lift slope per radian from finite wing theory, from the wing's aspect ratio. */
private static double liftSlope(AeroSurface s) {
double aspectRatio = s.aspectRatio();
return aspectRatio > 0.0 ? 2.0 * Math.PI / (1.0 + 2.0 / aspectRatio) : LIFT_SLOPE;
}
static double smoothstep(double edge0, double edge1, double x) {
double t = Math.max(0.0, Math.min(1.0, (x - edge0) / (edge1 - edge0)));
return t * t * (3.0 - 2.0 * t);
@@ -0,0 +1,44 @@
package dev.lumareth.improvedAerodynamics.flight;
import net.minecraft.nbt.CompoundTag;
/**
* The pilot inputs a control node's devices should follow, for everything that is not an aerodynamic surface (thrust
* vectoring nozzles). While the node's ship is flying these are the inputs the flight physics uses: the pilot's keys
* smoothed, or what cursor aim asks for in their place, plus the flight computer's pitch trim. A node without physics
* (on the ground, nothing moving, physics rate aerodynamics off) gives Warium's raw key values, as before.
*/
public final class LiveInputs {
// A node state the physics thread has not touched for this long is not flying any more
private static final long FRESH_MILLIS = 500;
private LiveInputs() {
}
/** The node's physics state if its ship is flying now, else null. */
static AeroState.NodeState flying(long nodeKey) {
AeroState.NodeState state = AeroState.nodeState(nodeKey);
return state != null && System.currentTimeMillis() - state.updatedMillis < FRESH_MILLIS ? state : null;
}
/**
* One axis of a node, in Warium's node units (-1 to 1), without any key multiplier.
*
* @param nodeTag the node block entity's persistent data, for the raw value
*/
public static double value(long nodeKey, CompoundTag nodeTag, ControlAxis axis) {
AeroState.NodeState state = flying(nodeKey);
return switch (axis) {
case PITCH -> state != null ? state.pitch + state.trimOutput : nodeTag.getDouble("Pitch");
case YAW -> state != null ? state.yaw : nodeTag.getDouble("Yaw");
case ROLL -> state != null ? state.roll : nodeTag.getDouble("Roll");
case THROTTLE -> nodeTag.getDouble("Throttle");
case NONE -> 0.0;
};
}
/** A Warium key such as "Pitch+" or "Roll-" read from the node: its axis' value times the key's sign. */
public static double key(long nodeKey, CompoundTag nodeTag, String key) {
return value(nodeKey, nodeTag, ControlAxis.of(key)) * ControlAxis.multiplier(key);
}
}
@@ -23,11 +23,13 @@ public final class MixinPlugin implements IMixinConfigPlugin {
private boolean warium;
private boolean futurism;
private boolean geckolib;
@Override
public void onLoad(String mixinPackage) {
warium = supported("valkyrien_warium", WARIUM_VERSION, "Warium VS");
futurism = supported("futurism", FUTURISM_VERSION, "Futurism");
geckolib = LoadingModList.get().getModFileById("geckolib") != null;
}
private static boolean supported(String modId, String version, String name) {
@@ -49,6 +51,10 @@ public final class MixinPlugin implements IMixinConfigPlugin {
if (mixinClassName.contains(".mixin.warium.")) {
return warium;
}
if (mixinClassName.contains(".mixin.gecko.")) {
// Animates Warium's control surfaces, which are GeckoLib models
return warium && geckolib;
}
if (mixinClassName.contains(".mixin.futurism.")) {
return futurism;
}
@@ -50,6 +50,9 @@ public final class WariumCapture {
world = level;
pos = BlockPos.containing(x, y, z);
state = blockState;
if (surfaceType.controllable && level instanceof ServerLevel serverLevel && Config.PHYSICS_RATE_AERODYNAMICS.get()) {
DeflectionSync.report(serverLevel, pos, surfaceType);
}
}
/**
@@ -0,0 +1,55 @@
package dev.lumareth.improvedAerodynamics.flight.mixin.futurism;
import dev.lumareth.improvedAerodynamics.flight.LiveInputs;
import net.mcreator.futurism.procedures.VectoredAfterburnerSystemProcedure;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.LevelAccessor;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.ModifyVariable;
/**
* Futurism's vectored afterburner reads the raw key values of its control node, so it ignored cursor aim and the flight
* computer. This hands it the same inputs the control surfaces follow ({@link LiveInputs}), through the same Key and Key2
* it is configured with. It stays what Futurism made it, 2D vectoring: the procedure still only tilts the thrust up or
* down. The value is kept on Warium's 0.2 key steps, since every change of it plays Futurism's servo sound and sends a
* block update.
*/
@Mixin(value = VectoredAfterburnerSystemProcedure.class, remap = false)
public abstract class VectoredAfterburnerSystemProcedureMixin {
private static final double STEP = 0.2;
@ModifyVariable(method = "execute", at = @At("STORE"), name = "input")
private static double improvedAerodynamics$liveInput(double input, LevelAccessor world, double x, double y, double z, BlockState blockstate) {
if (!(world instanceof ServerLevel level)) {
return input;
}
BlockEntity own = level.getBlockEntity(BlockPos.containing(x, y, z));
if (own == null) {
return input;
}
CompoundTag tag = own.getPersistentData();
BlockPos nodePos = BlockPos.containing(tag.getDouble("ControlX"), tag.getDouble("ControlY"), tag.getDouble("ControlZ"));
BlockEntity node = level.getBlockEntity(nodePos);
if (node == null) {
return input;
}
long nodeKey = nodePos.asLong();
CompoundTag nodeTag = node.getPersistentData();
double live = keyInput(nodeKey, nodeTag, tag.getString("Key")) + keyInput(nodeKey, nodeTag, tag.getString("Key2"));
live = Math.max(-1.0, Math.min(1.0, live));
return Math.round(live / STEP) * STEP;
}
/** Like Futurism reads a key: pitch, yaw or roll with its sign, anything else (Throttle, Null) adds nothing. */
private static double keyInput(long nodeKey, CompoundTag nodeTag, String key) {
if (key.startsWith("Pitch") || key.startsWith("Yaw") || key.startsWith("Roll")) {
return LiveInputs.key(nodeKey, nodeTag, key);
}
return 0.0;
}
}
@@ -0,0 +1,25 @@
package dev.lumareth.improvedAerodynamics.flight.mixin.gecko;
import dev.lumareth.improvedAerodynamics.client.animation.SurfaceAnimation;
import net.minecraft.world.level.block.entity.BlockEntity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import software.bernie.geckolib.core.animatable.GeoAnimatable;
import software.bernie.geckolib.core.animation.AnimationState;
import software.bernie.geckolib.model.GeoModel;
/**
* Runs after GeckoLib has played an animatable's animations, to put Warium's control surfaces at the deflection the
* flight physics gives them, see {@link SurfaceAnimation}.
*/
@Mixin(value = GeoModel.class, remap = false)
public abstract class GeoModelMixin {
@Inject(method = "handleAnimations", at = @At("RETURN"))
private void improvedAerodynamics$deflectSurface(GeoAnimatable animatable, long instanceId, AnimationState animationState, CallbackInfo ci) {
if (animatable instanceof BlockEntity blockEntity) {
SurfaceAnimation.apply(blockEntity, (GeoModel<?>) (Object) this);
}
}
}
@@ -0,0 +1,50 @@
package dev.lumareth.improvedAerodynamics.network;
import dev.lumareth.improvedAerodynamics.client.animation.SurfaceAnimation;
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;
/** How far control surfaces are deflected, for the clients near a ship: block positions and tenths of a degree. */
public final class DeflectionPacket {
private final long[] positions;
private final short[] tenths;
public DeflectionPacket(long[] positions, short[] tenths) {
this.positions = positions;
this.tenths = tenths;
}
public void encode(FriendlyByteBuf buffer) {
buffer.writeVarInt(positions.length);
for (int i = 0; i < positions.length; i++) {
buffer.writeLong(positions[i]);
buffer.writeShort(tenths[i]);
}
}
public static DeflectionPacket decode(FriendlyByteBuf buffer) {
int count = buffer.readVarInt();
long[] positions = new long[count];
short[] tenths = new short[count];
for (int i = 0; i < count; i++) {
positions[i] = buffer.readLong();
tenths[i] = buffer.readShort();
}
return new DeflectionPacket(positions, tenths);
}
public void handle(Supplier<NetworkEvent.Context> context) {
long[] pos = positions;
short[] values = tenths;
DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> () -> {
for (int i = 0; i < pos.length; i++) {
SurfaceAnimation.set(pos[i], values[i] / 10.0F);
}
});
context.get().setPacketHandled(true);
}
}
@@ -44,6 +44,11 @@ public final class Network {
.decoder(ControlModePacket::decode)
.consumerMainThread(ControlModePacket::handle)
.add();
CHANNEL.messageBuilder(DeflectionPacket.class, 11, NetworkDirection.PLAY_TO_CLIENT)
.encoder(DeflectionPacket::encode)
.decoder(DeflectionPacket::decode)
.consumerMainThread(DeflectionPacket::handle)
.add();
// Higher id than the neighbours so it does not collide with packets added on other branches
CHANNEL.messageBuilder(FlapPacket.class, 10, NetworkDirection.PLAY_TO_SERVER)
.encoder(FlapPacket::encode)
@@ -216,6 +216,18 @@
{
"id": "valkyrien_warium:green_control_surface_offset_top",
"required": false
},
{
"id": "warframes:airframe_control_surface",
"required": false
},
{
"id": "warframes:airframe_half_control_surface",
"required": false
},
{
"id": "warframes:airframe_stepped_control_surface",
"required": false
}
]
}
@@ -6,6 +6,8 @@
"compatibilityLevel": "JAVA_17",
"mixins": [
"futurism.ForceInducedShipsMixin",
"futurism.VectoredAfterburnerSystemProcedureMixin",
"gecko.GeoModelMixin",
"warium.AirBrakeTickProcedureMixin",
"warium.BypassedCompressorScriptProcedureMixin",
"warium.ControlSurfaceTickProcedureMixin",