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
This commit is contained in:
richard committed 2026-11-13 12:29:23 +01:00
1 parent 3478a04885
commit 80bb63d44f
12 files changed
+366 -59

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ mapping_version=2023.09.03-1.20.1
mod_id=improved_aerodynamics mod_id=improved_aerodynamics
mod_name=VS: Improved Aerodynamics mod_name=VS: Improved Aerodynamics
mod_license=MIT mod_license=MIT
mod_version=0.4.2 mod_version=0.4.3
azurelib_version=3.2.7 azurelib_version=3.2.7
mod_group_id=dev.lumareth.improvedaerodynamics mod_group_id=dev.lumareth.improvedaerodynamics
mod_authors=Lumareth mod_authors=Lumareth
@@ -239,10 +239,15 @@ public final class Config {
"negative g is harder on the craft and the pilot") "negative g is harder on the craft and the pilot")
.defineInRange("pushFactor", 0.4, 0.0, 1.0); .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") 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); .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); .defineInRange("minSpeed", 15.0, 0.0, 200.0);
static { static {
@@ -33,6 +33,7 @@ import net.minecraftforge.fml.ModList;
import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.util.ObfuscationReflectionHelper; import net.minecraftforge.fml.util.ObfuscationReflectionHelper;
import org.joml.Matrix4f; import org.joml.Matrix4f;
import org.joml.primitives.AABBdc;
import org.joml.Quaterniondc; import org.joml.Quaterniondc;
import org.joml.Vector3d; import org.joml.Vector3d;
import org.joml.Vector3f; import org.joml.Vector3f;
@@ -64,6 +65,9 @@ public final class CursorAimClient {
private static final float MARKER_DISTANCE = 2.0F; private static final float MARKER_DISTANCE = 2.0F;
private static final double LOOK_AHEAD_RATE = 6.0; private static final double LOOK_AHEAD_RATE = 6.0;
private static final double FREE_LOOK_RETURN_RATE = 10.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; private static boolean active;
// Chosen with /aero controls, told by the server // Chosen with /aero controls, told by the server
@@ -76,6 +80,8 @@ public final class CursorAimClient {
private static double cu; private static double cu;
// Camera direction in ship space and the free look offset on top of it, degrees // Camera direction in ship space and the free look offset on top of it, degrees
private static double[] cameraDirection; 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 offsetYaw;
private static double offsetPitch; private static double offsetPitch;
// Rotation the player was given last frame, to see what the mouse did since // 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) { if (rotation != null) {
cursorFromWorld(basis, rotation); cursorFromWorld(basis, rotation);
} }
updateAirborne(minecraft.level, seat);
send(minecraft, true); 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. */ /** 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) { private static Quaterniondc shipRotation(ClientLevel level, Entity seat, boolean render) {
Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, BlockPos.containing(seat.getX(), seat.getY(), seat.getZ())); Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, BlockPos.containing(seat.getX(), seat.getY(), seat.getZ()));
@@ -184,6 +232,7 @@ public final class CursorAimClient {
cr = 0.0; cr = 0.0;
cu = 0.0; cu = 0.0;
cameraDirection = null; cameraDirection = null;
airborneTicks = 0;
offsetYaw = 0.0; offsetYaw = 0.0;
offsetPitch = 0.0; offsetPitch = 0.0;
haveLast = false; haveLast = false;
@@ -272,13 +321,14 @@ public final class CursorAimClient {
} }
cursorToWorld(basis, rotation); 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 lookAhead = Config.CURSOR_AIM_LOOK_AHEAD.get();
double[] forward = basis.forward(); double[] forward = basis.forward();
if (cameraDirection == null || lookAhead <= 0.0) { if (cameraDirection == null || lookAhead <= 0.0) {
cameraDirection = forward.clone(); cameraDirection = forward.clone();
} else { } 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)); cameraDirection = turnTowards(cameraDirection, target, 1.0 - Math.exp(-lookAhead * LOOK_AHEAD_RATE * dt));
} }
double[] direction = rotate(cameraDirection, basis.up(), -Math.toRadians(offsetYaw)); double[] direction = rotate(cameraDirection, basis.up(), -Math.toRadians(offsetYaw));
@@ -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. * bone of Warium's control surface models to them after GeckoLib has played Warium's own fixed animation.
* <p> * <p>
* Warium's animations tilt the Fin bone by 20 degrees about X for a positive input, which GeckoLib turns into a * 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 * rotation of -20 degrees (it negates X and Y for rotations), and the same sign is used here, so a surface moves the
* Warium's own animation moves it, only by the amount the physics uses. * 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) @Mod.EventBusSubscriber(modid = dev.lumareth.improvedAerodynamics.ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT)
public final class SurfaceAnimation { public final class SurfaceAnimation {
private static final String BONE = "Fin"; 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 // 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; 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 // 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<Entry> ENTRIES = new Long2ObjectOpenHashMap<>(); 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() { private SurfaceAnimation() {
} }
@@ -53,7 +66,14 @@ public final class SurfaceAnimation {
} }
public static void apply(BlockEntity blockEntity, GeoModel<?> model) { 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; return;
} }
Entry entry = ENTRIES.get(blockEntity.getBlockPos().asLong()); Entry entry = ENTRIES.get(blockEntity.getBlockPos().asLong());
@@ -65,20 +85,54 @@ public final class SurfaceAnimation {
ENTRIES.remove(blockEntity.getBlockPos().asLong()); ENTRIES.remove(blockEntity.getBlockPos().asLong());
return; 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) { if (entry.drawn != 0L && now != entry.drawn) {
double dt = (now - entry.drawn) / 1.0E9; double dt = (now - entry.drawn) / 1.0E9;
entry.shown += (entry.target - entry.shown) * (float) (1.0 - Math.exp(-dt / EASE_SECONDS)); entry.shown += (entry.target - entry.shown) * (float) (1.0 - Math.exp(-dt / EASE_SECONDS));
} }
entry.drawn = now; entry.drawn = now;
CoreGeoBone bone = model.getAnimationProcessor().getBone(BONE);
if (bone != null) {
bone.setRotX((float) -Math.toRadians(entry.shown));
}
} }
@SubscribeEvent @SubscribeEvent
public static void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) { public static void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) {
ENTRIES.clear(); ENTRIES.clear();
VECTORED.clear();
} }
} }
@@ -1,5 +1,7 @@
package dev.lumareth.improvedAerodynamics.exhaust; 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.core.BlockPos;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation; 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 * 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 * 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 * 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 { final class NozzleControl {
private static final ResourceLocation NODE_BLOCK = new ResourceLocation("valkyrien_warium", "vehicle_control_node"); 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 key = own.getString("Key");
String key2 = own.getString("Key2"); String key2 = own.getString("Key2");
boolean unset = (key.isEmpty() || key.equals("Null")) && (key2.isEmpty() || key2.equals("Null")); 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); long nodeKey = nodePos.asLong();
double roll = clamp(tag.getDouble("Roll")) * sign(key, key2, "Roll", 0.0); double pitch = clamp(LiveInputs.value(nodeKey, tag, ControlAxis.PITCH)) * sign(key, key2, "Pitch", unset ? 1.0 : 0.0);
double yaw = clamp(tag.getDouble("Yaw")) * sign(key, key2, "Yaw", 1.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)); return new Inputs(node, pitch, roll, yaw, Math.max(0.0, tag.getDouble("Throttle")), tag.getBoolean(AfterburnerToggle.TAG));
} }
@@ -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, // 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 // measured while cursor aim flies the node. NaN until measured
double controlRate = Double.NaN; 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(); final AutoTrim.Targets trimTargets = new AutoTrim.Targets();
volatile double savedTrim; 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 // 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); liftAcceleration, dt);
state.pitch = moveTowards(state.pitch, effective.pitch(), step); state.pitch = moveTowards(state.pitch, effective.pitch(), step);
state.yaw = moveTowards(state.yaw, effective.yaw(), step); state.yaw = moveTowards(state.yaw, effective.yaw(), step);
state.roll = moveTowards(state.roll, effective.roll(), step); state.roll = moveTowards(state.roll, effective.roll(), step);
@@ -15,6 +15,11 @@ import org.joml.Vector3d;
* bank needs, so the craft turns without losing height;</li> * 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 * <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> * 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 * <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> * aiming does not rock the wings.</li>
* </ul> * </ul>
@@ -38,6 +43,10 @@ final class CursorPilot {
private static final double LIFT_MARGIN = 0.85; 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 // 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); 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 // 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 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 * @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, 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()) { if (!Config.CURSOR_AIM_ENABLED.get()) {
return node; return node;
} }
CursorAimRegistry.Aim aim = CursorAimRegistry.get(node.key()); CursorAimRegistry.Aim aim = CursorAimRegistry.get(node.key());
if (aim == null || speed < Config.CURSOR_AIM_MIN_SPEED.get()) { if (aim == null) {
return node; return node;
} }
@@ -61,24 +70,10 @@ final class CursorPilot {
double maxRoll = Math.toRadians(Config.CURSOR_AIM_MAX_ROLL_RATE_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 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[] f = aim.forward();
double[] r = aim.right(); double[] r = aim.right();
double[] u = aim.up(); double[] u = aim.up();
learnSigns(state, f, r, u);
// Bank of the craft: positive when the right wing is down, through upside down // 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 rightWorld = rotation.transform(new Vector3d(r[0], r[1], r[2]));
@@ -102,26 +97,85 @@ final class CursorPilot {
// 270 degree cone // 270 degree cone
double sideways = Math.atan2(cr, cf); double sideways = Math.atan2(cr, cf);
double vertical = Math.atan2(cu, Math.hypot(cf, cr)); double vertical = Math.atan2(cu, Math.hypot(cf, cr));
// Sideways as it would be with the wings level, what the bank is chosen by // The cursor 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 levelRight = cr * Math.cos(bank) + cu * Math.sin(bank);
double blend = smoothstep(Math.abs(levelSideways), FINE_START, FINE_END); 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; if (speed < Config.CURSOR_AIM_MIN_SPEED.get()) {
double rollRate = clamp(Config.CURSOR_AIM_BANK_GAIN.get() * (bankCommand - bank), -maxRoll, maxRoll); // 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, // 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 // 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 pitchStick = saturate(vertical / fullAngle);
double push = Config.CURSOR_AIM_PUSH_FACTOR.get(); 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); double cosBank = Math.cos(bank);
if (cosBank > 0.0) { if (cosBank > 0.0) {
double sinBank = Math.sin(bank); double sinBank = Math.sin(bank);
pitchRate += GRAVITY * sinBank * sinBank / (speed * Math.max(cosBank, 0.1)); pitchRate += GRAVITY * sinBank * sinBank / (speed * Math.max(cosBank, 0.1));
} }
pitchRate = clamp(pitchRate, -maxTurn * push, maxTurn); // Stalled wings ease the pull back, and it comes back as gently once they fly again: switching it at once with
if (wingStalled && pitchRate > 0.0) { // the stall would make the craft buck in and out of the stall
pitchRate *= Config.CURSOR_AIM_STALLED_PITCH_FACTOR.get(); 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 // Close to the reticle the nose follows the cursor with the rudder
double yawRate = maxTurn * saturate(sideways / fullAngle) * (1.0 - blend); 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 pitch = clamp(inputs[0] - state.trimOutput, -1.0, 1.0);
double yaw = clamp(inputs[1], -1.0, 1.0); double yaw = clamp(inputs[1], -1.0, 1.0);
double roll = clamp(inputs[2], -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(), return new NodeData(node.key(),
Math.abs(node.pitch()) > MANUAL_OVERRIDE ? node.pitch() : pitch, Math.abs(node.pitch()) > MANUAL_OVERRIDE ? node.pitch() : pitch,
Math.abs(node.yaw()) > MANUAL_OVERRIDE ? node.yaw() : yaw, Math.abs(node.yaw()) > MANUAL_OVERRIDE ? node.yaw() : yaw,
@@ -150,6 +209,25 @@ final class CursorPilot {
node.throttle(), node.autoTrim(), node.storedTrim(), node.flapSetting()); 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. */ /** 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) { private static double[] solve(Vector3d pitch, Vector3d yaw, Vector3d roll, Vector3d torque) {
Matrix3d responses = new Matrix3d(pitch, yaw, roll); Matrix3d responses = new Matrix3d(pitch, yaw, roll);
@@ -171,6 +249,11 @@ final class CursorPilot {
return new double[]{x.x, x.y, x.z}; 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) { private static double saturate(double value) {
return clamp(value, -1.0, 1.0); return clamp(value, -1.0, 1.0);
} }
@@ -39,8 +39,6 @@ public final class DeflectionSync {
private static final double FULL_INPUT_DEGREES = 20.0; private static final double FULL_INPUT_DEGREES = 20.0;
private static final long RESEND_TICKS = 20; private static final long RESEND_TICKS = 20;
private static final double RANGE = 256.0; 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) { 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(); long nodeKey = nodePos.asLong();
CompoundTag nodeTag = nodeEntity.getPersistentData(); CompoundTag nodeTag = nodeEntity.getPersistentData();
AeroState.NodeState state = AeroState.nodeState(nodeKey); AeroState.NodeState state = LiveInputs.flying(nodeKey);
boolean flying = state != null && System.currentTimeMillis() - state.updatedMillis < FRESH_MILLIS; 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)); double degrees = FULL_INPUT_DEGREES * Math.max(-1.0, Math.min(1.0, input));
if (type == SurfaceType.CONTROL_SURFACE && tag.getBoolean(WariumCapture.FLAP_TAG)) { if (type == SurfaceType.CONTROL_SURFACE && tag.getBoolean(WariumCapture.FLAP_TAG)) {
degrees += Math.toDegrees(flying ? state.flapAngle : staticFlap(level, nodeKey, nodeTag)); degrees += Math.toDegrees(flying ? state.flapAngle : staticFlap(level, nodeKey, nodeTag));
@@ -107,16 +105,9 @@ public final class DeflectionSync {
batch.tenths.add(tenths); batch.tenths.add(tenths);
} }
/** One key's share of a surface's input, like Warium reads "Pitch+" / "Roll-". */ /** One key's share of a surface's input, like Warium reads "Pitch+" / "Roll-". Throttle keys do not deflect it. */
private static double axis(String key, CompoundTag nodeTag, AeroState.NodeState state, boolean flying) { private static double axisInput(long nodeKey, CompoundTag nodeTag, String key) {
ControlAxis axis = ControlAxis.of(key); return ControlAxis.of(key) == ControlAxis.THROTTLE ? 0.0 : LiveInputs.key(nodeKey, nodeTag, 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;
};
} }
/** Flap angle of a node that is not flying: moves towards the lever's angle, once per tick however many flaps ask. */ /** Flap angle of a node that is not flying: moves towards the lever's angle, once per tick however many flaps ask. */
@@ -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);
}
}
@@ -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;
}
}
@@ -216,6 +216,18 @@
{ {
"id": "valkyrien_warium:green_control_surface_offset_top", "id": "valkyrien_warium:green_control_surface_offset_top",
"required": false "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,7 @@
"compatibilityLevel": "JAVA_17", "compatibilityLevel": "JAVA_17",
"mixins": [ "mixins": [
"futurism.ForceInducedShipsMixin", "futurism.ForceInducedShipsMixin",
"futurism.VectoredAfterburnerSystemProcedureMixin",
"gecko.GeoModelMixin", "gecko.GeoModelMixin",
"warium.AirBrakeTickProcedureMixin", "warium.AirBrakeTickProcedureMixin",
"warium.BypassedCompressorScriptProcedureMixin", "warium.BypassedCompressorScriptProcedureMixin",