From 80bb63d44f5d0638d6d5fd3634c837e3cd7d0879 Mon Sep 17 00:00:00 2001 From: richard Date: Fri, 13 Nov 2026 12:29:23 +0100 Subject: [PATCH] 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 --- gradle.properties | 2 +- .../lumareth/improvedAerodynamics/Config.java | 7 +- .../client/aim/CursorAimClient.java | 54 ++++++- .../client/animation/SurfaceAnimation.java | 70 +++++++-- .../exhaust/NozzleControl.java | 12 +- .../flight/AeroState.java | 10 +- .../flight/CursorPilot.java | 135 ++++++++++++++---- .../flight/DeflectionSync.java | 21 +-- .../flight/LiveInputs.java | 44 ++++++ ...ctoredAfterburnerSystemProcedureMixin.java | 55 +++++++ .../tags/blocks/flappable.json | 14 +- .../improved_aerodynamics.mixins.json | 1 + 12 files changed, 366 insertions(+), 59 deletions(-) create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/flight/LiveInputs.java create mode 100644 src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/futurism/VectoredAfterburnerSystemProcedureMixin.java 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",