diff --git a/gradle.properties b/gradle.properties
index 851ac81..a6413fd 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.0
+mod_version=0.4.2
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 e1ae0e0..c74a93a 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/Config.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/Config.java
@@ -204,6 +204,47 @@ public final class Config {
.comment("The nozzle gives off light while its afterburner is lit")
.define("lightEnabled", true);
+ static {
+ BUILDER.pop();
+ BUILDER.comment("Cursor aim: the mouse moves a cursor on screen, a virtual pilot flies the control node's craft by it").push("cursor_aim");
+ }
+
+ public static final ForgeConfigSpec.BooleanValue CURSOR_AIM_ENABLED = BUILDER.comment("Whether pilots may use cursor aim on this server")
+ .define("enabled", true);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_FULL_DEFLECTION_DEGREES = BUILDER.comment("Angle of the cursor from the gun reticle at which the virtual pilot turns as hard as it can: the maximum bank",
+ "sideways, the full pitch rate up or down. Closer than that it turns more gently, so the reticle settles onto",
+ "the cursor. How fast the craft can turn is worked out from the craft itself")
+ .defineInRange("fullDeflectionDegrees", 30.0, 5.0, 90.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_DEADZONE_DEGREES = BUILDER.comment("Radius around the gun reticle in degrees inside which the pilot stops steering towards the cursor. The reticle",
+ "then settles this far from the cursor. Outside it the command grows from zero, there is no jump at the edge")
+ .defineInRange("deadzoneDegrees", 0.0, 0.0, 15.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MAX_ROLL_RATE_DEGREES = BUILDER.comment("Fastest roll in degrees per second the pilot asks for when it rolls to a bank angle")
+ .defineInRange("maxRollRateDegrees", 150.0, 10.0, 720.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MAX_BANK_DEGREES = BUILDER.comment("Bank angle the pilot rolls to when the cursor is at full sideways deflection, at most. Craft whose wings",
+ "cannot hold that bank in a level turn get less")
+ .defineInRange("maxBankDegrees", 70.0, 10.0, 90.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_BANK_GAIN = BUILDER.comment("Roll rate in rad/s per radian of bank angle error, how eagerly the pilot rolls to the wanted bank and levels the wings")
+ .defineInRange("bankGain", 3.0, 0.2, 20.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_RATE_GAIN = BUILDER.comment("How hard the pilot corrects its turn rate, in angular acceleration per rad/s of rate error. Lower is smoother,",
+ "higher follows the cursor tighter and can start to oscillate")
+ .defineInRange("rateGain", 5.0, 0.5, 40.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_PUSH_FACTOR = BUILDER.comment("A cursor below the gun reticle pushes the nose down. This is how much of the pull rate that may be, since",
+ "negative g is harder on the craft and the pilot")
+ .defineInRange("pushFactor", 0.4, 0.0, 1.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_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")
+ .defineInRange("minSpeed", 15.0, 0.0, 200.0);
+
static {
BUILDER.pop();
}
@@ -225,6 +266,40 @@ public final class Config {
"fixed 20 degree steps. Needs physicsRateAerodynamics on the server")
.define("animateControlSurfaces", true);
+ static {
+ CLIENT_BUILDER.comment("Cursor aim, client side").push("cursor_aim");
+ }
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_LOOK_AHEAD = CLIENT_BUILDER.comment("How strongly the camera looks into the direction of the turn. 0 never moves it, 1 (100%) turns it quickly",
+ "to look where the cursor points. In between it follows more slowly")
+ .defineInRange("lookAhead", 0.0, 0.0, 1.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MOUSE_SENSITIVITY = CLIENT_BUILDER.comment("Multiplier for how far the mouse moves the cursor (or the camera while the free look key is held)")
+ .defineInRange("mouseSensitivity", 1.0, 0.05, 10.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_SPRING_BACK_SECONDS = CLIENT_BUILDER.comment("The cursor drifts to the gun reticle with this time constant in seconds, which ends the turn by itself.",
+ "0, the default, keeps it where you put it in the world, so the craft turns until the reticle is on it")
+ .defineInRange("springBackSeconds", 0.0, 0.0, 10.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MAX_ANGLE_DEGREES = CLIENT_BUILDER.comment("Furthest the cursor can go from the gun reticle, 135 is a 270 degree cone")
+ .defineInRange("maxCursorAngleDegrees", 135.0, 10.0, 180.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_ZOOM_STEP = CLIENT_BUILDER.comment("Blocks the camera moves per mouse wheel notch")
+ .defineInRange("zoomStep", 2.0, 0.1, 20.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_ZOOM_MAX = CLIENT_BUILDER.comment("Furthest the camera can be pulled back from its normal distance, in blocks")
+ .defineInRange("zoomMax", 80.0, 0.0, 500.0);
+
+ public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MARKER_SIZE = CLIENT_BUILDER.comment("Radius of the cursor ring on screen")
+ .defineInRange("markerSize", 0.04, 0.01, 0.3);
+
+ public static final ForgeConfigSpec.BooleanValue CURSOR_AIM_SHOW_RETICLE = CLIENT_BUILDER.comment("Draw a small cross where the gun reticle points, the zero of the cursor, so you can see it when the camera looks away")
+ .define("showReticle", true);
+
+ static {
+ CLIENT_BUILDER.pop();
+ }
+
static final ForgeConfigSpec CLIENT_SPEC = CLIENT_BUILDER.build();
private Config() {
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java
index f984cbb..5ef43ea 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java
@@ -1,11 +1,9 @@
package dev.lumareth.improvedAerodynamics;
import com.mojang.logging.LogUtils;
-import dev.lumareth.improvedAerodynamics.client.aim.CursorAimClientConfig;
import dev.lumareth.improvedAerodynamics.client.pipper.PipperConfig;
import dev.lumareth.improvedAerodynamics.exhaust.ExhaustSync;
import dev.lumareth.improvedAerodynamics.exhaust.NozzleContent;
-import dev.lumareth.improvedAerodynamics.flight.CursorAimConfig;
import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge;
import dev.lumareth.improvedAerodynamics.network.Network;
import dev.lumareth.improvedAerodynamics.terrain.LookaheadLoader;
@@ -34,8 +32,6 @@ public class ImprovedAerodynamics {
ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, Config.CLIENT_SPEC);
// Own file name: the default one is taken by Config.CLIENT_SPEC
ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, PipperConfig.SPEC, "improved_aerodynamics-pipper-client.toml");
- ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, CursorAimConfig.SPEC, "improved_aerodynamics-cursor-aim.toml");
- ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, CursorAimClientConfig.SPEC, "improved_aerodynamics-cursor-aim-client.toml");
NozzleContent.register(FMLJavaModLoadingContext.get().getModEventBus());
Network.register();
}
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 2f9b252..02d3e78 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClient.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClient.java
@@ -1,5 +1,6 @@
package dev.lumareth.improvedAerodynamics.client.aim;
+import dev.lumareth.improvedAerodynamics.Config;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.BufferUploader;
@@ -44,9 +45,10 @@ import java.lang.reflect.Method;
/**
* Cursor aim, the client side. While it is on:
*
- * - the mouse moves a cursor instead of the camera. The cursor is a direction relative to the gun reticle (the
- * craft's boresight), at most {@code maxCursorAngleDegrees} away from it, and springs back to it when the mouse
- * rests. It is sent to the server every tick, where a virtual pilot flies the craft by it;
+ * - the mouse moves a cursor instead of the camera. The cursor is a fixed direction in the world: the craft
+ * turning does not drag it along, so the virtual pilot on the server steers until the gun reticle (the craft's
+ * boresight) sits on the cursor again. It is kept at most {@code maxCursorAngleDegrees} from the reticle and, if
+ * spring back is set, drifts back to it. Every tick it is sent to the server as an offset from the reticle;
* - the camera is a chase view, looking along the reticle, or, with look ahead, towards the cursor;
* - holding the free look key moves the camera instead of the cursor, it eases back when released;
* - the mouse wheel changes how far the camera is behind the craft.
@@ -66,7 +68,9 @@ public final class CursorAimClient {
private static boolean active;
// Chosen with /aero controls, told by the server
private static boolean cursorAimMode;
- // Cursor as components along the craft's forward (gun reticle), right and up axes
+ // Cursor as a world direction, null until the first frame puts it on the reticle
+ private static Vector3d cursorWorld;
+ // The same cursor as components along the craft's forward (gun reticle), right and up axes, as of the last update
private static double cf = 1.0;
private static double cr;
private static double cu;
@@ -109,9 +113,49 @@ public final class CursorAimClient {
deactivate(minecraft);
return;
}
+ // The craft has turned since the last frame: the cursor stays put in the world, its offset changes
+ Entity seat = AimSeat.seatOf(player);
+ AimBasis basis = minecraft.level != null ? AimBasis.of(minecraft.level, seat.blockPosition()) : null;
+ Quaterniondc rotation = basis != null ? shipRotation(minecraft.level, seat, false) : null;
+ if (rotation != null) {
+ cursorFromWorld(basis, rotation);
+ }
send(minecraft, true);
}
+ /** Rotation of the ship the seat is on, its render transform when drawing, null if it is not on a ship. */
+ private static Quaterniondc shipRotation(ClientLevel level, Entity seat, boolean render) {
+ Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, BlockPos.containing(seat.getX(), seat.getY(), seat.getZ()));
+ if (ship == null) {
+ return null;
+ }
+ return render && ship instanceof ClientShip clientShip
+ ? clientShip.getRenderTransform().getShipToWorldRotation() : ship.getTransform().getShipToWorldRotation();
+ }
+
+ /** Sets the cursor's components from its world direction, putting it on the reticle the first time. */
+ private static void cursorFromWorld(AimBasis basis, Quaterniondc rotation) {
+ if (cursorWorld == null) {
+ double[] forward = basis.forward();
+ cursorWorld = rotation.transform(new Vector3d(forward[0], forward[1], forward[2]));
+ }
+ Vector3d ship = rotation.transformInverse(cursorWorld, new Vector3d());
+ double[] f = basis.forward();
+ double[] r = basis.right();
+ double[] u = basis.up();
+ cf = ship.x * f[0] + ship.y * f[1] + ship.z * f[2];
+ cr = ship.x * r[0] + ship.y * r[1] + ship.z * r[2];
+ cu = ship.x * u[0] + ship.y * u[1] + ship.z * u[2];
+ normalizeCursor();
+ limitCursor();
+ }
+
+ /** Stores the cursor's components back as its world direction. */
+ private static void cursorToWorld(AimBasis basis, Quaterniondc rotation) {
+ double[] ship = basis.toShip(cf, cr, cu);
+ cursorWorld = rotation.transform(new Vector3d(ship[0], ship[1], ship[2]));
+ }
+
/** Called when the server tells the client how its player flies. */
public static void setMode(boolean cursorAim) {
cursorAimMode = cursorAim;
@@ -135,6 +179,7 @@ public final class CursorAimClient {
return;
}
active = true;
+ cursorWorld = null;
cf = 1.0;
cr = 0.0;
cu = 0.0;
@@ -184,7 +229,8 @@ public final class CursorAimClient {
}
Entity seat = AimSeat.seatOf(player);
AimBasis basis = AimBasis.of(level, seat.blockPosition());
- if (basis == null) {
+ Quaterniondc rotation = basis != null ? shipRotation(level, seat, true) : null;
+ if (basis == null || rotation == null) {
return;
}
if (minecraft.options.getCameraType() != CameraType.THIRD_PERSON_BACK) {
@@ -207,10 +253,11 @@ public final class CursorAimClient {
deltaPitch = 0.0;
}
}
- double sensitivity = CursorAimClientConfig.MOUSE_SENSITIVITY.get();
+ double sensitivity = Config.CURSOR_AIM_MOUSE_SENSITIVITY.get();
boolean freeLook = minecraft.screen == null && AimKeys.FREE_LOOK.isDown();
- double spring = CursorAimClientConfig.SPRING_BACK_SECONDS.get();
+ cursorFromWorld(basis, rotation);
+ double spring = Config.CURSOR_AIM_SPRING_BACK_SECONDS.get();
if (spring > 0.0) {
shrinkCursor(Math.exp(-dt / spring));
}
@@ -223,9 +270,10 @@ public final class CursorAimClient {
offsetYaw *= decay;
offsetPitch *= decay;
}
+ cursorToWorld(basis, rotation);
// Camera: along the reticle, or turned towards the cursor by the look ahead setting
- double lookAhead = CursorAimClientConfig.LOOK_AHEAD.get();
+ double lookAhead = Config.CURSOR_AIM_LOOK_AHEAD.get();
double[] forward = basis.forward();
if (cameraDirection == null || lookAhead <= 0.0) {
cameraDirection = forward.clone();
@@ -295,7 +343,7 @@ public final class CursorAimClient {
}
private static void limitCursor() {
- double limit = Math.toRadians(CursorAimClientConfig.MAX_CURSOR_ANGLE_DEGREES.get());
+ double limit = Math.toRadians(Config.CURSOR_AIM_MAX_ANGLE_DEGREES.get());
double angle = Math.acos(Mth.clamp(cf, -1.0, 1.0));
if (angle <= limit) {
return;
@@ -318,7 +366,7 @@ public final class CursorAimClient {
if (!active || minecraft.screen != null) {
return;
}
- zoom = Mth.clamp(zoom - event.getScrollDelta() * CursorAimClientConfig.ZOOM_STEP.get(), -3.0, CursorAimClientConfig.ZOOM_MAX.get());
+ zoom = Mth.clamp(zoom - event.getScrollDelta() * Config.CURSOR_AIM_ZOOM_STEP.get(), -3.0, Config.CURSOR_AIM_ZOOM_MAX.get());
event.setCanceled(true);
}
@@ -354,14 +402,10 @@ public final class CursorAimClient {
}
Entity seat = AimSeat.seatOf(player);
AimBasis basis = AimBasis.of(level, seat.blockPosition());
- Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, BlockPos.containing(seat.getX(), seat.getY(), seat.getZ()));
- if (basis == null || ship == null) {
+ Quaterniondc rotation = basis != null ? shipRotation(level, seat, true) : null;
+ if (rotation == null || cursorWorld == null) {
return;
}
- Quaterniondc rotation = ship instanceof ClientShip clientShip
- ? clientShip.getRenderTransform().getShipToWorldRotation() : ship.getTransform().getShipToWorldRotation();
- double[] cursorShip = basis.toShip(cf, cr, cu);
- Vector3d cursorWorld = rotation.transform(new Vector3d(cursorShip[0], cursorShip[1], cursorShip[2]));
double[] forward = basis.forward();
Vector3d reticleWorld = rotation.transform(new Vector3d(forward[0], forward[1], forward[2]));
@@ -375,8 +419,8 @@ public final class CursorAimClient {
RenderSystem.setShader(GameRenderer::getPositionColorShader);
builder.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR);
- float size = (float) (CursorAimClientConfig.MARKER_SIZE.get() * MARKER_DISTANCE);
- if (CursorAimClientConfig.SHOW_RETICLE.get()) {
+ float size = (float) (Config.CURSOR_AIM_MARKER_SIZE.get() * MARKER_DISTANCE);
+ if (Config.CURSOR_AIM_SHOW_RETICLE.get()) {
plus(builder, matrix, camera, reticleWorld, size * 0.35F, RETICLE_COLOR);
}
ring(builder, matrix, camera, cursorWorld, size, CURSOR_COLOR);
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClientConfig.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClientConfig.java
deleted file mode 100644
index 3a92576..0000000
--- a/src/main/java/dev/lumareth/improvedAerodynamics/client/aim/CursorAimClientConfig.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package dev.lumareth.improvedAerodynamics.client.aim;
-
-import net.minecraftforge.common.ForgeConfigSpec;
-
-/** Client side settings of cursor aim. Lives in improved_aerodynamics-cursor-aim-client.toml. */
-public final class CursorAimClientConfig {
- public static final ForgeConfigSpec SPEC;
-
- public static final ForgeConfigSpec.DoubleValue LOOK_AHEAD;
- public static final ForgeConfigSpec.DoubleValue MOUSE_SENSITIVITY;
- public static final ForgeConfigSpec.DoubleValue SPRING_BACK_SECONDS;
- public static final ForgeConfigSpec.DoubleValue MAX_CURSOR_ANGLE_DEGREES;
- public static final ForgeConfigSpec.DoubleValue ZOOM_STEP;
- public static final ForgeConfigSpec.DoubleValue ZOOM_MAX;
- public static final ForgeConfigSpec.DoubleValue MARKER_SIZE;
- public static final ForgeConfigSpec.BooleanValue SHOW_RETICLE;
-
- static {
- ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder();
- builder.comment("Cursor aim, client side").push("cursor_aim");
- LOOK_AHEAD = builder.comment("How strongly the camera looks into the direction of the turn. 0 never moves it, 1 (100%) turns it quickly",
- "to look where the cursor points. In between it follows more slowly")
- .defineInRange("lookAhead", 0.0, 0.0, 1.0);
- MOUSE_SENSITIVITY = builder.comment("Multiplier for how far the mouse moves the cursor (or the camera while the free look key is held)")
- .defineInRange("mouseSensitivity", 1.0, 0.05, 10.0);
- SPRING_BACK_SECONDS = builder.comment("The cursor returns to the gun reticle with this time constant in seconds when you stop moving the mouse.",
- "0 makes it stay where you left it")
- .defineInRange("springBackSeconds", 1.0, 0.0, 10.0);
- MAX_CURSOR_ANGLE_DEGREES = builder.comment("Furthest the cursor can go from the gun reticle, 135 is a 270 degree cone")
- .defineInRange("maxCursorAngleDegrees", 135.0, 10.0, 180.0);
- ZOOM_STEP = builder.comment("Blocks the camera moves per mouse wheel notch")
- .defineInRange("zoomStep", 2.0, 0.1, 20.0);
- ZOOM_MAX = builder.comment("Furthest the camera can be pulled back from its normal distance, in blocks")
- .defineInRange("zoomMax", 80.0, 0.0, 500.0);
- MARKER_SIZE = builder.comment("Radius of the cursor ring on screen")
- .defineInRange("markerSize", 0.04, 0.01, 0.3);
- SHOW_RETICLE = builder.comment("Draw a small cross where the gun reticle points, the zero of the cursor, so you can see it when the camera looks away")
- .define("showReticle", true);
- builder.pop();
- SPEC = builder.build();
- }
-
- private CursorAimClientConfig() {
- }
-}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java
index 9c08e43..1f1cedb 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AeroState.java
@@ -27,6 +27,8 @@ public final class AeroState implements PhysicsBridge.Publishable {
private static final double INPUT_RATE_PER_TICK = 0.2;
private static final double VELOCITY_EPSILON = 0.05;
private static final double ANGULAR_EPSILON = 0.005;
+ // Time constant of the smoothing of measured control rates
+ private static final double RATE_SMOOTHING_SECONDS = 0.3;
private static final ControlAxis[] MEASURED_AXES = {ControlAxis.PITCH, ControlAxis.YAW, ControlAxis.ROLL};
private final SubstepClock clock;
@@ -41,6 +43,9 @@ public final class AeroState implements PhysicsBridge.Publishable {
private Frame current;
private boolean[] stalled = new boolean[0];
private boolean wingStalled;
+ // Lift the wings could make at the edge of the stall in the airflow of the last substep, NaN without the improved
+ // flight model, which has no stall
+ private double maxLift = Double.NaN;
private final Map nodeStates = new ConcurrentHashMap<>();
// Every ship's node states by node position, for the control surface animation on the server thread
private static final Map ALL_NODE_STATES = new ConcurrentHashMap<>();
@@ -71,6 +76,9 @@ public final class AeroState implements PhysicsBridge.Publishable {
final Vector3d yawResponseBuilding = new Vector3d();
final Vector3d rollResponse = new Vector3d();
final Vector3d rollResponseBuilding = new Vector3d();
+ // Fastest steady pitch rate (rad/s) the pitch surfaces can hold against the craft's pitch damping, smoothed,
+ // measured while cursor aim flies the node. NaN until measured
+ double controlRate = Double.NaN;
final AutoTrim.Targets trimTargets = new AutoTrim.Targets();
volatile double savedTrim;
@@ -155,9 +163,14 @@ public final class AeroState implements PhysicsBridge.Publishable {
double dt = clock.substepSeconds();
boolean improved = Config.IMPROVED_FLIGHT_MODEL.get();
Matrix3dc inertia = ship.getMomentOfInertia();
+ // Same frames and scaling as Warium applies its forces in
+ double scale = transform.getShipToWorldScaling().x();
+ double forceScale = autoScale ? scale * scale * scale : 1.0;
+ double liftAcceleration = maxLift * forceScale / ship.getMass();
- updateNodes(frame, worldVelocity, transform.getPositionInWorld().y(), rotation, inertia, w, dt, improved);
+ updateNodes(frame, worldVelocity, transform.getPositionInWorld().y(), rotation, inertia, w, dt, improved, liftAcceleration);
evaluate(frame, centerOfMass, v, w, improved, true, wrench);
+ measureControlRates(frame, centerOfMass, v, w, improved, dt);
if (Config.IMPLICIT_DAMPING.get()) {
// Implicit Euler for the damping part: scale each axis by 1 / (1 + dt * damping / inertia), using the
@@ -184,9 +197,6 @@ public final class AeroState implements PhysicsBridge.Publishable {
}
}
- // Same frames and scaling as Warium applies its forces in
- double scale = transform.getShipToWorldScaling().x();
- double forceScale = autoScale ? scale * scale * scale : 1.0;
Vector3d force = new Vector3d(wrench.force[0], wrench.force[1], wrench.force[2]).mul(forceScale);
Vector3d forceTorque = new Vector3d(wrench.forceTorque[0], wrench.forceTorque[1], wrench.forceTorque[2]).mul(forceScale);
if (force.isFinite() && forceTorque.isFinite()) {
@@ -239,6 +249,7 @@ public final class AeroState implements PhysicsBridge.Publishable {
double[] r = new double[3];
if (commit) {
wingStalled = false;
+ maxLift = improved ? 0.0 : Double.NaN;
for (NodeState state : nodeStates.values()) {
state.pitchResponseBuilding.zero();
state.yawResponseBuilding.zero();
@@ -261,6 +272,9 @@ public final class AeroState implements PhysicsBridge.Publishable {
if (commit) {
stalled[i] = nowStalled;
wingStalled |= nowStalled && s.type() == SurfaceType.WING;
+ if (s.type() == SurfaceType.WING || s.type() == SurfaceType.CONTROL_SURFACE && s.wingGroup()) {
+ maxLift += ImprovedAero.maxLift(s, v, w, r, flap, weight, tuning);
+ }
}
} else {
WariumAero.evaluate(s, v, w, r, input, weight, out);
@@ -306,6 +320,37 @@ public final class AeroState implements PhysicsBridge.Publishable {
}
}
+ /**
+ * For every node cursor aim flies: the steady pitch rate its pitch surfaces can hold, full deflection torque over
+ * the craft's pitch damping, both measured about the axis the pitch surfaces turn the craft about.
+ */
+ private void measureControlRates(Frame frame, Vector3dc centerOfMass, double[] v, double[] w, boolean improved, double dt) {
+ for (NodeData node : frame.nodes.values()) {
+ NodeState state = nodeStates.get(node.key());
+ if (state == null || !CursorAimRegistry.isActive(node.key())) {
+ continue;
+ }
+ double response = state.pitchResponse.length();
+ if (response < 1.0E-9) {
+ continue;
+ }
+ Vector3d axis = state.pitchResponse.div(response, new Vector3d());
+ double[] turned = {w[0] + ANGULAR_EPSILON * axis.x, w[1] + ANGULAR_EPSILON * axis.y, w[2] + ANGULAR_EPSILON * axis.z};
+ evaluate(frame, centerOfMass, v, turned, improved, false, probe);
+ double damping = 0.0;
+ for (int i = 0; i < 3; i++) {
+ damping -= (probe.totalTorque(i) - wrench.totalTorque(i)) * axis.get(i);
+ }
+ damping /= ANGULAR_EPSILON;
+ if (!(damping > 1.0E-9)) {
+ continue;
+ }
+ double rate = response / damping;
+ state.controlRate = Double.isNaN(state.controlRate) ? rate
+ : state.controlRate + (rate - state.controlRate) * Math.min(1.0, dt / RATE_SMOOTHING_SECONDS);
+ }
+ }
+
/** A control surface's input, combined from its two keys and clamped like Warium does. */
private static double input(AeroSurface s, NodeData node, NodeState state, ControlAxis nudged, double extra) {
if (node == null || state == null) {
@@ -328,7 +373,7 @@ public final class AeroState implements PhysicsBridge.Publishable {
}
private void updateNodes(Frame frame, Vector3dc worldVelocity, double altitude, Quaterniondc rotation, Matrix3dc inertia,
- double[] w, double dt, boolean improved) {
+ double[] w, double dt, boolean improved, double liftAcceleration) {
double step = Config.CONTROL_SMOOTHING.get() ? INPUT_RATE_PER_TICK * clock.weight() : Double.POSITIVE_INFINITY;
// Torque of the last substep, in ship axes, which the flight computer separates from its own surfaces' share
double[] torque = {wrench.totalTorque(0), wrench.totalTorque(1), wrench.totalTorque(2)};
@@ -338,7 +383,8 @@ public final class AeroState implements PhysicsBridge.Publishable {
continue;
}
// Cursor aim, if the pilot has it on, supplies the inputs the pilot is not giving with the keys
- NodeData effective = CursorPilot.apply(state, node, rotation, inertia, w, worldVelocity.length(), wingStalled);
+ NodeData effective = CursorPilot.apply(state, node, rotation, inertia, w, worldVelocity.length(), wingStalled,
+ liftAcceleration);
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/CursorAimConfig.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimConfig.java
deleted file mode 100644
index 0efd188..0000000
--- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimConfig.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package dev.lumareth.improvedAerodynamics.flight;
-
-import net.minecraftforge.common.ForgeConfigSpec;
-
-/** Server side settings of the cursor aim virtual pilot. Lives in improved_aerodynamics-cursor-aim.toml. */
-public final class CursorAimConfig {
- public static final ForgeConfigSpec SPEC;
-
- public static final ForgeConfigSpec.BooleanValue ENABLED;
- public static final ForgeConfigSpec.DoubleValue FULL_DEFLECTION_DEGREES;
- public static final ForgeConfigSpec.DoubleValue DEADZONE_DEGREES;
- public static final ForgeConfigSpec.DoubleValue MAX_TURN_RATE_DEGREES;
- public static final ForgeConfigSpec.DoubleValue MAX_ROLL_RATE_DEGREES;
- public static final ForgeConfigSpec.DoubleValue MAX_BANK_DEGREES;
- public static final ForgeConfigSpec.DoubleValue BANK_GAIN;
- public static final ForgeConfigSpec.DoubleValue RATE_GAIN;
- public static final ForgeConfigSpec.DoubleValue PUSH_FACTOR;
- public static final ForgeConfigSpec.DoubleValue STALLED_PITCH_FACTOR;
- public static final ForgeConfigSpec.DoubleValue MIN_SPEED;
-
- static {
- ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder();
- builder.comment("Cursor aim: the mouse moves a cursor on screen, a virtual pilot flies the control node's craft by it").push("cursor_aim");
- ENABLED = builder.comment("Whether pilots may use cursor aim on this server")
- .define("enabled", true);
- FULL_DEFLECTION_DEGREES = builder.comment("Angle of the cursor from the gun reticle at which the virtual pilot asks for full deflection: the maximum bank",
- "sideways, the full pitch rate up or down")
- .defineInRange("fullDeflectionDegrees", 30.0, 5.0, 90.0);
- DEADZONE_DEGREES = builder.comment("Radius around the gun reticle in degrees inside which the cursor commands nothing, so the craft flies straight.",
- "Outside it the command grows from zero, there is no jump at the edge")
- .defineInRange("deadzoneDegrees", 3.0, 0.0, 15.0);
- MAX_TURN_RATE_DEGREES = builder.comment("Turn rate in degrees per second the pilot asks for at full deflection, both for the turn at maximum bank and for pitching")
- .defineInRange("maxTurnRateDegrees", 40.0, 5.0, 360.0);
- MAX_ROLL_RATE_DEGREES = builder.comment("Fastest roll in degrees per second the pilot asks for when it rolls to a bank angle")
- .defineInRange("maxRollRateDegrees", 150.0, 10.0, 720.0);
- MAX_BANK_DEGREES = builder.comment("Bank angle the pilot rolls to when the cursor is at full sideways deflection")
- .defineInRange("maxBankDegrees", 70.0, 10.0, 90.0);
- BANK_GAIN = builder.comment("Roll rate in rad/s per radian of bank angle error, how eagerly the pilot rolls to the wanted bank and levels the wings")
- .defineInRange("bankGain", 3.0, 0.2, 20.0);
- RATE_GAIN = builder.comment("How hard the pilot corrects its turn rate, in angular acceleration per rad/s of rate error. Lower is smoother,",
- "higher follows the cursor tighter and can start to oscillate")
- .defineInRange("rateGain", 5.0, 0.5, 40.0);
- PUSH_FACTOR = builder.comment("A cursor below the gun reticle pushes the nose down. This is how much of the pull rate that may be, since",
- "negative g is harder on the craft and the pilot")
- .defineInRange("pushFactor", 0.4, 0.0, 1.0);
- STALLED_PITCH_FACTOR = builder.comment("Multiplier for the pitch rate the pilot asks for while the wings are stalled, so it does not keep pulling")
- .defineInRange("stalledPitchFactor", 0.3, 0.0, 1.0);
- MIN_SPEED = builder.comment("Below this speed in blocks per second the pilot does nothing, the surfaces have no effect anyway")
- .defineInRange("minSpeed", 15.0, 0.0, 200.0);
- builder.pop();
- SPEC = builder.build();
- }
-
- private CursorAimConfig() {
- }
-}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimServer.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimServer.java
index 5d715ea..2bdb473 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimServer.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorAimServer.java
@@ -1,5 +1,6 @@
package dev.lumareth.improvedAerodynamics.flight;
+import dev.lumareth.improvedAerodynamics.Config;
import net.mcreator.valkyrienwarium.entity.SeatEntityEntity;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
@@ -24,7 +25,7 @@ public final class CursorAimServer {
}
long nodeKey = basis.nodePos().asLong();
double length = Math.sqrt(cf * cf + cr * cr + cu * cu);
- if (!active || !CursorAimConfig.ENABLED.get() || !(length > 1.0E-6) || !Double.isFinite(length)) {
+ if (!active || !Config.CURSOR_AIM_ENABLED.get() || !(length > 1.0E-6) || !Double.isFinite(length)) {
CursorAimRegistry.clear(nodeKey);
return;
}
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorPilot.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorPilot.java
index c3c2ab9..c7dec46 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorPilot.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/CursorPilot.java
@@ -1,29 +1,45 @@
package dev.lumareth.improvedAerodynamics.flight;
+import dev.lumareth.improvedAerodynamics.Config;
import org.joml.Matrix3d;
import org.joml.Matrix3dc;
import org.joml.Quaterniondc;
import org.joml.Vector3d;
/**
- * The virtual pilot of cursor aim. The cursor is a stick that springs back to the gun reticle:
+ * The virtual pilot of cursor aim. The cursor is a direction fixed in the world, and the pilot turns the craft until
+ * the gun reticle sits on it, so the offset it gets shrinks to nothing as the turn completes:
*
- * - sideways it commands a bank angle and turn: the further out, the steeper the bank and the harder the pull
- * that comes with it. Centered it levels the wings, so a centered cursor is straight flight;
- * - up and down it commands a pitch rate;
- * - close to the reticle it steers with yaw and pitch directly instead of banking, so aiming does not rock the
- * wings.
+ * - it banks towards the cursor, judged with the wings level so the bank does not hide where the cursor is: the
+ * further the cursor is to the side, the steeper the bank. While banked it pulls as much as a level turn at that
+ * bank needs, so the craft turns without losing height;
+ * - 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;
+ * - 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.
*
* The wanted rotation is turned into stick inputs by the measured effect of each input on the craft (torque per unit
* of input, see {@link AeroState}), so it works for any craft however its surfaces are mixed or oriented: angular
* acceleration wanted times inertia, solved for the three inputs with a little regularization for axes that have no
* surfaces. Keys the pilot holds win over it, per axis.
+ *
+ * How hard the craft can turn is worked out live, not configured: the lift its wings can make at the edge of the stall
+ * at the current speed and altitude, over its mass and speed, and the steady pitch rate its pitch surfaces can hold
+ * against its pitch damping, whichever is lower. The same lift limit caps the bank to what the craft can hold level, so
+ * a heavy bomber banks gently and turns wide while a light fighter racks into a steep, tight turn.
*/
final class CursorPilot {
private static final double FINE_START = Math.toRadians(4.0);
private static final double FINE_END = Math.toRadians(16.0);
private static final double MANUAL_OVERRIDE = 0.05;
- private static final double MAX_PULL_BANK = Math.toRadians(90.0);
+ // Gravity of VS
+ private static final double GRAVITY = 10.0;
+ // Share of the stall limited lift the pilot plans with, so it does not ride the stall
+ private static final double LIFT_MARGIN = 0.85;
+ // Bank the pilot rolls to at full sideways deflection even when the wings cannot hold the turn level
+ private static final double MIN_BANK = Math.toRadians(20.0);
+ // Turn rate when neither limit can be measured, like the first moments of a flight
+ private static final double FALLBACK_TURN_RATE = Math.toRadians(30.0);
private CursorPilot() {
}
@@ -32,57 +48,83 @@ final class CursorPilot {
* @return the node with the pilot's wanted inputs in place of the keys, or the node itself if cursor aim is off
*/
static NodeData apply(AeroState.NodeState state, NodeData node, Quaterniondc rotation, Matrix3dc inertia, double[] w,
- double speed, boolean wingStalled) {
- if (!CursorAimConfig.ENABLED.get()) {
+ double speed, boolean wingStalled, double liftAcceleration) {
+ if (!Config.CURSOR_AIM_ENABLED.get()) {
return node;
}
CursorAimRegistry.Aim aim = CursorAimRegistry.get(node.key());
- if (aim == null || speed < CursorAimConfig.MIN_SPEED.get()) {
+ if (aim == null || speed < Config.CURSOR_AIM_MIN_SPEED.get()) {
return node;
}
- double fullAngle = Math.toRadians(CursorAimConfig.FULL_DEFLECTION_DEGREES.get());
- double maxTurn = Math.toRadians(CursorAimConfig.MAX_TURN_RATE_DEGREES.get());
- double maxRoll = Math.toRadians(CursorAimConfig.MAX_ROLL_RATE_DEGREES.get());
- double maxBank = Math.toRadians(CursorAimConfig.MAX_BANK_DEGREES.get());
+ double fullAngle = Math.toRadians(Config.CURSOR_AIM_FULL_DEFLECTION_DEGREES.get());
+ double maxRoll = Math.toRadians(Config.CURSOR_AIM_MAX_ROLL_RATE_DEGREES.get());
+ double maxBank = Math.toRadians(Config.CURSOR_AIM_MAX_BANK_DEGREES.get());
+
+ // 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();
- // Cursor as angles from the reticle: sideways (positive right) and up, both through any 270 degree cone
- double sideways = Math.atan2(aim.cr(), aim.cf());
- double vertical = Math.atan2(aim.cu(), Math.hypot(aim.cf(), aim.cr()));
- // Deadzone around the reticle: shrink the cursor offset by its radius, direction kept
- double offset = Math.hypot(sideways, vertical);
- double deadzone = Math.toRadians(CursorAimConfig.DEADZONE_DEGREES.get());
- if (offset > 1.0E-9) {
- double scale = Math.max(0.0, offset - deadzone) / offset;
- sideways *= scale;
- vertical *= scale;
- }
- double lateral = saturate(sideways / fullAngle);
- double blend = smoothstep(Math.abs(sideways), FINE_START, FINE_END);
-
// Bank of the craft: positive when the right wing is down, through upside down
Vector3d rightWorld = rotation.transform(new Vector3d(r[0], r[1], r[2]));
Vector3d upWorld = rotation.transform(new Vector3d(u[0], u[1], u[2]));
double bank = Math.atan2(-rightWorld.y, upWorld.y);
- double bankCommand = maxBank * lateral * blend;
- double rollRate = clamp(CursorAimConfig.BANK_GAIN.get() * (bankCommand - bank), -maxRoll, maxRoll);
-
- // Pitch: the stick, plus the pull that carries the turn while banked
- double pitchStick = saturate(vertical / fullAngle);
- double pitchRate = maxTurn * (pitchStick >= 0.0 ? pitchStick : pitchStick * CursorAimConfig.PUSH_FACTOR.get());
- if (Math.abs(bank) < MAX_PULL_BANK) {
- pitchRate += maxTurn * Math.abs(lateral) * blend * Math.sin(Math.abs(bank));
+ // Deadzone around the reticle: shrink the cursor's offset by its radius, direction kept
+ double cf = aim.cf();
+ double cr = aim.cr();
+ double cu = aim.cu();
+ double deadzone = Math.toRadians(Config.CURSOR_AIM_DEADZONE_DEGREES.get());
+ double offset = Math.acos(clamp(cf, -1.0, 1.0));
+ double across = Math.hypot(cr, cu);
+ if (deadzone > 0.0 && offset > 1.0E-9 && across > 1.0E-9) {
+ double shrunk = Math.max(0.0, offset - deadzone);
+ cf = Math.cos(shrunk);
+ cr *= Math.sin(shrunk) / across;
+ cu *= Math.sin(shrunk) / across;
}
+ // Cursor as angles from the reticle as seen from the cockpit: sideways (positive right) and up, through any
+ // 270 degree cone
+ double sideways = Math.atan2(cr, cf);
+ double vertical = Math.atan2(cu, Math.hypot(cf, cr));
+ // 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);
+
+ double bankCommand = maxBank * saturate(levelSideways / fullAngle) * blend;
+ double rollRate = clamp(Config.CURSOR_AIM_BANK_GAIN.get() * (bankCommand - bank), -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);
+ 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 *= CursorAimConfig.STALLED_PITCH_FACTOR.get();
+ pitchRate *= Config.CURSOR_AIM_STALLED_PITCH_FACTOR.get();
}
// Close to the reticle the nose follows the cursor with the rudder
- double yawRate = maxTurn * lateral * (1.0 - blend);
+ double yawRate = maxTurn * saturate(sideways / fullAngle) * (1.0 - blend);
// Wanted angular velocity in ship axes: roll about forward, pitch about right, yaw (nose right) about -up
Vector3d wanted = new Vector3d(
@@ -90,7 +132,7 @@ final class CursorPilot {
rollRate * f[1] + pitchRate * r[1] - yawRate * u[1],
rollRate * f[2] + pitchRate * r[2] - yawRate * u[2]);
Vector3d error = wanted.sub(w[0], w[1], w[2]);
- Vector3d acceleration = error.mul(CursorAimConfig.RATE_GAIN.get());
+ Vector3d acceleration = error.mul(Config.CURSOR_AIM_RATE_GAIN.get());
Vector3d torque = inertia.transform(acceleration, new Vector3d());
double[] inputs = solve(state.pitchResponse, state.yawResponse, state.rollResponse, torque);
diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/ImprovedAero.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/ImprovedAero.java
index 9785f80..1e75540 100644
--- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/ImprovedAero.java
+++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/ImprovedAero.java
@@ -129,11 +129,8 @@ final class ImprovedAero {
// Low aspect ratio wings keep their flow attached longer thanks to leading-edge vortices. Control surfaces
// in a wing's group stall with the wing instead of before it
- double vortexFactor = tuning.vortex() && (s.type() == SurfaceType.WING || s.wingGroup() && s.type().controllable)
- ? VortexLift.factor(s.aspectRatio()) : 0.0;
- double baseStall = s.slab() ? SurfaceType.SLAB_STALL_ANGLE
- : s.wingGroup() && s.type().controllable ? SurfaceType.WING.stallAngle : s.type().stallAngle;
- double critical = Math.toRadians(baseStall) * (1.0 + tuning.vortexStallBonus() * vortexFactor) * tuning.stallAngleScale();
+ double vortexFactor = vortexFactor(s, tuning);
+ double critical = criticalAngle(s, tuning, vortexFactor);
double absAlpha = Math.abs(alpha);
if (alpha >= 0.0) {
critical += FLAP_STALL_GAIN * flapShift;
@@ -149,9 +146,8 @@ final class ImprovedAero {
// Finite wing theory: lift slope and induced drag follow from the wing's aspect ratio
double aspectRatio = s.aspectRatio();
- double slope = aspectRatio > 0.0 ? 2.0 * Math.PI / (1.0 + 2.0 / aspectRatio) : LIFT_SLOPE;
double inducedDrag = aspectRatio > 0.0 ? 1.0 / (Math.PI * EFFICIENCY * aspectRatio) : INDUCED_DRAG;
- double attachedLift = slope * alpha;
+ double attachedLift = liftSlope(s) * alpha;
if (s.type() == SurfaceType.WING) {
attachedLift += VortexLift.liftCoefficient(alpha, vortexFactor, tuning.vortexLiftStrength());
}
@@ -175,6 +171,44 @@ final class ImprovedAero {
return stalled;
}
+ /**
+ * Lift (along the surface's normal) the surface would make in the airflow it sees now at the highest angle of
+ * attack it can fly without starting to stall, flaps included. Only meaningful for horizontal surfaces.
+ */
+ static double maxLift(AeroSurface s, double[] v, double[] w, double[] r, double flap, double weight, Tuning tuning) {
+ double vx = v[0] + w[1] * r[2] - w[2] * r[1];
+ double vy = v[1] + w[2] * r[0] - w[0] * r[2];
+ double vz = v[2] + w[0] * r[1] - w[1] * r[0];
+ double u = vx * s.facingX() + vz * s.facingZ();
+ double planeSpeedSq = u * u + vy * vy;
+ double vortexFactor = vortexFactor(s, tuning);
+ double flapShift = FLAP_ALPHA_FACTOR * flap;
+ double alpha = criticalAngle(s, tuning, vortexFactor) + FLAP_STALL_GAIN * flapShift - STALL_BLEND;
+ double lift = liftSlope(s) * alpha;
+ if (s.type() == SurfaceType.WING) {
+ lift += VortexLift.liftCoefficient(alpha, vortexFactor, tuning.vortexLiftStrength());
+ }
+ return LIFT_CONSTANT * s.pressure() * weight * s.fluidFactor() * planeSpeedSq * lift * tuning.liftScale();
+ }
+
+ private static double vortexFactor(AeroSurface s, Tuning tuning) {
+ return tuning.vortex() && (s.type() == SurfaceType.WING || s.wingGroup() && s.type().controllable)
+ ? VortexLift.factor(s.aspectRatio()) : 0.0;
+ }
+
+ /** Angle of attack (radians, camber and flaps included) at which the surface starts to stall, without flaps. */
+ private static double criticalAngle(AeroSurface s, Tuning tuning, double vortexFactor) {
+ double baseStall = s.slab() ? SurfaceType.SLAB_STALL_ANGLE
+ : s.wingGroup() && s.type().controllable ? SurfaceType.WING.stallAngle : s.type().stallAngle;
+ return Math.toRadians(baseStall) * (1.0 + tuning.vortexStallBonus() * vortexFactor) * tuning.stallAngleScale();
+ }
+
+ /** Lift slope per radian from finite wing theory, from the wing's aspect ratio. */
+ private static double liftSlope(AeroSurface s) {
+ double aspectRatio = s.aspectRatio();
+ return aspectRatio > 0.0 ? 2.0 * Math.PI / (1.0 + 2.0 / aspectRatio) : LIFT_SLOPE;
+ }
+
static double smoothstep(double edge0, double edge1, double x) {
double t = Math.max(0.0, Math.min(1.0, (x - edge0) / (edge1 - edge0)));
return t * t * (3.0 - 2.0 * t);