diff --git a/gradle.properties b/gradle.properties
index a6413fd..044d3bf 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -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.2
+mod_version=0.4.3
azurelib_version=3.2.7
mod_group_id=dev.lumareth.improvedaerodynamics
mod_authors=Lumareth
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/Config.java b/src/main/java/dev/lumareth/improvedAerodynamics/Config.java
index c74a93a..dcb62ad 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/Config.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/Config.java
@@ -239,10 +239,15 @@ public final class Config {
"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 nothing, the surfaces have no effect anyway")
+ 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 {
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClient.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClient.java
index 02d3e78..1d22e10 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClient.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClient.java
@@ -33,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;
@@ -64,6 +65,9 @@ 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
@@ -76,6 +80,8 @@ public final class CursorAimClient {
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
@@ -120,9 +126,51 @@ public final class CursorAimClient {
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()));
@@ -184,6 +232,7 @@ public final class CursorAimClient {
cr = 0.0;
cu = 0.0;
cameraDirection = null;
+ airborneTicks = 0;
offsetYaw = 0.0;
offsetPitch = 0.0;
haveLast = false;
@@ -272,13 +321,14 @@ public final class CursorAimClient {
}
cursorToWorld(basis, rotation);
- // Camera: along the reticle, or turned towards the cursor by the look ahead setting
+ // 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));
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/animation/SurfaceAnimation.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/animation/SurfaceAnimation.java
index 5e08033..e6686d2 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/client/animation/SurfaceAnimation.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/animation/SurfaceAnimation.java
@@ -16,12 +16,23 @@ import software.bernie.geckolib.model.GeoModel;
* bone of Warium's control surface models to them after GeckoLib has played Warium's own fixed animation.
*
* 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 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.
+ * 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.
+ *
+ * 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
@@ -36,6 +47,8 @@ public final class SurfaceAnimation {
}
private static final Long2ObjectOpenHashMap ENTRIES = new Long2ObjectOpenHashMap<>();
+ // Shown angle (degrees) of each Futurism vectored afterburner
+ private static final Long2ObjectOpenHashMap VECTORED = new Long2ObjectOpenHashMap<>();
private SurfaceAnimation() {
}
@@ -53,7 +66,14 @@ public final class SurfaceAnimation {
}
public static void apply(BlockEntity blockEntity, GeoModel> model) {
- if (ENTRIES.isEmpty() || !Config.ANIMATE_SURFACES.get()) {
+ 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());
@@ -65,20 +85,54 @@ public final class SurfaceAnimation {
ENTRIES.remove(blockEntity.getBlockPos().asLong());
return;
}
- // The same block can be drawn more than once a frame (shadows, other passes), the easing steps once per frame
+ 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;
- CoreGeoBone bone = model.getAnimationProcessor().getBone(BONE);
- if (bone != null) {
- bone.setRotX((float) -Math.toRadians(entry.shown));
- }
}
@SubscribeEvent
public static void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) {
ENTRIES.clear();
+ VECTORED.clear();
}
}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleControl.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleControl.java
index d8987d7..6d261b7 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleControl.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleControl.java
@@ -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));
}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java
index 1f1cedb..883b661 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java
@@ -79,6 +79,14 @@ public final class AeroState implements PhysicsBridge.Publishable {
// 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;
@@ -384,7 +392,7 @@ public final class AeroState implements PhysicsBridge.Publishable {
}
// 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,
- liftAcceleration);
+ 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);
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorPilot.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorPilot.java
index c7dec46..22dd2ff 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorPilot.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorPilot.java
@@ -15,6 +15,11 @@ import org.joml.Vector3d;
* bank needs, so the craft turns without losing height;
* 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;
+ * 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;
+ * below {@code minSpeed} there is too little airflow to fly by: the cursor moves elevators and rudder directly;
* 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.
*
@@ -38,6 +43,10 @@ final class CursorPilot {
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);
@@ -48,12 +57,12 @@ 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, double liftAcceleration) {
+ 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 < Config.CURSOR_AIM_MIN_SPEED.get()) {
+ if (aim == null) {
return node;
}
@@ -61,24 +70,10 @@ final class CursorPilot {
double maxRoll = Math.toRadians(Config.CURSOR_AIM_MAX_ROLL_RATE_DEGREES.get());
double maxBank = Math.toRadians(Config.CURSOR_AIM_MAX_BANK_DEGREES.get());
- // 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;
- }
- 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[] f = aim.forward();
double[] r = aim.right();
double[] u = aim.up();
+ 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]));
@@ -102,26 +97,85 @@ final class CursorPilot {
// 270 degree cone
double sideways = Math.atan2(cr, cf);
double vertical = Math.atan2(cu, Math.hypot(cf, cr));
- // Sideways as it would be with the wings level, what the bank is chosen by
- double levelSideways = Math.atan2(cr * Math.cos(bank) + cu * Math.sin(bank), cf);
- double blend = smoothstep(Math.abs(levelSideways), FINE_START, FINE_END);
+ // 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));
- double bankCommand = maxBank * saturate(levelSideways / fullAngle) * blend;
- double rollRate = clamp(Config.CURSOR_AIM_BANK_GAIN.get() * (bankCommand - bank), -maxRoll, maxRoll);
+ 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();
- double pitchRate = maxTurn * (pitchStick >= 0.0 ? pitchStick : pitchStick * push);
+ // 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));
}
- pitchRate = clamp(pitchRate, -maxTurn * push, maxTurn);
- if (wingStalled && pitchRate > 0.0) {
- pitchRate *= Config.CURSOR_AIM_STALLED_PITCH_FACTOR.get();
+ // 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 * saturate(sideways / fullAngle) * (1.0 - blend);
@@ -143,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,
@@ -150,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);
@@ -171,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);
}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/DeflectionSync.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/DeflectionSync.java
index 94c24f3..46bbe96 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/DeflectionSync.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/DeflectionSync.java
@@ -39,8 +39,6 @@ public final class DeflectionSync {
private static final double FULL_INPUT_DEGREES = 20.0;
private static final long RESEND_TICKS = 20;
private static final double RANGE = 256.0;
- // 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 record Batch(ServerLevel level, double x, double y, double z, LongArrayList positions, ShortArrayList tenths) {
}
@@ -71,10 +69,10 @@ public final class DeflectionSync {
}
long nodeKey = nodePos.asLong();
CompoundTag nodeTag = nodeEntity.getPersistentData();
- AeroState.NodeState state = AeroState.nodeState(nodeKey);
- boolean flying = state != null && System.currentTimeMillis() - state.updatedMillis < FRESH_MILLIS;
+ AeroState.NodeState state = LiveInputs.flying(nodeKey);
+ boolean flying = state != null;
- double input = axis(tag.getString("Key"), nodeTag, state, flying) + axis(tag.getString("Key2"), nodeTag, state, flying);
+ 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));
@@ -107,16 +105,9 @@ public final class DeflectionSync {
batch.tenths.add(tenths);
}
- /** One key's share of a surface's input, like Warium reads "Pitch+" / "Roll-". */
- private static double axis(String key, CompoundTag nodeTag, AeroState.NodeState state, boolean flying) {
- ControlAxis axis = ControlAxis.of(key);
- double multiplier = ControlAxis.multiplier(key);
- return switch (axis) {
- case PITCH -> (flying ? state.pitch + state.trimOutput : nodeTag.getDouble("Pitch")) * multiplier;
- case YAW -> (flying ? state.yaw : nodeTag.getDouble("Yaw")) * multiplier;
- case ROLL -> (flying ? state.roll : nodeTag.getDouble("Roll")) * multiplier;
- default -> 0.0;
- };
+ /** 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. */
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/LiveInputs.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/LiveInputs.java
new file mode 100644
index 0000000..48b375e
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/LiveInputs.java
@@ -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);
+ }
+}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/futurism/VectoredAfterburnerSystemProcedureMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/futurism/VectoredAfterburnerSystemProcedureMixin.java
new file mode 100644
index 0000000..da8c3a5
--- /dev/null
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/futurism/VectoredAfterburnerSystemProcedureMixin.java
@@ -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;
+ }
+}
diff --git a/src/main/resources/data/improved_aerodynamics/tags/blocks/flappable.json b/src/main/resources/data/improved_aerodynamics/tags/blocks/flappable.json
index 1f129e2..a4f39e0 100644
--- a/src/main/resources/data/improved_aerodynamics/tags/blocks/flappable.json
+++ b/src/main/resources/data/improved_aerodynamics/tags/blocks/flappable.json
@@ -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
}
]
-}
\ No newline at end of file
+}
diff --git a/src/main/resources/improved_aerodynamics.mixins.json b/src/main/resources/improved_aerodynamics.mixins.json
index 39940dc..c86983d 100644
--- a/src/main/resources/improved_aerodynamics.mixins.json
+++ b/src/main/resources/improved_aerodynamics.mixins.json
@@ -6,6 +6,7 @@
"compatibilityLevel": "JAVA_17",
"mixins": [
"futurism.ForceInducedShipsMixin",
+ "futurism.VectoredAfterburnerSystemProcedureMixin",
"gecko.GeoModelMixin",
"warium.AirBrakeTickProcedureMixin",
"warium.BypassedCompressorScriptProcedureMixin",