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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 18:44:58 +01:00
Claude 782df70a69 Merge feature/flaps into flight-computer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 18:37:11 +01:00
Claude 2e75d956a0 Turn auto trim into a fly-by-wire flight computer
Pitch input becomes a rate command; the computer measures the pitching torque
without its own surfaces (airframe stability, thrust line, weight offset) and
cancels it with dynamic inversion, so relaxed-stability craft stay controllable.
Replaces trimAuthority with flightComputer* config values.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 18:29:27 +01:00
Claude 9f10ff08ff Only cursor aim players may set or clear a node's cursor
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 18:29:27 +01:00
Claude 2f4f90c773 Choose cursor aim with /aero controls instead of a keybind
/aero controls keyboard|cursor_aim is open to every player, stored per player
and synced to the client, which switches on automatically when seated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 18:29:27 +01:00
Claude d66e8a83d2 Add configurable cursor deadzone (0-15 degrees)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 18:29:13 +01:00
Claude 8bb2cfd859 Add Metalstorm-style cursor aim
Virtual stick cursor steered through the gun reticle, server-side virtual pilot
using measured control responses, client cursor/camera handling with look-ahead,
free look and scroll zoom.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 18:29:13 +01:00
Claude d376a6b9fa feat(client): keep the lead pipper through radar loss
Follows the ship matched to the selected radar contact (true position and
velocity) and extrapolates the last contact when no ship is loaded, so
notching or the radar speed gate no longer removes the pipper.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
2026-11-11 18:28:58 +01:00
Claude da74c2950c feat(client): gun pipper with ground impact point and radar lead marker
Draws where the shells of the first light autocannon come down and, for the
selected radar target, a lead pipper. Uses the same ballistics as Warium's
LACFireScript (muzzle speed, barrels, drag 0.99, gravity 0.05).

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

No files matched your search

+4
View File
@@ -84,6 +84,10 @@ dependencies {
compileOnly fg.deobf("blank:WariumVS:1.1.7") compileOnly fg.deobf("blank:WariumVS:1.1.7")
compileOnly fg.deobf("blank:futurism:0.0.4") compileOnly fg.deobf("blank:futurism:0.0.4")
// GeckoLib 4.8.4, which Warium VS animates its control surfaces with (optional dependency, remapped, the surface animation mixin
// only applies when it and Warium VS are present)
compileOnly fg.deobf("maven.modrinth:geckolib:aC5KMoNg")
// AzureLib 3.2.7 for the animated block models (optional dependency, the common jar uses obfuscated names, so it is remapped) // AzureLib 3.2.7 for the animated block models (optional dependency, the common jar uses obfuscated names, so it is remapped)
compileOnly fg.deobf("mod.azure.azurelib:azurelib-common-${minecraft_version}:${azurelib_version}") compileOnly fg.deobf("mod.azure.azurelib:azurelib-common-${minecraft_version}:${azurelib_version}")
} }
+1 -1
View File
@@ -12,7 +12,7 @@ mapping_version=2023.09.03-1.20.1
mod_id=improved_aerodynamics mod_id=improved_aerodynamics
mod_name=VS: Improved Aerodynamics mod_name=VS: Improved Aerodynamics
mod_license=MIT mod_license=MIT
mod_version=0.4.0 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
@@ -123,12 +123,31 @@ public final class Config {
.comment("Improved flight model: multiplier for the extra lift the vortices make at high angles of attack") .comment("Improved flight model: multiplier for the extra lift the vortices make at high angles of attack")
.defineInRange("vortexLiftStrength", 1.0D, 0.0D, 3.0D); .defineInRange("vortexLiftStrength", 1.0D, 0.0D, 3.0D);
public static final ForgeConfigSpec.DoubleValue TRIM_AUTHORITY = BUILDER public static final ForgeConfigSpec.DoubleValue FBW_AUTHORITY = BUILDER
.comment("Auto trim: largest pitch input (fraction of full deflection) the trim may hold") .comment("Flight computer (auto trim): largest total pitch input, as a fraction of full deflection, the computer may use.",
.defineInRange("trimAuthority", 0.5D, 0.0D, 1.0D); "A craft with strongly relaxed stability needs close to 1")
.defineInRange("flightComputerAuthority", 1.0D, 0.0D, 1.0D);
public static final ForgeConfigSpec.DoubleValue FBW_RATE_GAIN = BUILDER
.comment("Flight computer: how hard it corrects a pitch rate that differs from the command, in rad/s² of angular",
"acceleration per rad/s of error. Higher holds the nose tighter, too high makes slow surfaces oscillate")
.defineInRange("flightComputerRateGain", 8.0D, 0.5D, 40.0D);
public static final ForgeConfigSpec.DoubleValue FBW_INTEGRAL_GAIN = BUILDER
.comment("Flight computer: integral gain that removes the steady error left by the torque model")
.defineInRange("flightComputerIntegralGain", 4.0D, 0.0D, 40.0D);
public static final ForgeConfigSpec.DoubleValue FBW_STICK_RATE_DEGREES = BUILDER
.comment("Flight computer: pitch rate in degrees per second the pilot commands with full pitch input")
.defineInRange("flightComputerStickRateDegrees", 40.0D, 5.0D, 120.0D);
public static final ForgeConfigSpec.DoubleValue FBW_ACTUATOR_RATE = BUILDER
.comment("Flight computer: fastest the computer moves the pitch surfaces, in fraction of full deflection per second.",
"Unstable craft need it high enough to catch a divergence, 4 or more")
.defineInRange("flightComputerActuatorRate", 6.0D, 0.5D, 50.0D);
public static final ForgeConfigSpec.DoubleValue TRIM_MIN_SPEED = BUILDER public static final ForgeConfigSpec.DoubleValue TRIM_MIN_SPEED = BUILDER
.comment("Auto trim: below this airspeed (blocks per second) the trim holds its current value") .comment("Flight computer (auto trim): below this airspeed (blocks per second) the computer holds its current output")
.defineInRange("trimMinSpeed", 10.0D, 0.0D, 100.0D); .defineInRange("trimMinSpeed", 10.0D, 0.0D, 100.0D);
public static final ForgeConfigSpec.DoubleValue FLAP_TAKEOFF_DEGREES = BUILDER public static final ForgeConfigSpec.DoubleValue FLAP_TAKEOFF_DEGREES = BUILDER
@@ -185,6 +204,52 @@ public final class Config {
.comment("The nozzle gives off light while its afterburner is lit") .comment("The nozzle gives off light while its afterburner is lit")
.define("lightEnabled", true); .define("lightEnabled", true);
static {
BUILDER.pop();
BUILDER.comment("Cursor aim: the mouse moves a cursor on screen, a virtual pilot flies the control node's craft by it").push("cursor_aim");
}
public static final ForgeConfigSpec.BooleanValue CURSOR_AIM_ENABLED = BUILDER.comment("Whether pilots may use cursor aim on this server")
.define("enabled", true);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_FULL_DEFLECTION_DEGREES = BUILDER.comment("Angle of the cursor from the gun reticle at which the virtual pilot turns as hard as it can: the maximum bank",
"sideways, the full pitch rate up or down. Closer than that it turns more gently, so the reticle settles onto",
"the cursor. How fast the craft can turn is worked out from the craft itself")
.defineInRange("fullDeflectionDegrees", 30.0, 5.0, 90.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_DEADZONE_DEGREES = BUILDER.comment("Radius around the gun reticle in degrees inside which the pilot stops steering towards the cursor. The reticle",
"then settles this far from the cursor. Outside it the command grows from zero, there is no jump at the edge")
.defineInRange("deadzoneDegrees", 0.0, 0.0, 15.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MAX_ROLL_RATE_DEGREES = BUILDER.comment("Fastest roll in degrees per second the pilot asks for when it rolls to a bank angle")
.defineInRange("maxRollRateDegrees", 150.0, 10.0, 720.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MAX_BANK_DEGREES = BUILDER.comment("Bank angle the pilot rolls to when the cursor is at full sideways deflection, at most. Craft whose wings",
"cannot hold that bank in a level turn get less")
.defineInRange("maxBankDegrees", 70.0, 10.0, 90.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_BANK_GAIN = BUILDER.comment("Roll rate in rad/s per radian of bank angle error, how eagerly the pilot rolls to the wanted bank and levels the wings")
.defineInRange("bankGain", 3.0, 0.2, 20.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_RATE_GAIN = BUILDER.comment("How hard the pilot corrects its turn rate, in angular acceleration per rad/s of rate error. Lower is smoother,",
"higher follows the cursor tighter and can start to oscillate")
.defineInRange("rateGain", 5.0, 0.5, 40.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_PUSH_FACTOR = BUILDER.comment("A cursor below the gun reticle pushes the nose down. This is how much of the pull rate that may be, since",
"negative g is harder on the craft and the pilot")
.defineInRange("pushFactor", 0.4, 0.0, 1.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_REVERSE_TURN_DEGREES = BUILDER.comment("How far below the nose (wings level) the cursor has to be before the pilot rolls over and pulls towards it",
"instead of pushing the nose down. Less than that is flown with the elevators alone, like for a landing")
.defineInRange("reverseTurnDegrees", 40.0, 10.0, 135.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_STALLED_PITCH_FACTOR = BUILDER.comment("Multiplier for the pitch rate the pilot asks for while the wings are stalled, so it does not keep pulling")
.defineInRange("stalledPitchFactor", 0.3, 0.0, 1.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MIN_SPEED = BUILDER.comment("Below this speed in blocks per second the pilot does not fly the craft: the cursor moves the elevators and",
"rudder directly instead, so they follow it while taxiing and rotating for takeoff")
.defineInRange("minSpeed", 15.0, 0.0, 200.0);
static { static {
BUILDER.pop(); BUILDER.pop();
} }
@@ -201,6 +266,45 @@ public final class Config {
.comment("Vectored afterburner nozzle: afterburner ignition and heavy afterburner sound") .comment("Vectored afterburner nozzle: afterburner ignition and heavy afterburner sound")
.define("soundEnabled", true); .define("soundEnabled", true);
public static final ForgeConfigSpec.BooleanValue ANIMATE_SURFACES = CLIENT_BUILDER
.comment("Control surfaces and flaps visibly deflect by as much as the flight physics deflects them, instead of Warium's",
"fixed 20 degree steps. Needs physicsRateAerodynamics on the server")
.define("animateControlSurfaces", true);
static {
CLIENT_BUILDER.comment("Cursor aim, client side").push("cursor_aim");
}
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_LOOK_AHEAD = CLIENT_BUILDER.comment("How strongly the camera looks into the direction of the turn. 0 never moves it, 1 (100%) turns it quickly",
"to look where the cursor points. In between it follows more slowly")
.defineInRange("lookAhead", 0.0, 0.0, 1.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MOUSE_SENSITIVITY = CLIENT_BUILDER.comment("Multiplier for how far the mouse moves the cursor (or the camera while the free look key is held)")
.defineInRange("mouseSensitivity", 1.0, 0.05, 10.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_SPRING_BACK_SECONDS = CLIENT_BUILDER.comment("The cursor drifts to the gun reticle with this time constant in seconds, which ends the turn by itself.",
"0, the default, keeps it where you put it in the world, so the craft turns until the reticle is on it")
.defineInRange("springBackSeconds", 0.0, 0.0, 10.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MAX_ANGLE_DEGREES = CLIENT_BUILDER.comment("Furthest the cursor can go from the gun reticle, 135 is a 270 degree cone")
.defineInRange("maxCursorAngleDegrees", 135.0, 10.0, 180.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_ZOOM_STEP = CLIENT_BUILDER.comment("Blocks the camera moves per mouse wheel notch")
.defineInRange("zoomStep", 2.0, 0.1, 20.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_ZOOM_MAX = CLIENT_BUILDER.comment("Furthest the camera can be pulled back from its normal distance, in blocks")
.defineInRange("zoomMax", 80.0, 0.0, 500.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MARKER_SIZE = CLIENT_BUILDER.comment("Radius of the cursor ring on screen")
.defineInRange("markerSize", 0.04, 0.01, 0.3);
public static final ForgeConfigSpec.BooleanValue CURSOR_AIM_SHOW_RETICLE = CLIENT_BUILDER.comment("Draw a small cross where the gun reticle points, the zero of the cursor, so you can see it when the camera looks away")
.define("showReticle", true);
static {
CLIENT_BUILDER.pop();
}
static final ForgeConfigSpec CLIENT_SPEC = CLIENT_BUILDER.build(); static final ForgeConfigSpec CLIENT_SPEC = CLIENT_BUILDER.build();
private Config() { private Config() {
@@ -7,6 +7,7 @@ import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.tree.LiteralCommandNode; import com.mojang.brigadier.tree.LiteralCommandNode;
import dev.lumareth.improvedAerodynamics.exhaust.NozzleCommands; import dev.lumareth.improvedAerodynamics.exhaust.NozzleCommands;
import dev.lumareth.improvedAerodynamics.flight.ControlMode;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands; import net.minecraft.commands.Commands;
import net.minecraft.commands.SharedSuggestionProvider; import net.minecraft.commands.SharedSuggestionProvider;
@@ -21,7 +22,8 @@ import java.util.List;
/** /**
* /improvedaerodynamics (or /aero) to read and change the most important server config values in game. * /improvedaerodynamics (or /aero) to read and change the most important server config values in game.
* {@code /aero} lists them, {@code /aero <option>} shows one, {@code /aero <option> <value>} sets and saves it. * {@code /aero} lists them, {@code /aero <option>} shows one, {@code /aero <option> <value>} sets and saves it.
* Needs operator rights, except for the owner of a singleplayer world, who may use them without cheats. * The config options need operator rights, except for the owner of a singleplayer world, who may use them without cheats.
* {@code /aero controls keyboard|cursor_aim} is open to every player and picks how they fly.
* All of them are read live, so changes apply immediately. * All of them are read live, so changes apply immediately.
*/ */
public final class ConfigCommands { public final class ConfigCommands {
@@ -48,7 +50,11 @@ public final class ConfigCommands {
new Option<>("vortex_lift", Config.VORTEX_LIFT, BoolArgumentType.bool(), Boolean.class), new Option<>("vortex_lift", Config.VORTEX_LIFT, BoolArgumentType.bool(), Boolean.class),
new Option<>("vortex_stall_bonus", Config.VORTEX_STALL_BONUS, DoubleArgumentType.doubleArg(), Double.class), new Option<>("vortex_stall_bonus", Config.VORTEX_STALL_BONUS, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("vortex_lift_strength", Config.VORTEX_LIFT_STRENGTH, DoubleArgumentType.doubleArg(), Double.class), new Option<>("vortex_lift_strength", Config.VORTEX_LIFT_STRENGTH, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("trim_authority", Config.TRIM_AUTHORITY, DoubleArgumentType.doubleArg(), Double.class), new Option<>("flight_computer_authority", Config.FBW_AUTHORITY, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("flight_computer_rate_gain", Config.FBW_RATE_GAIN, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("flight_computer_integral_gain", Config.FBW_INTEGRAL_GAIN, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("flight_computer_stick_rate_degrees", Config.FBW_STICK_RATE_DEGREES, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("flight_computer_actuator_rate", Config.FBW_ACTUATOR_RATE, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("trim_min_speed", Config.TRIM_MIN_SPEED, DoubleArgumentType.doubleArg(), Double.class), new Option<>("trim_min_speed", Config.TRIM_MIN_SPEED, DoubleArgumentType.doubleArg(), Double.class),
new Option<>("air_placeholder", Config.ENABLE_AIR_PLACEHOLDER, BoolArgumentType.bool(), Boolean.class), new Option<>("air_placeholder", Config.ENABLE_AIR_PLACEHOLDER, BoolArgumentType.bool(), Boolean.class),
new Option<>("nozzle_max_deflection", Config.NOZZLE_MAX_DEFLECTION, DoubleArgumentType.doubleArg(), Double.class), new Option<>("nozzle_max_deflection", Config.NOZZLE_MAX_DEFLECTION, DoubleArgumentType.doubleArg(), Double.class),
@@ -78,21 +84,47 @@ public final class ConfigCommands {
@SubscribeEvent @SubscribeEvent
public static void onRegisterCommands(RegisterCommandsEvent event) { public static void onRegisterCommands(RegisterCommandsEvent event) {
// The root is open to everybody for /aero controls, the config options check for rights themselves
LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal("improvedaerodynamics") LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal("improvedaerodynamics")
.requires(ConfigCommands::mayUse) .executes(context -> mayUse(context.getSource()) ? list(context.getSource()) : denied(context.getSource()));
.executes(context -> list(context.getSource()));
for (Option<?> option : OPTIONS) { for (Option<?> option : OPTIONS) {
root.then(optionNode(option)); root.then(optionNode(option).requires(ConfigCommands::mayUse));
} }
root.then(SeedgenCommands.node()); root.then(SeedgenCommands.node().requires(ConfigCommands::mayUse));
root.then(NozzleCommands.node()); root.then(NozzleCommands.node().requires(ConfigCommands::mayUse));
root.then(controlsNode());
LiteralCommandNode<CommandSourceStack> node = event.getDispatcher().register(root); LiteralCommandNode<CommandSourceStack> node = event.getDispatcher().register(root);
event.getDispatcher().register(Commands.literal("aero") event.getDispatcher().register(Commands.literal("aero")
.requires(ConfigCommands::mayUse) .executes(context -> mayUse(context.getSource()) ? list(context.getSource()) : denied(context.getSource()))
.executes(context -> list(context.getSource()))
.redirect(node)); .redirect(node));
} }
private static int denied(CommandSourceStack source) {
source.sendFailure(Component.literal("Use /aero controls keyboard or /aero controls cursor_aim"));
return 0;
}
/** /aero controls [keyboard|cursor_aim], for every player (permission level 0). */
private static LiteralArgumentBuilder<CommandSourceStack> controlsNode() {
return Commands.literal("controls")
.requires(source -> source.getEntity() instanceof ServerPlayer)
.executes(context -> {
ServerPlayer player = context.getSource().getPlayerOrException();
boolean cursorAim = ControlMode.isCursorAim(player);
context.getSource().sendSuccess(() -> Component.literal("Controls: " + (cursorAim ? "cursor_aim" : "keyboard")), false);
return 1;
})
.then(Commands.literal("keyboard").executes(context -> setControls(context.getSource(), false)))
.then(Commands.literal("cursor_aim").executes(context -> setControls(context.getSource(), true)));
}
private static int setControls(CommandSourceStack source, boolean cursorAim) throws com.mojang.brigadier.exceptions.CommandSyntaxException {
ServerPlayer player = source.getPlayerOrException();
ControlMode.set(player, cursorAim);
source.sendSuccess(() -> Component.literal("Controls set to " + (cursorAim ? "cursor_aim" : "keyboard")), false);
return 1;
}
private static <T> LiteralArgumentBuilder<CommandSourceStack> optionNode(Option<T> option) { private static <T> LiteralArgumentBuilder<CommandSourceStack> optionNode(Option<T> option) {
return Commands.literal(option.name()) return Commands.literal(option.name())
.executes(context -> { .executes(context -> {
@@ -1,6 +1,7 @@
package dev.lumareth.improvedAerodynamics; package dev.lumareth.improvedAerodynamics;
import com.mojang.logging.LogUtils; import com.mojang.logging.LogUtils;
import dev.lumareth.improvedAerodynamics.client.pipper.PipperConfig;
import dev.lumareth.improvedAerodynamics.exhaust.ExhaustSync; import dev.lumareth.improvedAerodynamics.exhaust.ExhaustSync;
import dev.lumareth.improvedAerodynamics.exhaust.NozzleContent; import dev.lumareth.improvedAerodynamics.exhaust.NozzleContent;
import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge; import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge;
@@ -24,9 +25,13 @@ public class ImprovedAerodynamics {
MinecraftForge.EVENT_BUS.register(PhysicsBridge.class); MinecraftForge.EVENT_BUS.register(PhysicsBridge.class);
MinecraftForge.EVENT_BUS.register(ConfigCommands.class); MinecraftForge.EVENT_BUS.register(ConfigCommands.class);
MinecraftForge.EVENT_BUS.register(ExhaustSync.class); MinecraftForge.EVENT_BUS.register(ExhaustSync.class);
MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.ControlMode.class);
MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.FlapInteraction.class); MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.FlapInteraction.class);
MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.DeflectionSync.class);
ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC); ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC);
ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, Config.CLIENT_SPEC); ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, Config.CLIENT_SPEC);
// Own file name: the default one is taken by Config.CLIENT_SPEC
ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, PipperConfig.SPEC, "improved_aerodynamics-pipper-client.toml");
NozzleContent.register(FMLJavaModLoadingContext.get().getModEventBus()); NozzleContent.register(FMLJavaModLoadingContext.get().getModEventBus());
Network.register(); Network.register();
} }
@@ -0,0 +1,31 @@
package dev.lumareth.improvedAerodynamics.client.aim;
import com.mojang.blaze3d.platform.InputConstants;
import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics;
import net.minecraft.client.KeyMapping;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
import net.minecraftforge.client.settings.KeyConflictContext;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import org.lwjgl.glfw.GLFW;
/** Keys of cursor aim, which is switched on with /aero controls cursor_aim. The free look key is not shift: in a seat, shift gets the player out of it. */
public final class AimKeys {
public static final KeyMapping FREE_LOOK = new KeyMapping("key.improved_aerodynamics.cursor_aim_free_look",
KeyConflictContext.IN_GAME, InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_LEFT_ALT, "key.categories.improved_aerodynamics");
private AimKeys() {
}
@Mod.EventBusSubscriber(modid = ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT, bus = Mod.EventBusSubscriber.Bus.MOD)
public static final class Registration {
private Registration() {
}
@SubscribeEvent
public static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) {
event.register(FREE_LOOK);
}
}
}
@@ -0,0 +1,15 @@
package dev.lumareth.improvedAerodynamics.client.aim;
import net.mcreator.valkyrienwarium.entity.SeatEntityEntity;
import net.minecraft.world.entity.Entity;
/** Everything that touches Warium VS classes, so nothing else loads them when Warium is missing. */
final class AimSeat {
private AimSeat() {
}
static Entity seatOf(Entity rider) {
Entity root = rider.getRootVehicle();
return root instanceof SeatEntityEntity ? root : null;
}
}
@@ -0,0 +1,545 @@
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;
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.blaze3d.vertex.Tesselator;
import com.mojang.blaze3d.vertex.VertexFormat;
import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics;
import dev.lumareth.improvedAerodynamics.client.pipper.GunPipperRenderer;
import dev.lumareth.improvedAerodynamics.flight.AimBasis;
import dev.lumareth.improvedAerodynamics.network.AimPacket;
import dev.lumareth.improvedAerodynamics.network.Network;
import net.minecraft.client.Camera;
import net.minecraft.client.CameraType;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.renderer.GameRenderer;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.InputEvent;
import net.minecraftforge.client.event.RenderLevelStageEvent;
import net.minecraftforge.client.event.ViewportEvent;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
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;
import org.valkyrienskies.core.api.ships.ClientShip;
import org.valkyrienskies.core.api.ships.Ship;
import org.valkyrienskies.mod.common.VSGameUtilsKt;
import java.lang.reflect.Method;
/**
* Cursor aim, the client side. While it is on:
* <ul>
* <li>the mouse moves a cursor instead of the camera. The cursor is a fixed direction in the world: the craft
* turning does not drag it along, so the virtual pilot on the server steers until the gun reticle (the craft's
* boresight) sits on the cursor again. It is kept at most {@code maxCursorAngleDegrees} from the reticle and, if
* spring back is set, drifts back to it. Every tick it is sent to the server as an offset from the reticle;</li>
* <li>the camera is a chase view, looking along the reticle, or, with look ahead, towards the cursor;</li>
* <li>holding the free look key moves the camera instead of the cursor, it eases back when released;</li>
* <li>the mouse wheel changes how far the camera is behind the craft.</li>
* </ul>
* The camera is driven through the rotation of the player, which for a rider of a Valkyrien Skies ship is relative to
* the ship, so the camera follows the ship's attitude like Valkyrien Skies does for any mounted player. Mouse
* movement shows up as a change of that rotation, which is taken as the movement of the cursor and put back.
*/
@Mod.EventBusSubscriber(modid = ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT)
public final class CursorAimClient {
private static final int CURSOR_COLOR = 0xFFFFE040;
private static final int RETICLE_COLOR = 0xFFFFFFFF;
private static final float MARKER_DISTANCE = 2.0F;
private static final double LOOK_AHEAD_RATE = 6.0;
private static final double FREE_LOOK_RETURN_RATE = 10.0;
// Blocks under the craft's bounding box that count as still on the ground, and how long it has to be off it
private static final double GROUND_CLEARANCE = 1.5;
private static final int AIRBORNE_TICKS = 20;
private static boolean active;
// Chosen with /aero controls, told by the server
private static boolean cursorAimMode;
// Cursor as a world direction, null until the first frame puts it on the reticle
private static Vector3d cursorWorld;
// The same cursor as components along the craft's forward (gun reticle), right and up axes, as of the last update
private static double cf = 1.0;
private static double cr;
private static double cu;
// Camera direction in ship space and the free look offset on top of it, degrees
private static double[] cameraDirection;
// Client ticks the craft has been off the ground, up to AIRBORNE_TICKS
private static int airborneTicks;
private static double offsetYaw;
private static double offsetPitch;
// Rotation the player was given last frame, to see what the mouse did since
private static boolean haveLast;
private static float lastYaw;
private static float lastPitch;
private static long lastNanos;
private static double zoom;
private static CameraType previousCamera;
private static Method cameraMove;
private CursorAimClient() {
}
// ---- Toggling and the packet to the server ----
@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
if (event.phase != TickEvent.Phase.END) {
return;
}
Minecraft minecraft = Minecraft.getInstance();
LocalPlayer player = minecraft.player;
if (!active) {
if (cursorAimMode && player != null && seated(player) && serverHasMod(minecraft)) {
activate(minecraft, player);
}
return;
}
if (!cursorAimMode) {
deactivate(minecraft);
return;
}
if (player == null || !seated(player)) {
deactivate(minecraft);
return;
}
// The craft has turned since the last frame: the cursor stays put in the world, its offset changes
Entity seat = AimSeat.seatOf(player);
AimBasis basis = minecraft.level != null ? AimBasis.of(minecraft.level, seat.blockPosition()) : null;
Quaterniondc rotation = basis != null ? shipRotation(minecraft.level, seat, false) : null;
if (rotation != null) {
cursorFromWorld(basis, rotation);
}
updateAirborne(minecraft.level, seat);
send(minecraft, true);
}
/**
* Whether the craft is off the ground, for look ahead: on the ground (and for a moment after leaving it) the camera
* stays behind the reticle, so pulling the nose up to take off does not swing it down under the craft.
*/
private static void updateAirborne(ClientLevel level, Entity seat) {
Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, BlockPos.containing(seat.getX(), seat.getY(), seat.getZ()));
if (ship == null || onGround(level, ship.getWorldAABB())) {
airborneTicks = 0;
} else if (airborneTicks < AIRBORNE_TICKS) {
airborneTicks++;
}
}
/**
* Whether there is solid ground right under the craft: any block with a collision shape in a layer just below the
* bottom of its world bounding box. The craft's own blocks are not in the world, so they never count.
*/
private static boolean onGround(ClientLevel level, AABBdc box) {
int minX = Mth.floor(box.minX());
int maxX = Mth.floor(box.maxX());
int minZ = Mth.floor(box.minZ());
int maxZ = Mth.floor(box.maxZ());
int fromY = Mth.floor(box.minY() - GROUND_CLEARANCE);
int toY = Mth.floor(box.minY() + 0.5);
// At most about 16 x 16 columns, a big craft is sampled on a coarser grid
int stepX = Math.max(1, (maxX - minX) / 16);
int stepZ = Math.max(1, (maxZ - minZ) / 16);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int x = minX; x <= maxX; x += stepX) {
for (int z = minZ; z <= maxZ; z += stepZ) {
for (int y = fromY; y <= toY; y++) {
pos.set(x, y, z);
if (!level.getBlockState(pos).getCollisionShape(level, pos).isEmpty()) {
return true;
}
}
}
}
return false;
}
/** Rotation of the ship the seat is on, its render transform when drawing, null if it is not on a ship. */
private static Quaterniondc shipRotation(ClientLevel level, Entity seat, boolean render) {
Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, BlockPos.containing(seat.getX(), seat.getY(), seat.getZ()));
if (ship == null) {
return null;
}
return render && ship instanceof ClientShip clientShip
? clientShip.getRenderTransform().getShipToWorldRotation() : ship.getTransform().getShipToWorldRotation();
}
/** Sets the cursor's components from its world direction, putting it on the reticle the first time. */
private static void cursorFromWorld(AimBasis basis, Quaterniondc rotation) {
if (cursorWorld == null) {
double[] forward = basis.forward();
cursorWorld = rotation.transform(new Vector3d(forward[0], forward[1], forward[2]));
}
Vector3d ship = rotation.transformInverse(cursorWorld, new Vector3d());
double[] f = basis.forward();
double[] r = basis.right();
double[] u = basis.up();
cf = ship.x * f[0] + ship.y * f[1] + ship.z * f[2];
cr = ship.x * r[0] + ship.y * r[1] + ship.z * r[2];
cu = ship.x * u[0] + ship.y * u[1] + ship.z * u[2];
normalizeCursor();
limitCursor();
}
/** Stores the cursor's components back as its world direction. */
private static void cursorToWorld(AimBasis basis, Quaterniondc rotation) {
double[] ship = basis.toShip(cf, cr, cu);
cursorWorld = rotation.transform(new Vector3d(ship[0], ship[1], ship[2]));
}
/** Called when the server tells the client how its player flies. */
public static void setMode(boolean cursorAim) {
cursorAimMode = cursorAim;
}
private static boolean seated(LocalPlayer player) {
return ModList.get().isLoaded("valkyrien_warium") && AimSeat.seatOf(player) != null;
}
private static boolean serverHasMod(Minecraft minecraft) {
return minecraft.getConnection() != null && Network.CHANNEL.isRemotePresent(minecraft.getConnection().getConnection());
}
private static void activate(Minecraft minecraft, LocalPlayer player) {
if (!seated(player)) {
player.displayClientMessage(Component.translatable("message.improved_aerodynamics.cursor_aim.no_seat"), true);
return;
}
if (!serverHasMod(minecraft)) {
player.displayClientMessage(Component.translatable("message.improved_aerodynamics.cursor_aim.no_server"), true);
return;
}
active = true;
cursorWorld = null;
cf = 1.0;
cr = 0.0;
cu = 0.0;
cameraDirection = null;
airborneTicks = 0;
offsetYaw = 0.0;
offsetPitch = 0.0;
haveLast = false;
lastNanos = System.nanoTime();
previousCamera = minecraft.options.getCameraType();
minecraft.options.setCameraType(CameraType.THIRD_PERSON_BACK);
player.displayClientMessage(Component.translatable("message.improved_aerodynamics.cursor_aim.on"), true);
}
private static void deactivate(Minecraft minecraft) {
if (!active) {
return;
}
active = false;
send(minecraft, false);
if (previousCamera != null) {
minecraft.options.setCameraType(previousCamera);
previousCamera = null;
}
if (minecraft.player != null) {
minecraft.player.displayClientMessage(Component.translatable("message.improved_aerodynamics.cursor_aim.off"), true);
}
}
private static void send(Minecraft minecraft, boolean on) {
if (serverHasMod(minecraft)) {
Network.CHANNEL.sendToServer(new AimPacket(on, (float) cf, (float) cr, (float) cu));
}
}
// ---- Mouse and camera, once per frame before the world is rendered ----
@SubscribeEvent
public static void onRenderTick(TickEvent.RenderTickEvent event) {
if (!active || event.phase != TickEvent.Phase.START) {
return;
}
Minecraft minecraft = Minecraft.getInstance();
LocalPlayer player = minecraft.player;
ClientLevel level = minecraft.level;
if (player == null || level == null || !seated(player)) {
return;
}
Entity seat = AimSeat.seatOf(player);
AimBasis basis = AimBasis.of(level, seat.blockPosition());
Quaterniondc rotation = basis != null ? shipRotation(level, seat, true) : null;
if (basis == null || rotation == null) {
return;
}
if (minecraft.options.getCameraType() != CameraType.THIRD_PERSON_BACK) {
minecraft.options.setCameraType(CameraType.THIRD_PERSON_BACK);
}
long now = System.nanoTime();
double dt = Mth.clamp((now - lastNanos) / 1.0E9, 0.0, 0.1);
lastNanos = now;
// What the mouse did: the player's rotation moved away from what it was set to
double deltaYaw = 0.0;
double deltaPitch = 0.0;
if (haveLast && minecraft.screen == null) {
deltaYaw = Mth.wrapDegrees(player.getYRot() - lastYaw);
deltaPitch = player.getXRot() - lastPitch;
if (Math.abs(deltaYaw) > 90.0 || Math.abs(deltaPitch) > 90.0) {
// Not the mouse, something reset the rotation
deltaYaw = 0.0;
deltaPitch = 0.0;
}
}
double sensitivity = Config.CURSOR_AIM_MOUSE_SENSITIVITY.get();
boolean freeLook = minecraft.screen == null && AimKeys.FREE_LOOK.isDown();
cursorFromWorld(basis, rotation);
double spring = Config.CURSOR_AIM_SPRING_BACK_SECONDS.get();
if (spring > 0.0) {
shrinkCursor(Math.exp(-dt / spring));
}
if (freeLook) {
offsetYaw += deltaYaw * sensitivity;
offsetPitch = Mth.clamp(offsetPitch - deltaPitch * sensitivity, -89.0, 89.0);
} else {
moveCursor(Math.toRadians(deltaYaw * sensitivity), Math.toRadians(-deltaPitch * sensitivity));
double decay = Math.exp(-dt * FREE_LOOK_RETURN_RATE);
offsetYaw *= decay;
offsetPitch *= decay;
}
cursorToWorld(basis, rotation);
// 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 {
// 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));
direction = rotate(direction, rotate(basis.right(), basis.up(), -Math.toRadians(offsetYaw)), Math.toRadians(offsetPitch));
// Valkyrien Skies reads the view of a mounted player in ship space: yaw 0 looks along +Z
float yaw = (float) Math.toDegrees(Math.atan2(-direction[0], direction[2]));
float pitch = (float) Math.toDegrees(Math.atan2(-direction[1], Math.hypot(direction[0], direction[2])));
player.setYRot(yaw);
player.setXRot(pitch);
player.yRotO = yaw;
player.xRotO = pitch;
player.yHeadRot = yaw;
player.yHeadRotO = yaw;
lastYaw = yaw;
lastPitch = pitch;
haveLast = true;
}
/** Moves the cursor like a look direction: yaw towards the right, pitch up, radians. */
private static void moveCursor(double yaw, double pitch) {
double nf = cf * Math.cos(yaw) - cr * Math.sin(yaw);
double nr = cf * Math.sin(yaw) + cr * Math.cos(yaw);
cf = nf;
cr = nr;
nf = cf * Math.cos(pitch) - cu * Math.sin(pitch);
double nu = cf * Math.sin(pitch) + cu * Math.cos(pitch);
cf = nf;
cu = nu;
normalizeCursor();
limitCursor();
}
/** Brings the cursor back towards the reticle along the shortest way, keeping only this fraction of its angle. */
private static void shrinkCursor(double keep) {
double angle = Math.acos(Mth.clamp(cf, -1.0, 1.0));
double lateral = Math.sqrt(cr * cr + cu * cu);
if (angle < 1.0E-6 || lateral < 1.0E-9) {
cf = 1.0;
cr = 0.0;
cu = 0.0;
return;
}
double newAngle = angle * keep;
double scale = Math.sin(newAngle) / lateral;
cf = Math.cos(newAngle);
cr *= scale;
cu *= scale;
}
private static void normalizeCursor() {
double length = Math.sqrt(cf * cf + cr * cr + cu * cu);
if (!(length > 1.0E-9)) {
cf = 1.0;
cr = 0.0;
cu = 0.0;
return;
}
cf /= length;
cr /= length;
cu /= length;
}
private static void limitCursor() {
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;
}
double lateral = Math.sqrt(cr * cr + cu * cu);
if (lateral < 1.0E-9) {
return;
}
double scale = Math.sin(limit) / lateral;
cf = Math.cos(limit);
cr *= scale;
cu *= scale;
}
// ---- Mouse wheel and camera distance ----
@SubscribeEvent
public static void onScroll(InputEvent.MouseScrollingEvent event) {
Minecraft minecraft = Minecraft.getInstance();
if (!active || minecraft.screen != null) {
return;
}
zoom = Mth.clamp(zoom - event.getScrollDelta() * Config.CURSOR_AIM_ZOOM_STEP.get(), -3.0, Config.CURSOR_AIM_ZOOM_MAX.get());
event.setCanceled(true);
}
@SubscribeEvent
public static void onCameraAngles(ViewportEvent.ComputeCameraAngles event) {
if (!active || Math.abs(zoom) < 1.0E-3) {
return;
}
try {
if (cameraMove == null) {
// Camera.move(double, double, double), moves along the camera's own axes
cameraMove = ObfuscationReflectionHelper.findMethod(Camera.class, "m_90568_", double.class, double.class, double.class);
}
cameraMove.invoke(event.getCamera(), -zoom, 0.0, 0.0);
} catch (ReflectiveOperationException | RuntimeException exception) {
ImprovedAerodynamics.LOGGER.warn("Cursor aim camera zoom is not available", exception);
zoom = 0.0;
}
}
// ---- Drawing the cursor ----
@SubscribeEvent
public static void onRenderLevel(RenderLevelStageEvent event) {
if (!active || event.getStage() != RenderLevelStageEvent.Stage.AFTER_PARTICLES) {
return;
}
Minecraft minecraft = Minecraft.getInstance();
ClientLevel level = minecraft.level;
LocalPlayer player = minecraft.player;
if (level == null || player == null || !seated(player)) {
return;
}
Entity seat = AimSeat.seatOf(player);
AimBasis basis = AimBasis.of(level, seat.blockPosition());
Quaterniondc rotation = basis != null ? shipRotation(level, seat, true) : null;
if (rotation == null || cursorWorld == null) {
return;
}
double[] forward = basis.forward();
Vector3d reticleWorld = rotation.transform(new Vector3d(forward[0], forward[1], forward[2]));
Camera camera = event.getCamera();
Matrix4f matrix = event.getPoseStack().last().pose();
BufferBuilder builder = Tesselator.getInstance().getBuilder();
RenderSystem.disableDepthTest();
RenderSystem.enableBlend();
RenderSystem.defaultBlendFunc();
RenderSystem.disableCull();
RenderSystem.setShader(GameRenderer::getPositionColorShader);
builder.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR);
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);
BufferUploader.drawWithShader(builder.end());
RenderSystem.enableCull();
RenderSystem.enableDepthTest();
RenderSystem.disableBlend();
}
private static Vector3f screenPoint(Camera camera, Vector3d direction) {
Vector3f center = new Vector3f((float) direction.x, (float) direction.y, (float) direction.z);
if (center.lengthSquared() < 1.0E-9F) {
return null;
}
return center.normalize().mul(MARKER_DISTANCE);
}
private static void ring(BufferBuilder builder, Matrix4f matrix, Camera camera, Vector3d direction, float radius, int color) {
Vector3f center = screenPoint(camera, direction);
if (center == null) {
return;
}
Vector3f left = new Vector3f(camera.getLeftVector());
Vector3f up = new Vector3f(camera.getUpVector());
float thickness = radius * 0.12F;
GunPipperRenderer.ring(builder, matrix, center, left, up, radius, thickness, color);
GunPipperRenderer.quad(builder, matrix, center, left, up, thickness * 1.4F, thickness * 1.4F, color);
}
private static void plus(BufferBuilder builder, Matrix4f matrix, Camera camera, Vector3d direction, float arm, int color) {
Vector3f center = screenPoint(camera, direction);
if (center == null) {
return;
}
Vector3f left = new Vector3f(camera.getLeftVector());
Vector3f up = new Vector3f(camera.getUpVector());
float thickness = arm * 0.14F;
GunPipperRenderer.quad(builder, matrix, center, left, up, arm, thickness, color);
GunPipperRenderer.quad(builder, matrix, center, left, up, thickness, arm, color);
}
// ---- Vector helpers on arrays of three ----
/** Rotates v about the unit axis by an angle in radians (Rodrigues). */
private static double[] rotate(double[] v, double[] axis, double angle) {
double c = Math.cos(angle);
double s = Math.sin(angle);
double dot = v[0] * axis[0] + v[1] * axis[1] + v[2] * axis[2];
double cx = axis[1] * v[2] - axis[2] * v[1];
double cy = axis[2] * v[0] - axis[0] * v[2];
double cz = axis[0] * v[1] - axis[1] * v[0];
return new double[]{
v[0] * c + cx * s + axis[0] * dot * (1.0 - c),
v[1] * c + cy * s + axis[1] * dot * (1.0 - c),
v[2] * c + cz * s + axis[2] * dot * (1.0 - c)};
}
/** Turns unit vector a towards unit vector b by a fraction of the angle between them. */
private static double[] turnTowards(double[] a, double[] b, double fraction) {
double dot = Mth.clamp(a[0] * b[0] + a[1] * b[1] + a[2] * b[2], -1.0, 1.0);
double cx = a[1] * b[2] - a[2] * b[1];
double cy = a[2] * b[0] - a[0] * b[2];
double cz = a[0] * b[1] - a[1] * b[0];
double sin = Math.sqrt(cx * cx + cy * cy + cz * cz);
if (sin < 1.0E-9) {
return dot > 0.0 ? b.clone() : a.clone();
}
double[] axis = {cx / sin, cy / sin, cz / sin};
return rotate(a, axis, Math.acos(dot) * fraction);
}
}
@@ -0,0 +1,138 @@
package dev.lumareth.improvedAerodynamics.client.animation;
import dev.lumareth.improvedAerodynamics.Config;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.ClientPlayerNetworkEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import software.bernie.geckolib.core.animatable.model.CoreGeoBone;
import software.bernie.geckolib.model.GeoModel;
/**
* Client side of the control surface animation. The server tells the clients how far each control surface is deflected
* (see {@code DeflectionSync}); this class keeps those values, eases towards them, and sets the rotation of the "Fin"
* bone of Warium's control surface models to them after GeckoLib has played Warium's own fixed animation.
* <p>
* Warium's animations tilt the Fin bone by 20 degrees about X for a positive input, which GeckoLib turns into a
* rotation of -20 degrees (it negates X and Y for rotations), and the same sign is used here, so a surface moves the
* way Warium's own animation moves it, only by the amount the physics uses. Warframes' vertical control surfaces are
* modelled differently: their Fin turns about Y, by -20 degrees in the animation file (+20 after GeckoLib) for the same
* input.
* <p>
* Futurism's vectored afterburner is shown the same way: its own animation only swings the Nozzle bone all the way (20
* degrees about X) up or down, here it follows the tilt the block actually has, which Futurism keeps in the block's
* synced data as "Vectorpitch" (minus a third of the input).
*/
@Mod.EventBusSubscriber(modid = dev.lumareth.improvedAerodynamics.ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT)
public final class SurfaceAnimation {
private static final String BONE = "Fin";
private static final String WARFRAMES_VERTICAL = "net.evo_mc.warframes.block.entity.WarframeVerticalControlSurfaceBlockEntity";
private static final String FUTURISM_VECTORED = "net.mcreator.futurism.block.entity.VectoredAfterburnerTileEntity";
private static final String NOZZLE_BONE = "Nozzle";
// Futurism's animation swing at full input, degrees
private static final float FUTURISM_FULL_DEGREES = 20.0F;
// A value that has not been refreshed for this long is dropped, Warium's own animation takes over again
private static final long STALE_NANOS = 3_000_000_000L;
// Time in seconds the visible deflection takes to close most of the way to the latest value
private static final double EASE_SECONDS = 0.07;
private static final class Entry {
float target;
float shown;
long updated;
long drawn;
boolean placed;
}
private static final Long2ObjectOpenHashMap<Entry> ENTRIES = new Long2ObjectOpenHashMap<>();
// Shown angle (degrees) of each Futurism vectored afterburner
private static final Long2ObjectOpenHashMap<Entry> VECTORED = new Long2ObjectOpenHashMap<>();
private SurfaceAnimation() {
}
/** New deflection of a control surface in degrees, in Warium's animation sign (positive is its "input up" tilt). */
public static void set(long pos, float degrees) {
Entry entry = ENTRIES.computeIfAbsent(pos, key -> new Entry());
entry.target = degrees;
entry.updated = System.nanoTime();
if (!entry.placed) {
// First value of a surface: show it right away, it may have been there before the player came near
entry.shown = degrees;
entry.placed = true;
}
}
public static void apply(BlockEntity blockEntity, GeoModel<?> model) {
if (!Config.ANIMATE_SURFACES.get()) {
return;
}
if (blockEntity.getClass().getName().equals(FUTURISM_VECTORED)) {
applyVectored(blockEntity, model);
return;
}
if (ENTRIES.isEmpty()) {
return;
}
Entry entry = ENTRIES.get(blockEntity.getBlockPos().asLong());
if (entry == null) {
return;
}
long now = System.nanoTime();
if (now - entry.updated > STALE_NANOS) {
ENTRIES.remove(blockEntity.getBlockPos().asLong());
return;
}
ease(entry, now);
CoreGeoBone bone = model.getAnimationProcessor().getBone(BONE);
if (bone == null) {
return;
}
float radians = (float) Math.toRadians(entry.shown);
if (blockEntity.getClass().getName().equals(WARFRAMES_VERTICAL)) {
bone.setRotX(0.0F);
bone.setRotY(radians);
} else {
bone.setRotX(-radians);
}
// Setting a rotation flags the bone as animated, and GeckoLib cleared its flags before this runs. Left set, the
// flag would make GeckoLib skip resetting this bone for the next block drawn with the same model (all blocks of
// a model share its bones), so that block would keep this block's deflection
bone.resetStateChanges();
}
/** Futurism's vectored afterburner: the Nozzle bone at the block's real tilt, Futurism's sign. */
private static void applyVectored(BlockEntity blockEntity, GeoModel<?> model) {
float input = (float) (-3.0 * blockEntity.getPersistentData().getDouble("Vectorpitch"));
float degrees = FUTURISM_FULL_DEGREES * Math.max(-1.0F, Math.min(1.0F, input));
Entry entry = VECTORED.computeIfAbsent(blockEntity.getBlockPos().asLong(), key -> new Entry());
entry.target = degrees;
if (!entry.placed) {
entry.shown = degrees;
entry.placed = true;
}
ease(entry, System.nanoTime());
CoreGeoBone bone = model.getAnimationProcessor().getBone(NOZZLE_BONE);
if (bone != null) {
bone.setRotX((float) -Math.toRadians(entry.shown));
bone.resetStateChanges();
}
}
/** Moves the shown value towards the target. The same block can be drawn more than once a frame (shadows, other passes), the easing steps once per frame. */
private static void ease(Entry entry, long now) {
if (entry.drawn != 0L && now != entry.drawn) {
double dt = (now - entry.drawn) / 1.0E9;
entry.shown += (entry.target - entry.shown) * (float) (1.0 - Math.exp(-dt / EASE_SECONDS));
}
entry.drawn = now;
}
@SubscribeEvent
public static void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) {
ENTRIES.clear();
VECTORED.clear();
}
}
@@ -0,0 +1,147 @@
package dev.lumareth.improvedAerodynamics.client.pipper;
/**
* Flight of a Warium shell, which is a vanilla arrow: every tick it moves by its velocity, the velocity is then scaled
* by the drag and gravity is subtracted. That is linear in the launch velocity, so the position after n ticks is
* <pre>M + D(n) * v0 - (0, G(n), 0)</pre>
* with D(n) the sum of the drag factors and G(n) the drop gravity causes on its own. Both are tabulated once, which
* makes both the impact point and the lead solution cheap enough to run every frame.
* <p>
* Plain doubles and no Minecraft classes, so it can be tested on its own. Units are blocks and ticks.
*/
public final class Ballistics {
public static final int MAX_TICKS = 400;
private final double[] d = new double[MAX_TICKS + 1];
private final double[] g = new double[MAX_TICKS + 1];
public Ballistics(double drag, double gravity) {
double y = 0.0;
double vy = 0.0;
double sum = 0.0;
double factor = 1.0;
for (int n = 1; n <= MAX_TICKS; n++) {
y += vy;
vy = vy * drag - gravity;
sum += factor;
factor *= drag;
d[n] = sum;
g[n] = -y;
}
}
/** Sum of the drag factors after n ticks, n may be fractional. */
public double d(double n) {
return interpolate(d, n);
}
/** Drop in blocks after n ticks, n may be fractional. */
public double g(double n) {
return interpolate(g, n);
}
private static double interpolate(double[] table, double n) {
if (n <= 0.0) {
return 0.0;
}
if (n >= MAX_TICKS) {
return table[MAX_TICKS];
}
int i = (int) n;
double f = n - i;
return table[i] + (table[i + 1] - table[i]) * f;
}
/** Height of the terrain a shell would hit at a column. */
public interface Ground {
double heightAt(double x, double z);
}
/**
* Where a shell fired from the muzzle with the given world velocity (blocks per tick, already including the
* speed of the shooter if shells inherit it) comes down. Returns the impact point as {x, y, z} or null if it
* stays in the air for the whole table.
*/
public double[] impact(double mx, double my, double mz, double vx, double vy, double vz, Ground ground) {
double px = mx;
double py = my;
double pz = mz;
for (int n = 1; n <= MAX_TICKS; n++) {
double nx = mx + d[n] * vx;
double ny = my + d[n] * vy - g[n];
double nz = mz + d[n] * vz;
double floor = ground.heightAt(nx, nz);
if (ny <= floor) {
// Linear crossing inside this step
double before = py - ground.heightAt(px, pz);
double after = ny - floor;
double f = before - after > 1.0E-9 ? Math.min(1.0, Math.max(0.0, before / (before - after))) : 1.0;
return new double[] {px + (nx - px) * f, py + (ny - py) * f, pz + (nz - pz) * f};
}
px = nx;
py = ny;
pz = nz;
}
return null;
}
/** Result of {@link #lead}: aim direction, time of flight in ticks and the range to the target's current position. */
public record Lead(double dirX, double dirY, double dirZ, double ticks, double range) {
}
/**
* Aim direction that makes a shell meet a moving target.
*
* @param speed muzzle speed in blocks per tick
* @param t* target position now
* @param tv* target velocity in blocks per tick
* @param sv* velocity the shell inherits from the shooter in blocks per tick, zero if it does not
* @return the solution or null if the shell cannot reach the target within the table
*/
public Lead lead(double mx, double my, double mz, double speed, double tx, double ty, double tz,
double tvx, double tvy, double tvz, double svx, double svy, double svz) {
double range = Math.sqrt(sq(tx - mx) + sq(ty - my) + sq(tz - mz));
double previousN = 0.0;
for (int n = 1; n <= MAX_TICKS; n++) {
double f = miss(n, mx, my, mz, speed, tx, ty, tz, tvx, tvy, tvz, svx, svy, svz);
if (f <= 0.0) {
double lo = previousN;
double hi = n;
for (int i = 0; i < 30; i++) {
double mid = 0.5 * (lo + hi);
if (miss(mid, mx, my, mz, speed, tx, ty, tz, tvx, tvy, tvz, svx, svy, svz) > 0.0) {
lo = mid;
} else {
hi = mid;
}
}
double t = 0.5 * (lo + hi);
double dn = d(t);
double rx = tx + tvx * t - mx - dn * svx;
double ry = ty + tvy * t - my - dn * svy + g(t);
double rz = tz + tvz * t - mz - dn * svz;
double length = Math.sqrt(rx * rx + ry * ry + rz * rz);
if (length < 1.0E-6) {
return null;
}
return new Lead(rx / length, ry / length, rz / length, t, range);
}
previousN = n;
}
return null;
}
/** Distance the shell can still cover short of where the target will be, positive means it has not got there yet. */
private double miss(double n, double mx, double my, double mz, double speed, double tx, double ty, double tz,
double tvx, double tvy, double tvz, double svx, double svy, double svz) {
double dn = d(n);
double rx = tx + tvx * n - mx - dn * svx;
double ry = ty + tvy * n - my - dn * svy + g(n);
double rz = tz + tvz * n - mz - dn * svz;
return Math.sqrt(rx * rx + ry * ry + rz * rz) - speed * dn;
}
private static double sq(double v) {
return v * v;
}
}
@@ -0,0 +1,155 @@
package dev.lumareth.improvedAerodynamics.client.pipper;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.level.block.state.properties.Property;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkStatus;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraftforge.fml.ModList;
import net.minecraftforge.registries.ForgeRegistries;
import org.joml.primitives.AABBic;
import java.util.ArrayList;
import java.util.List;
/**
* Finds the light autocannon of a ship and works out what it will shoot, the same way Warium's LACFireScript does:
* barrel count, muzzle, aim direction and muzzle speed.
*/
final class GunFinder {
private static final ResourceLocation LIGHT_AUTOCANNON = new ResourceLocation("crusty_chunks", "light_autocannon");
private static final TagKey<Block> BARREL = TagKey.create(Registries.BLOCK, new ResourceLocation("crusty_chunks", "acbarrel"));
private static final TagKey<Block> PASSTHROUGH = TagKey.create(Registries.BLOCK, new ResourceLocation("crusty_chunks", "barrelpassthrough"));
private static final int RESCAN_TICKS = 100;
private static final int MAX_GUNS = 16;
/** Muzzle and direction in ship space and the speed of the shell in blocks per tick. */
record Shot(double mx, double my, double mz, double dx, double dy, double dz, double speed) {
}
private long shipId = Long.MIN_VALUE;
private long lastScan = Long.MIN_VALUE;
private final List<BlockPos> guns = new ArrayList<>();
/** The gun closest to the seat, or null if the ship has none loaded. */
Shot find(ClientLevel level, long shipId, AABBic aabb, BlockPos seat) {
if (shipId != this.shipId || level.getGameTime() - lastScan >= RESCAN_TICKS || level.getGameTime() < lastScan) {
this.shipId = shipId;
lastScan = level.getGameTime();
scan(level, aabb);
}
BlockPos best = null;
double bestDistance = Double.MAX_VALUE;
for (BlockPos pos : guns) {
double distance = pos.distSqr(seat);
if (distance < bestDistance) {
bestDistance = distance;
best = pos;
}
}
return best == null ? null : shotOf(level, best);
}
private void scan(ClientLevel level, AABBic aabb) {
guns.clear();
Block gun = ForgeRegistries.BLOCKS.getValue(LIGHT_AUTOCANNON);
if (gun == null || aabb == null) {
return;
}
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
for (int cx = aabb.minX() >> 4; cx <= aabb.maxX() >> 4; cx++) {
for (int cz = aabb.minZ() >> 4; cz <= aabb.maxZ() >> 4; cz++) {
ChunkAccess chunk = level.getChunk(cx, cz, ChunkStatus.FULL, false);
if (chunk == null) {
continue;
}
LevelChunkSection[] sections = chunk.getSections();
for (int i = 0; i < sections.length; i++) {
LevelChunkSection section = sections[i];
if (section.hasOnlyAir() || !section.maybeHas(state -> state.is(gun))) {
continue;
}
int baseY = level.getSectionYFromSectionIndex(i) << 4;
for (int x = 0; x < 16; x++) {
for (int y = 0; y < 16; y++) {
for (int z = 0; z < 16; z++) {
if (section.getBlockState(x, y, z).is(gun) && guns.size() < MAX_GUNS) {
guns.add(pos.set((cx << 4) + x, baseY + y, (cz << 4) + z).immutable());
}
}
}
}
}
}
}
}
private static Shot shotOf(ClientLevel level, BlockPos gun) {
BlockState state = level.getBlockState(gun);
Property<?> property = state.getBlock().getStateDefinition().getProperty("facing");
if (!(property instanceof DirectionProperty facingProperty)) {
return null;
}
Direction facing = state.getValue(facingProperty);
int fx = facing.getStepX();
int fy = facing.getStepY();
int fz = facing.getStepZ();
// Barrels, counted like Warium does: up to five more behind the first one
double barrels = 1.0;
for (int i = 0; i < 5 && level.getBlockState(gun.offset(fx * (int) (1 + barrels), fy * (int) (1 + barrels), fz * (int) (1 + barrels))).is(BARREL); i++) {
barrels++;
}
// Muzzle: past the barrels, skipping through blocks the shell passes
double position = barrels;
for (int i = 0; i < 10; i++) {
BlockState at = level.getBlockState(gun.offset(fx * (int) (1 + position), fy * (int) (1 + position), fz * (int) (1 + position)));
if ((!at.isAir() && !at.canOcclude()) || at.is(PASSTHROUGH)) {
position++;
} else {
break;
}
}
double mx = gun.getX() + fx * (1.5 + position) + 0.5;
double my = gun.getY() + fy * (1.5 + position) + 0.5;
double mz = gun.getZ() + fz * (1.5 + position) + 0.5;
// Aim, from the tags of the gun itself
double aimX = 0.0;
double aimY = 0.0;
double aimZ = 0.0;
BlockEntity entity = level.getBlockEntity(gun);
double gx = entity == null ? 0.0 : entity.getPersistentData().getDouble("X");
double gz = entity == null ? 0.0 : entity.getPersistentData().getDouble("Z");
double gpitch = entity == null ? 0.0 : entity.getPersistentData().getDouble("Pitch");
if (1.0 > Math.abs(fx - gx) || 1.0 > Math.abs(fz - gz)) {
aimY = gpitch;
aimX = gx + fx;
aimZ = gz + fz;
}
if (aimX * aimZ == 0.0) {
aimX = fx;
aimZ = fz;
}
double length = Math.sqrt(aimX * aimX + aimY * aimY + aimZ * aimZ);
if (length < 1.0E-6) {
return null;
}
double speed = PipperConfig.MUZZLE_SPEED_OVERRIDE.get();
if (speed <= 0.0) {
double multiplier = ModList.get().isLoaded("ritchiesprojectilelib") ? 2.0 : 1.0;
speed = (5.0 + barrels / 1.5) * multiplier;
}
return new Shot(mx, my, mz, aimX / length, aimY / length, aimZ / length, speed);
}
}
@@ -0,0 +1,251 @@
package dev.lumareth.improvedAerodynamics.client.pipper;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.BufferUploader;
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.blaze3d.vertex.Tesselator;
import com.mojang.blaze3d.vertex.VertexFormat;
import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.GameRenderer;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.RenderLevelStageEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.ModList;
import net.minecraftforge.fml.common.Mod;
import org.joml.Matrix4f;
import org.joml.Vector3d;
import org.joml.Vector3f;
import org.valkyrienskies.core.api.ships.ClientShip;
import org.valkyrienskies.core.api.ships.Ship;
import org.valkyrienskies.mod.common.VSGameUtilsKt;
/**
* Draws two markers for the first light autocannon of the aircraft you sit in:
* an orange ring where its shells come down on the ground, and, for the radar target selected on the seat, a green
* ring you put on the target to hit it (the lead pipper): green from the radar, cyan while following the ship itself,
* yellow while only extrapolating a lost contact. Both come from the same ballistics Warium's shells follow.
*/
@Mod.EventBusSubscriber(modid = ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT)
public final class GunPipperRenderer {
private static final int IMPACT_COLOR = 0xFFFF9020;
private static final int LEAD_COLOR = 0xFF40FF60;
private static final int VISUAL_COLOR = 0xFF40E8FF;
private static final int COAST_COLOR = 0xFFFFE040;
private static final float DISTANCE = 2.0F;
private static final GunFinder FINDER = new GunFinder();
private static final TargetTrack TRACK = new TargetTrack();
private static Ballistics ballistics;
private static double ballisticsDrag = Double.NaN;
private static double ballisticsGravity = Double.NaN;
private GunPipperRenderer() {
}
@SubscribeEvent
public static void onRenderLevel(RenderLevelStageEvent event) {
if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_PARTICLES || !PipperConfig.SPEC.isLoaded()
|| !PipperConfig.ENABLED.get() || !ModList.get().isLoaded("valkyrien_warium")) {
return;
}
Minecraft minecraft = Minecraft.getInstance();
ClientLevel level = minecraft.level;
if (level == null || minecraft.player == null) {
return;
}
Entity seat = WariumSeat.seatOf(minecraft.player);
if (seat == null) {
TRACK.reset();
return;
}
BlockPos seatPos = BlockPos.containing(seat.getX(), seat.getY(), seat.getZ());
Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, seatPos);
if (ship == null) {
return;
}
GunFinder.Shot shot = FINDER.find(level, ship.getId(), ship.getShipAABB(), seatPos);
if (shot == null) {
return;
}
org.joml.Matrix4dc shipToWorld = ship instanceof ClientShip clientShip
? clientShip.getRenderTransform().getShipToWorld() : ship.getShipToWorld();
Vector3d muzzle = shipToWorld.transformPosition(new Vector3d(shot.mx(), shot.my(), shot.mz()));
Vector3d direction = shipToWorld.transformDirection(new Vector3d(shot.dx(), shot.dy(), shot.dz())).normalize();
double inherit = PipperConfig.INHERIT_SHIP_VELOCITY.get() ? 1.0 / 20.0 : 0.0;
org.joml.Vector3dc velocity = ship.getVelocity();
double svx = velocity.x() * inherit;
double svy = velocity.y() * inherit;
double svz = velocity.z() * inherit;
Ballistics table = table();
Camera camera = event.getCamera();
Matrix4f matrix = event.getPoseStack().last().pose();
BufferBuilder builder = Tesselator.getInstance().getBuilder();
RenderSystem.disableDepthTest();
RenderSystem.enableBlend();
RenderSystem.defaultBlendFunc();
RenderSystem.disableCull();
RenderSystem.setShader(GameRenderer::getPositionColorShader);
builder.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR);
boolean drawn = false;
if (PipperConfig.SHOW_IMPACT.get()) {
double[] impact = table.impact(muzzle.x, muzzle.y, muzzle.z,
direction.x * shot.speed() + svx, direction.y * shot.speed() + svy, direction.z * shot.speed() + svz,
(x, z) -> level.getHeight(Heightmap.Types.MOTION_BLOCKING, Mth.floor(x), Mth.floor(z)));
if (impact != null) {
drawn |= marker(builder, matrix, camera, impact[0], impact[1], impact[2], IMPACT_COLOR, false);
}
}
if (PipperConfig.SHOW_LEAD.get()) {
drawn |= lead(builder, matrix, camera, level, seat, seatPos, ship.getId(), table, muzzle, shot.speed(), svx, svy, svz);
} else {
TRACK.reset();
}
if (drawn) {
BufferUploader.drawWithShader(builder.end());
} else {
builder.end().release();
}
RenderSystem.enableCull();
RenderSystem.enableDepthTest();
RenderSystem.disableBlend();
}
private static boolean lead(BufferBuilder builder, Matrix4f matrix, Camera camera, ClientLevel level, Entity seat, BlockPos seatPos,
long ownShipId, Ballistics table, Vector3d muzzle, double speed, double svx, double svy, double svz) {
int selected = WariumSeat.selectedTarget(seat);
double[] radar = selected > 0 ? radarPosition(level, seatPos, selected) : null;
TargetTrack.Fix fix = TRACK.resolve(level, ownShipId, selected, radar, level.getGameTime(),
PipperConfig.VISUAL_TRACK.get(), PipperConfig.COAST_SECONDS.get());
if (fix == null) {
return false;
}
Ballistics.Lead lead = table.lead(muzzle.x, muzzle.y, muzzle.z, speed, fix.x(), fix.y(), fix.z(),
fix.vx(), fix.vy(), fix.vz(), svx, svy, svz);
if (lead == null) {
return false;
}
int color = switch (fix.source()) {
case RADAR -> LEAD_COLOR;
case VISUAL -> VISUAL_COLOR;
case COAST -> COAST_COLOR;
};
return marker(builder, matrix, camera,
muzzle.x + lead.dirX() * lead.range(), muzzle.y + lead.dirY() * lead.range(), muzzle.z + lead.dirZ() * lead.range(),
color, true);
}
/** Position the node reports for a target index, or null if it reports none. */
private static double[] radarPosition(ClientLevel level, BlockPos seatPos, int selected) {
BlockEntity seatEntity = level.getBlockEntity(seatPos);
if (seatEntity == null) {
return null;
}
var seatData = seatEntity.getPersistentData();
BlockEntity node = level.getBlockEntity(BlockPos.containing(seatData.getDouble("ControlX"), seatData.getDouble("ControlY"), seatData.getDouble("ControlZ")));
if (node == null) {
return null;
}
var data = node.getPersistentData();
// Warium builds these keys from a double, so they read TargetX1.0, TargetY1.0 and so on
double tx = data.getDouble("TargetX" + (double) selected);
double ty = data.getDouble("TargetY" + (double) selected);
double tz = data.getDouble("TargetZ" + (double) selected);
if (tx == 0.0 && ty == 0.0 && tz == 0.0) {
return null;
}
return new double[] {tx, ty, tz};
}
private static Ballistics table() {
double drag = PipperConfig.DRAG.get();
double gravity = PipperConfig.GRAVITY.get();
if (ballistics == null || drag != ballisticsDrag || gravity != ballisticsGravity) {
ballistics = new Ballistics(drag, gravity);
ballisticsDrag = drag;
ballisticsGravity = gravity;
}
return ballistics;
}
/** A ring on the screen in the direction of a world point, with ticks on it for the lead pipper. */
private static boolean marker(BufferBuilder builder, Matrix4f matrix, Camera camera, double x, double y, double z, int color, boolean ticks) {
Vec3 cameraPosition = camera.getPosition();
Vector3f toPoint = new Vector3f((float) (x - cameraPosition.x), (float) (y - cameraPosition.y), (float) (z - cameraPosition.z));
if (toPoint.lengthSquared() < 1.0E-6F) {
return false;
}
toPoint.normalize();
if (toPoint.dot(camera.getLookVector()) <= 0.05F) {
return false;
}
Vector3f center = new Vector3f(toPoint).mul(DISTANCE);
Vector3f left = new Vector3f(camera.getLeftVector());
Vector3f up = new Vector3f(camera.getUpVector());
float radius = (float) (PipperConfig.MARKER_SIZE.get() * DISTANCE);
float thickness = radius * 0.12F;
ring(builder, matrix, center, left, up, radius, thickness, color);
quad(builder, matrix, center, left, up, thickness * 1.6F, thickness * 1.6F, color);
if (ticks) {
for (int i = 0; i < 4; i++) {
double angle = i * Math.PI / 2.0;
float cx = (float) Math.cos(angle);
float cy = (float) Math.sin(angle);
Vector3f tickCenter = new Vector3f(center)
.fma(cx * radius * 1.45F, left).fma(cy * radius * 1.45F, up);
float halfX = Math.abs(cx) > 0.5F ? radius * 0.35F : thickness;
float halfY = Math.abs(cy) > 0.5F ? radius * 0.35F : thickness;
quad(builder, matrix, tickCenter, left, up, halfX, halfY, color);
}
}
return true;
}
public static void ring(BufferBuilder builder, Matrix4f matrix, Vector3f center, Vector3f left, Vector3f up, float radius, float thickness, int color) {
final int segments = 32;
float inner = radius - thickness;
float outer = radius + thickness;
for (int i = 0; i < segments; i++) {
double a0 = i * 2.0 * Math.PI / segments;
double a1 = (i + 1) * 2.0 * Math.PI / segments;
vertex(builder, matrix, center, left, up, (float) Math.cos(a0) * inner, (float) Math.sin(a0) * inner, color);
vertex(builder, matrix, center, left, up, (float) Math.cos(a0) * outer, (float) Math.sin(a0) * outer, color);
vertex(builder, matrix, center, left, up, (float) Math.cos(a1) * outer, (float) Math.sin(a1) * outer, color);
vertex(builder, matrix, center, left, up, (float) Math.cos(a1) * inner, (float) Math.sin(a1) * inner, color);
}
}
public static void quad(BufferBuilder builder, Matrix4f matrix, Vector3f center, Vector3f left, Vector3f up, float halfX, float halfY, int color) {
vertex(builder, matrix, center, left, up, -halfX, -halfY, color);
vertex(builder, matrix, center, left, up, halfX, -halfY, color);
vertex(builder, matrix, center, left, up, halfX, halfY, color);
vertex(builder, matrix, center, left, up, -halfX, halfY, color);
}
private static void vertex(BufferBuilder builder, Matrix4f matrix, Vector3f center, Vector3f left, Vector3f up, float a, float b, int color) {
// The left vector points to the left of the screen, so a positive offset along it is drawn to the left
float vx = center.x - left.x * a + up.x * b;
float vy = center.y - left.y * a + up.y * b;
float vz = center.z - left.z * a + up.z * b;
builder.vertex(matrix, vx, vy, vz)
.color(color >> 16 & 255, color >> 8 & 255, color & 255, color >>> 24)
.endVertex();
}
}
@@ -0,0 +1,53 @@
package dev.lumareth.improvedAerodynamics.client.pipper;
import net.minecraftforge.common.ForgeConfigSpec;
/** Client side settings of the gun pipper. Lives in improved_aerodynamics-client.toml. */
public final class PipperConfig {
public static final ForgeConfigSpec SPEC;
public static final ForgeConfigSpec.BooleanValue ENABLED;
public static final ForgeConfigSpec.BooleanValue SHOW_IMPACT;
public static final ForgeConfigSpec.BooleanValue SHOW_LEAD;
public static final ForgeConfigSpec.BooleanValue INHERIT_SHIP_VELOCITY;
public static final ForgeConfigSpec.BooleanValue VISUAL_TRACK;
public static final ForgeConfigSpec.DoubleValue COAST_SECONDS;
public static final ForgeConfigSpec.DoubleValue MUZZLE_SPEED_OVERRIDE;
public static final ForgeConfigSpec.DoubleValue GRAVITY;
public static final ForgeConfigSpec.DoubleValue DRAG;
public static final ForgeConfigSpec.DoubleValue MARKER_SIZE;
static {
ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder();
builder.comment("Gun pipper for Warium light autocannons, drawn while seated in a Warium seat").push("pipper");
ENABLED = builder.comment("Master switch")
.define("enabled", true);
SHOW_IMPACT = builder.comment("Show where the shells of the gun come down on the ground (orange ring)")
.define("showImpact", true);
SHOW_LEAD = builder.comment("Show the lead pipper for the radar target you selected (green ring)")
.define("showLead", true);
VISUAL_TRACK = builder.comment("Follow the selected target's ship itself once the radar has shown it, so the lead pipper keeps working when",
"the radar loses it (notching, speed gate, ground clutter). Only while the ship is loaded, which is within render distance",
"(cyan ring)")
.define("visualTrack", true);
COAST_SECONDS = builder.comment("Seconds a lost radar contact is extrapolated from its last position and velocity when its ship is not loaded",
"(yellow ring), 0 turns it off")
.defineInRange("coastSeconds", 8.0, 0.0, 60.0);
INHERIT_SHIP_VELOCITY = builder.comment("Add the speed of your aircraft to the shells. Warium does not do this itself, only turn it",
"on if a test shows your shells keep the speed of the jet (fire level at full speed, then hovering)")
.define("inheritShipVelocity", false);
MUZZLE_SPEED_OVERRIDE = builder.comment("Muzzle speed in blocks per tick, 0 works it out from the barrel length like Warium does")
.defineInRange("muzzleSpeedOverride", 0.0, 0.0, 100.0);
GRAVITY = builder.comment("Gravity of the shells in blocks per tick squared")
.defineInRange("gravity", 0.05, 0.0, 1.0);
DRAG = builder.comment("Speed the shells keep each tick, 0.99 is a vanilla arrow")
.defineInRange("drag", 0.99, 0.5, 1.0);
MARKER_SIZE = builder.comment("Radius of the markers on screen")
.defineInRange("markerSize", 0.03, 0.005, 0.2);
builder.pop();
SPEC = builder.build();
}
private PipperConfig() {
}
}
@@ -0,0 +1,131 @@
package dev.lumareth.improvedAerodynamics.client.pipper;
import net.minecraft.client.multiplayer.ClientLevel;
import org.joml.Vector3d;
import org.joml.primitives.AABBdc;
import org.valkyrienskies.core.api.ships.ClientShip;
import org.valkyrienskies.mod.common.VSGameUtilsKt;
/**
* The target the lead pipper aims at. The radar is only the way in: its contact is matched to the closest loaded ship
* and from then on that ship is followed directly, with its true position and velocity, so the pipper survives the
* radar losing the contact (notching, the speed gate, ground clutter). Without a loaded ship, which happens beyond
* render distance, the last radar position is extrapolated for a limited time.
* <p>
* The selected index is no help for this: the radar rebuilds its list, so after a loss the index can point at another
* contact or at an empty slot.
*/
final class TargetTrack {
/** A radar position this close to a ship is taken to be that ship. */
private static final double SNAP_BLOCKS = 80.0;
/** A fresh radar position this far from the followed ship means the player moved on to another target. */
private static final double REBIND_BLOCKS = 250.0;
private static final long NONE = Long.MIN_VALUE;
enum Source {
RADAR, VISUAL, COAST
}
/** Position in blocks, velocity in blocks per tick. */
record Fix(double x, double y, double z, double vx, double vy, double vz, Source source) {
}
private final TargetTracker radar = new TargetTracker();
private int index = -1;
private long boundId = NONE;
void reset() {
radar.reset();
index = -1;
boundId = NONE;
}
/**
* @param radarPosition the position the node reports for the selected index, or null if it reports none
*/
Fix resolve(ClientLevel level, long ownShipId, int selected, double[] radarPosition, long tick,
boolean visualTrack, double coastSeconds) {
if (selected <= 0) {
reset();
return null;
}
if (selected != index) {
reset();
index = selected;
}
if (radarPosition != null) {
radar.update(selected, radarPosition[0], radarPosition[1], radarPosition[2], tick);
}
if (visualTrack) {
ClientShip bound = boundId == NONE ? null : shipById(level, boundId);
if (radarPosition != null && radar.age(tick) <= 1) {
// Fresh contact: pick the ship it belongs to, or let go of the old one if the player switched
if (bound == null || distance(center(bound), radarPosition) > REBIND_BLOCKS) {
bound = nearest(level, ownShipId, radarPosition);
boundId = bound == null ? NONE : bound.getId();
}
}
if (bound != null) {
Vector3d center = center(bound);
var velocity = bound.getVelocity();
return new Fix(center.x, center.y, center.z, velocity.x() / 20.0, velocity.y() / 20.0, velocity.z() / 20.0, Source.VISUAL);
}
boundId = NONE;
}
if (!radar.hasPosition()) {
return null;
}
long age = radar.age(tick);
if (age > TargetTracker.STALE_TICKS) {
if (coastSeconds <= 0.0 || !radar.hasVelocity() || age > coastSeconds * 20.0) {
return null;
}
double[] p = radar.predicted(tick);
return new Fix(p[0], p[1], p[2], radar.vx(), radar.vy(), radar.vz(), Source.COAST);
}
double[] p = radar.predicted(tick);
// A contact is only extrapolated up to the age of its last update
return new Fix(p[0], p[1], p[2], radar.vx(), radar.vy(), radar.vz(), Source.RADAR);
}
private static ClientShip shipById(ClientLevel level, long id) {
for (ClientShip ship : VSGameUtilsKt.getShipObjectWorld(level).getLoadedShips()) {
if (ship.getId() == id) {
return ship;
}
}
return null;
}
private static ClientShip nearest(ClientLevel level, long ownShipId, double[] position) {
ClientShip best = null;
double bestDistance = SNAP_BLOCKS;
for (ClientShip ship : VSGameUtilsKt.getShipObjectWorld(level).getLoadedShips()) {
if (ship.getId() == ownShipId) {
continue;
}
double distance = distance(center(ship), position);
if (distance < bestDistance) {
bestDistance = distance;
best = ship;
}
}
return best;
}
/** The same point Warium's radar reports: the center of the world bounding box. */
private static Vector3d center(ClientShip ship) {
AABBdc box = ship.getWorldAABB();
return box.center(new Vector3d());
}
private static double distance(Vector3d a, double[] b) {
return Math.sqrt(sq(a.x - b[0]) + sq(a.y - b[1]) + sq(a.z - b[2]));
}
private static double sq(double v) {
return v * v;
}
}
@@ -0,0 +1,113 @@
package dev.lumareth.improvedAerodynamics.client.pipper;
/**
* Target velocity from the radar. The node only stores positions, and they change when the radar updates, which is
* not every tick. So the velocity is the change between two distinct positions divided by the ticks between them,
* smoothed. When the radar stops updating the last velocity is kept so the track can be extrapolated.
*/
final class TargetTracker {
/** Ticks without a new position after which the contact counts as lost and is only extrapolated. */
static final long STALE_TICKS = 40;
private static final double SMOOTHING = 0.5;
private static final double JUMP_BLOCKS_PER_TICK = 15.0;
private int index = -1;
private double px;
private double py;
private double pz;
private long sampleTick;
private double vx;
private double vy;
private double vz;
private boolean hasVelocity;
void update(int selected, double x, double y, double z, long tick) {
if (selected != index || tick < sampleTick) {
index = selected;
px = x;
py = y;
pz = z;
sampleTick = tick;
hasVelocity = false;
vx = 0.0;
vy = 0.0;
vz = 0.0;
return;
}
if (x != px || y != py || z != pz) {
long dt = Math.max(1L, tick - sampleTick);
double nx = (x - px) / dt;
double ny = (y - py) / dt;
double nz = (z - pz) / dt;
if (Math.sqrt(nx * nx + ny * ny + nz * nz) > JUMP_BLOCKS_PER_TICK) {
// Teleport or a different contact reusing the slot
hasVelocity = false;
} else if (hasVelocity) {
vx += (nx - vx) * SMOOTHING;
vy += (ny - vy) * SMOOTHING;
vz += (nz - vz) * SMOOTHING;
} else {
vx = nx;
vy = ny;
vz = nz;
hasVelocity = true;
}
px = x;
py = y;
pz = z;
sampleTick = tick;
}
}
void reset() {
index = -1;
hasVelocity = false;
vx = 0.0;
vy = 0.0;
vz = 0.0;
}
boolean hasPosition() {
return index >= 0;
}
/** Ticks since the radar last gave a new position. */
long age(long tick) {
return tick - sampleTick;
}
/** The last radar position moved on by the velocity over the time since. */
double[] predicted(long tick) {
long dt = Math.max(0L, tick - sampleTick);
return new double[] {px + vx * dt, py + vy * dt, pz + vz * dt};
}
double lastX() {
return px;
}
double lastY() {
return py;
}
double lastZ() {
return pz;
}
/** Velocity in blocks per tick, zero when unknown. */
double vx() {
return vx;
}
double vy() {
return vy;
}
double vz() {
return vz;
}
boolean hasVelocity() {
return hasVelocity;
}
}
@@ -0,0 +1,20 @@
package dev.lumareth.improvedAerodynamics.client.pipper;
import net.mcreator.valkyrienwarium.entity.SeatEntityEntity;
import net.minecraft.world.entity.Entity;
/** Everything that touches Warium VS classes, so nothing else loads them when Warium is missing. */
final class WariumSeat {
private WariumSeat() {
}
static Entity seatOf(Entity rider) {
Entity root = rider.getRootVehicle();
return root instanceof SeatEntityEntity ? root : null;
}
/** Index of the radar target selected on the seat, 1 based, 0 for none. */
static int selectedTarget(Entity seat) {
return seat.getEntityData().get(SeatEntityEntity.DATA_SelectedTarget);
}
}
@@ -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));
} }
@@ -19,7 +19,7 @@ import java.util.concurrent.ConcurrentHashMap;
* to; at the end of the tick that set is handed to the physics thread, which computes the forces from the ship's * to; at the end of the tick that set is handed to the physics thread, which computes the forces from the ship's
* motion in each substep until the next tick arrives. * motion in each substep until the next tick arrives.
* <p> * <p>
* The physics side also smooths pilot input, runs auto trim, and integrates damping implicitly so stiff damping * The physics side also smooths pilot input, runs the flight computer (auto trim), and integrates damping implicitly so stiff damping
* cannot overshoot. * cannot overshoot.
*/ */
public final class AeroState implements PhysicsBridge.Publishable { public final class AeroState implements PhysicsBridge.Publishable {
@@ -27,6 +27,9 @@ public final class AeroState implements PhysicsBridge.Publishable {
private static final double INPUT_RATE_PER_TICK = 0.2; private static final double INPUT_RATE_PER_TICK = 0.2;
private static final double VELOCITY_EPSILON = 0.05; private static final double VELOCITY_EPSILON = 0.05;
private static final double ANGULAR_EPSILON = 0.005; 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; private final SubstepClock clock;
@@ -40,7 +43,12 @@ public final class AeroState implements PhysicsBridge.Publishable {
private Frame current; private Frame current;
private boolean[] stalled = new boolean[0]; private boolean[] stalled = new boolean[0];
private boolean wingStalled; private boolean wingStalled;
// Lift the wings could make at the edge of the stall in the airflow of the last substep, NaN without the improved
// flight model, which has no stall
private double maxLift = Double.NaN;
private final Map<Long, NodeState> nodeStates = new ConcurrentHashMap<>(); private final Map<Long, NodeState> nodeStates = new ConcurrentHashMap<>();
// Every ship's node states by node position, for the control surface animation on the server thread
private static final Map<Long, NodeState> ALL_NODE_STATES = new ConcurrentHashMap<>();
private final Wrench wrench = new Wrench(); private final Wrench wrench = new Wrench();
private final Wrench probe = new Wrench(); private final Wrench probe = new Wrench();
@@ -56,19 +64,48 @@ public final class AeroState implements PhysicsBridge.Publishable {
double trimOutput; double trimOutput;
// Deflection of this node's flaps in radians, moves towards what the flap lever and airspeed allow // Deflection of this node's flaps in radians, moves towards what the flap lever and airspeed allow
double flapAngle; double flapAngle;
// Wall clock time of the last physics substep that ran this node, to tell whether its ship is flying
volatile long updatedMillis;
// Total pitch input the flight computer applied last substep, pilot and trim together
double computerOutput;
// Torque (ship axes) one unit of pitch input on this node's pitch surfaces makes, measured every substep // Torque (ship axes) one unit of pitch input on this node's pitch surfaces makes, measured every substep
final Vector3d pitchResponse = new Vector3d(); final Vector3d pitchResponse = new Vector3d();
final Vector3d pitchResponseBuilding = new Vector3d(); final Vector3d pitchResponseBuilding = new Vector3d();
// The same for yaw and roll, measured while cursor aim flies the node
final Vector3d yawResponse = new Vector3d();
final Vector3d yawResponseBuilding = new Vector3d();
final Vector3d rollResponse = new Vector3d();
final Vector3d rollResponseBuilding = new Vector3d();
// Fastest steady pitch rate (rad/s) the pitch surfaces can hold against the craft's pitch damping, smoothed,
// measured while cursor aim flies the node. NaN until measured
double controlRate = Double.NaN;
// Cursor aim is rolling the craft over to reach a cursor far below the nose
boolean reversing;
// Share of the pull cursor aim keeps, eased down while the wings are stalled
double stallEase = 1.0;
// Which way pitch and yaw input turn the craft (+1 or -1 for nose up and nose right), learned from the measured
// responses. Until then Warium's keys: W (Pitch+) is usually nose down, D (Yaw+) nose right
double pitchSign = -1.0;
double yawSign = 1.0;
final AutoTrim.Targets trimTargets = new AutoTrim.Targets(); final AutoTrim.Targets trimTargets = new AutoTrim.Targets();
volatile double savedTrim; volatile double savedTrim;
Vector3d responseBuilding(ControlAxis axis) {
return switch (axis) {
case YAW -> yawResponseBuilding;
case ROLL -> rollResponseBuilding;
default -> pitchResponseBuilding;
};
}
NodeState(NodeData data) { NodeState(NodeData data) {
pitch = data.pitch(); pitch = data.pitch();
yaw = data.yaw(); yaw = data.yaw();
roll = data.roll(); roll = data.roll();
trimIntegral = data.autoTrim() ? data.storedTrim() : 0.0; trimIntegral = data.autoTrim() ? data.storedTrim() : 0.0;
trimOutput = trimIntegral; trimOutput = data.autoTrim() ? 0.0 : trimIntegral;
savedTrim = trimIntegral; savedTrim = trimIntegral;
computerOutput = pitch + trimOutput;
flapAngle = Math.toRadians(FlapSetting.of(data.flapSetting()).degrees()); flapAngle = Math.toRadians(FlapSetting.of(data.flapSetting()).degrees());
} }
} }
@@ -134,9 +171,14 @@ public final class AeroState implements PhysicsBridge.Publishable {
double dt = clock.substepSeconds(); double dt = clock.substepSeconds();
boolean improved = Config.IMPROVED_FLIGHT_MODEL.get(); boolean improved = Config.IMPROVED_FLIGHT_MODEL.get();
Matrix3dc inertia = ship.getMomentOfInertia(); 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); evaluate(frame, centerOfMass, v, w, improved, true, wrench);
measureControlRates(frame, centerOfMass, v, w, improved, dt);
if (Config.IMPLICIT_DAMPING.get()) { if (Config.IMPLICIT_DAMPING.get()) {
// Implicit Euler for the damping part: scale each axis by 1 / (1 + dt * damping / inertia), using the // Implicit Euler for the damping part: scale each axis by 1 / (1 + dt * damping / inertia), using the
@@ -163,9 +205,6 @@ public final class AeroState implements PhysicsBridge.Publishable {
} }
} }
// Same frames and scaling as Warium applies its forces in
double scale = transform.getShipToWorldScaling().x();
double forceScale = autoScale ? scale * scale * scale : 1.0;
Vector3d force = new Vector3d(wrench.force[0], wrench.force[1], wrench.force[2]).mul(forceScale); Vector3d 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); Vector3d forceTorque = new Vector3d(wrench.forceTorque[0], wrench.forceTorque[1], wrench.forceTorque[2]).mul(forceScale);
if (force.isFinite() && forceTorque.isFinite()) { if (force.isFinite() && forceTorque.isFinite()) {
@@ -192,9 +231,15 @@ public final class AeroState implements PhysicsBridge.Publishable {
for (int i = 0; i < stalled.length; i++) { for (int i = 0; i < stalled.length; i++) {
stalled[i] = previous.get(frame.surfaces[i].key()); stalled[i] = previous.get(frame.surfaces[i].key());
} }
nodeStates.keySet().removeIf(key -> !frame.nodes.containsKey((long) key)); nodeStates.keySet().removeIf(key -> {
boolean gone = !frame.nodes.containsKey((long) key);
if (gone) {
ALL_NODE_STATES.remove(key);
}
return gone;
});
for (NodeData node : frame.nodes.values()) { for (NodeData node : frame.nodes.values()) {
nodeStates.computeIfAbsent(node.key(), key -> new NodeState(node)); ALL_NODE_STATES.put(node.key(), nodeStates.computeIfAbsent(node.key(), key -> new NodeState(node)));
} }
current = frame; current = frame;
} }
@@ -212,8 +257,11 @@ public final class AeroState implements PhysicsBridge.Publishable {
double[] r = new double[3]; double[] r = new double[3];
if (commit) { if (commit) {
wingStalled = false; wingStalled = false;
maxLift = improved ? 0.0 : Double.NaN;
for (NodeState state : nodeStates.values()) { for (NodeState state : nodeStates.values()) {
state.pitchResponseBuilding.zero(); state.pitchResponseBuilding.zero();
state.yawResponseBuilding.zero();
state.rollResponseBuilding.zero();
} }
} }
@@ -224,7 +272,7 @@ public final class AeroState implements PhysicsBridge.Publishable {
r[2] = s.z() - centerOfMass.z(); r[2] = s.z() - centerOfMass.z();
NodeData node = s.node() != AeroSurface.NO_NODE ? frame.nodes.get(s.node()) : null; NodeData node = s.node() != AeroSurface.NO_NODE ? frame.nodes.get(s.node()) : null;
NodeState state = node != null ? nodeStates.get(node.key()) : null; NodeState state = node != null ? nodeStates.get(node.key()) : null;
double input = s.type().controllable ? input(s, node, state, 0.0) : 0.0; double input = s.type().controllable ? input(s, node, state, ControlAxis.NONE, 0.0) : 0.0;
double flap = s.flap() && state != null ? state.flapAngle : 0.0; double flap = s.flap() && state != null ? state.flapAngle : 0.0;
if (improved) { if (improved) {
@@ -232,16 +280,24 @@ public final class AeroState implements PhysicsBridge.Publishable {
if (commit) { if (commit) {
stalled[i] = nowStalled; stalled[i] = nowStalled;
wingStalled |= nowStalled && s.type() == SurfaceType.WING; 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 { } else {
WariumAero.evaluate(s, v, w, r, input, weight, out); WariumAero.evaluate(s, v, w, r, input, weight, out);
} }
// How much a little more pitch input on this node would change the torque, for auto trim // How much a little more input on an axis would change the torque: pitch for auto trim, all three for
if (commit && node != null && node.autoTrim() && s.type().controllable && s.listensToPitch()) { // cursor aim
if (commit && node != null && s.type().controllable) {
boolean aimed = CursorAimRegistry.isActive(node.key());
for (ControlAxis axis : MEASURED_AXES) {
if (!s.listensTo(axis) || !(aimed || (axis == ControlAxis.PITCH && node.autoTrim()))) {
continue;
}
double delta = 0.05; double delta = 0.05;
probe.clear(); probe.clear();
double base = 0.0;
if (improved) { if (improved) {
ImprovedAero.evaluate(s, v, w, r, input, flap, weight, tuning, stalled[i], probe); ImprovedAero.evaluate(s, v, w, r, input, flap, weight, tuning, stalled[i], probe);
} else { } else {
@@ -249,59 +305,100 @@ public final class AeroState implements PhysicsBridge.Publishable {
} }
double[] before = {probe.totalTorque(0), probe.totalTorque(1), probe.totalTorque(2)}; double[] before = {probe.totalTorque(0), probe.totalTorque(1), probe.totalTorque(2)};
probe.clear(); probe.clear();
double nudged = input(s, node, state, delta); double nudged = input(s, node, state, axis, delta);
if (improved) { if (improved) {
ImprovedAero.evaluate(s, v, w, r, nudged, flap, weight, tuning, stalled[i], probe); ImprovedAero.evaluate(s, v, w, r, nudged, flap, weight, tuning, stalled[i], probe);
} else { } else {
WariumAero.evaluate(s, v, w, r, nudged, weight, probe); WariumAero.evaluate(s, v, w, r, nudged, weight, probe);
} }
state.pitchResponseBuilding.add( state.responseBuilding(axis).add(
(probe.totalTorque(0) - before[0]) / delta, (probe.totalTorque(0) - before[0]) / delta,
(probe.totalTorque(1) - before[1]) / delta, (probe.totalTorque(1) - before[1]) / delta,
(probe.totalTorque(2) - before[2]) / delta); (probe.totalTorque(2) - before[2]) / delta);
} }
} }
}
if (commit) { if (commit) {
for (NodeState state : nodeStates.values()) { for (NodeState state : nodeStates.values()) {
state.pitchResponse.set(state.pitchResponseBuilding); state.pitchResponse.set(state.pitchResponseBuilding);
state.yawResponse.set(state.yawResponseBuilding);
state.rollResponse.set(state.rollResponseBuilding);
} }
} }
} }
/**
* 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. */ /** 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, double extraPitch) { private static double input(AeroSurface s, NodeData node, NodeState state, ControlAxis nudged, double extra) {
if (node == null || state == null) { if (node == null || state == null) {
return 0.0; return 0.0;
} }
double value = axisValue(s.axis1(), node, state, extraPitch) * s.multiplier1() double value = axisValue(s.axis1(), node, state, nudged, extra) * s.multiplier1()
+ axisValue(s.axis2(), node, state, extraPitch) * s.multiplier2(); + axisValue(s.axis2(), node, state, nudged, extra) * s.multiplier2();
return Math.max(-1.0, Math.min(1.0, value)); return Math.max(-1.0, Math.min(1.0, value));
} }
private static double axisValue(ControlAxis axis, NodeData node, NodeState state, double extraPitch) { private static double axisValue(ControlAxis axis, NodeData node, NodeState state, ControlAxis nudged, double extra) {
double nudge = axis == nudged ? extra : 0.0;
return switch (axis) { return switch (axis) {
case PITCH -> state.pitch + state.trimOutput + extraPitch; case PITCH -> state.pitch + state.trimOutput + nudge;
case YAW -> state.yaw; case YAW -> state.yaw + nudge;
case ROLL -> state.roll; case ROLL -> state.roll + nudge;
case THROTTLE -> node.throttle(); case THROTTLE -> node.throttle();
case NONE -> 0.0; case NONE -> 0.0;
}; };
} }
private void updateNodes(Frame frame, Vector3dc worldVelocity, double altitude, Quaterniondc rotation, Matrix3dc inertia, 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; 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)};
for (NodeData node : frame.nodes.values()) { for (NodeData node : frame.nodes.values()) {
NodeState state = nodeStates.get(node.key()); NodeState state = nodeStates.get(node.key());
if (state == null) { if (state == null) {
continue; continue;
} }
state.pitch = moveTowards(state.pitch, node.pitch(), step); // Cursor aim, if the pilot has it on, supplies the inputs the pilot is not giving with the keys
state.yaw = moveTowards(state.yaw, node.yaw(), step); NodeData effective = CursorPilot.apply(state, node, rotation, inertia, w, worldVelocity.length(), wingStalled,
state.roll = moveTowards(state.roll, node.roll(), step); liftAcceleration, dt);
state.pitch = moveTowards(state.pitch, effective.pitch(), step);
state.yaw = moveTowards(state.yaw, effective.yaw(), step);
state.roll = moveTowards(state.roll, effective.roll(), step);
updateFlaps(state, node, worldVelocity.length(), dt); updateFlaps(state, node, worldVelocity.length(), dt);
AutoTrim.update(state, node, worldVelocity, altitude, rotation, inertia, w, dt, improved && wingStalled); state.updatedMillis = System.currentTimeMillis();
AutoTrim.update(state, effective, worldVelocity, altitude, rotation, inertia, w, torque, dt, improved && wingStalled);
} }
} }
@@ -319,6 +416,11 @@ public final class AeroState implements PhysicsBridge.Publishable {
state.flapAngle = moveTowards(state.flapAngle, target, Math.toRadians(Config.FLAP_SLEW_DEGREES.get()) * dt); state.flapAngle = moveTowards(state.flapAngle, target, Math.toRadians(Config.FLAP_SLEW_DEGREES.get()) * dt);
} }
/** The physics state of a control node, null if no physics runs for it. */
static NodeState nodeState(long nodeKey) {
return ALL_NODE_STATES.get(nodeKey);
}
static double moveTowards(double value, double target, double maxStep) { static double moveTowards(double value, double target, double maxStep) {
if (Math.abs(target - value) <= maxStep) { if (Math.abs(target - value) <= maxStep) {
return target; return target;
@@ -37,6 +37,10 @@ public record AeroSurface(long key, SurfaceType type, boolean slab, double x, do
} }
boolean listensToPitch() { boolean listensToPitch() {
return node != NO_NODE && (axis1 == ControlAxis.PITCH || axis2 == ControlAxis.PITCH); return listensTo(ControlAxis.PITCH);
}
boolean listensTo(ControlAxis axis) {
return node != NO_NODE && (axis1 == axis || axis2 == axis);
} }
} }
@@ -0,0 +1,101 @@
package dev.lumareth.improvedAerodynamics.flight;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.level.block.state.properties.Property;
/**
* The frame cursor aim steers in, in ship coordinates: forward is the boresight (what Warium's gun reticle points
* along, from the HUD block in front of the seat, else the way the seat faces), up is the ship's up axis and right
* completes it. Server and client both build it from the same block data, so a cursor sent as components in this
* frame means the same on both.
*
* @param nodePos the Vehicle Control Node the seat is linked to
*/
public record AimBasis(BlockPos seatPos, BlockPos nodePos, double[] forward, double[] right, double[] up) {
/**
* @param entityPos block position of the seat entity. Warium's control seat block is at that position or just
* below it.
* @return null if there is no seat block linked to a control node there
*/
public static AimBasis of(Level level, BlockPos entityPos) {
for (BlockPos pos : new BlockPos[]{entityPos, entityPos.below()}) {
BlockEntity seat = level.getBlockEntity(pos);
if (seat == null || !seat.getPersistentData().contains("ControlX")) {
continue;
}
CompoundTag tag = seat.getPersistentData();
BlockPos nodePos = BlockPos.containing(tag.getDouble("ControlX"), tag.getDouble("ControlY"), tag.getDouble("ControlZ"));
return new AimBasis(pos, nodePos, null, null, null).withAxes(level, pos, nodePos);
}
return null;
}
private AimBasis withAxes(Level level, BlockPos seatPos, BlockPos nodePos) {
BlockState state = level.getBlockState(seatPos);
Direction facing = Direction.NORTH;
Property<?> property = state.getBlock().getStateDefinition().getProperty("facing");
if (property instanceof DirectionProperty directionProperty) {
facing = state.getValue(directionProperty);
}
double fx = facing.getStepX();
double fy = facing.getStepY();
double fz = facing.getStepZ();
// Warium's gun reticle: the HUD block in front of the seat holds the aim vector of its guns
BlockEntity hud = level.getBlockEntity(seatPos.relative(facing));
if (hud != null && hud.getPersistentData().contains("Pitch")) {
CompoundTag hudTag = hud.getPersistentData();
double bx = hudTag.getDouble("X") + facing.getStepX();
double by = hudTag.getDouble("Pitch");
double bz = hudTag.getDouble("Z") + facing.getStepZ();
if (bx * bx + by * by + bz * bz > 1.0E-6) {
fx = bx;
fy = by;
fz = bz;
}
}
double length = Math.sqrt(fx * fx + fy * fy + fz * fz);
if (length < 1.0E-6) {
fx = 0.0;
fy = 0.0;
fz = -1.0;
length = 1.0;
}
fx /= length;
fy /= length;
fz /= length;
// right = forward x up(0,1,0)
double rx = -fz;
double ry = 0.0;
double rz = fx;
double rl = Math.sqrt(rx * rx + rz * rz);
if (rl < 1.0E-6) {
rx = 1.0;
rz = 0.0;
rl = 1.0;
}
rx /= rl;
rz /= rl;
// up = right x forward
double ux = ry * fz - rz * fy;
double uy = rz * fx - rx * fz;
double uz = rx * fy - ry * fx;
return new AimBasis(seatPos, nodePos, new double[]{fx, fy, fz}, new double[]{rx, ry, rz}, new double[]{ux, uy, uz});
}
/** Ship-space direction of a cursor given by its components along forward, right and up. */
public double[] toShip(double cf, double cr, double cu) {
return new double[]{
forward[0] * cf + right[0] * cr + up[0] * cu,
forward[1] * cf + right[1] * cr + up[1] * cu,
forward[2] * cf + right[2] * cr + up[2] * cu};
}
}
@@ -7,19 +7,28 @@ import org.joml.Vector3d;
import org.joml.Vector3dc; import org.joml.Vector3dc;
/** /**
* Auto trim for the pitch control surfaces of one control node: while the pilot gives no pitch input it holds the * The flight computer of one control node, switched on and off with the auto trim key. It is a fly-by-wire pitch
* altitude the craft had when the pilot let go, cancelling whatever pitching moment the craft has (off-center weight, * system: the pilot's pitch input is a pitch rate command, not a surface deflection, and the computer moves the
* thrust line, speed changes). * surfaces to make the craft follow it, whatever the airframe does on its own.
* <p> * <p>
* Three nested loops: altitude error sets a gentle climb rate, climb rate error sets a nose attitude (never more than * Every physics substep it measures the pitching torque the craft makes with the pitch surfaces taken out
* {@link #MAX_ATTITUDE} from level), attitude error sets a pitch rate, and a PI controller moves the trim to reach * ({@code external}: weight offset, thrust line, and above all the airframe's own stability or instability), and the
* that rate. Bounding the attitude matters: in Warium's physics the flight path follows the nose only slowly, so * torque one unit of pitch input makes ({@code response}). It then picks the input that makes the craft's total
* steering the flight path directly with pitch rate keeps rotating the nose until the craft dives. * pitch torque equal the angular acceleration it wants, {@code rateGain * (rate command - pitch rate)} plus a small
* integral for what the model gets wrong. That cancels a craft that is statically unstable, or trimmed badly, as
* fast as the surfaces can move, so a jet built with relaxed stability can be flown like a stable one.
* <p> * <p>
* Attitude is measured from the ship's own up axis, so it assumes the craft is built level, as Warium craft are. The * Without pilot input the rate command comes from three nested loops that hold the altitude the craft had when the
* trim's effect is measured every substep (torque per unit of pitch input), which gives the pitch axis and lets the * pilot let go: altitude error sets a gentle climb rate, climb rate error sets a nose attitude (never more than
* inner gains be expressed as angular acceleration, so the same tuning works for any craft, speed and altitude. * {@link #MAX_ATTITUDE} from level), attitude error sets a pitch rate. Bounding the attitude matters: in Warium's
* The trim holds still while the pilot pitches, when too slow, and when wings stall, so it never fights maneuvers. * physics the flight path follows the nose only slowly, so steering the flight path directly with pitch rate keeps
* rotating the nose until the craft dives.
* <p>
* Attitude is measured from the ship's own up axis, so it assumes the craft is built level, as Warium craft are.
* Torques are measured per unit of input, so the same tuning works for any craft, speed and altitude. Below the
* minimum speed the computer holds its output, the surfaces have no authority there anyway. Stalled wings cut the
* pilot's rate command and the altitude hold, so the computer only keeps the nose where it is. While cursor aim flies
* the node, that is in charge instead.
*/ */
final class AutoTrim { final class AutoTrim {
// Climb rate (blocks/s) per block of altitude error, and its limit // Climb rate (blocks/s) per block of altitude error, and its limit
@@ -32,12 +41,16 @@ final class AutoTrim {
// Pitch rate (rad/s) per radian of attitude error, and its limit // Pitch rate (rad/s) per radian of attitude error, and its limit
private static final double ATTITUDE_GAIN = 1.0; private static final double ATTITUDE_GAIN = 1.0;
private static final double MAX_PITCH_RATE = 0.15; private static final double MAX_PITCH_RATE = 0.15;
// Angular acceleration per rad/s of pitch rate error, and integral gain // Slow trim movement when the computer is switched off
private static final double PROPORTIONAL_GAIN = 3.0;
private static final double INTEGRAL_GAIN = 1.5;
// Fastest the trim moves, in input per second
private static final double TRIM_RATE = 1.0; private static final double TRIM_RATE = 1.0;
private static final double PILOT_DEADBAND = 0.01; private static final double PILOT_DEADBAND = 0.01;
// The computer assumes the surfaces do this much of what was measured, so a response measured too high (stalled
// surfaces, clamped inputs) makes it command a little too much rather than too little, which stays stable
private static final double RESPONSE_MARGIN = 0.7;
// Pitch rate command while stalled, as a fraction of what the pilot asked
private static final double STALLED_COMMAND = 0.3;
// The integral may make up for this much rate error worth of acceleration
private static final double MAX_ERROR_FOR_INTEGRAL = 0.5;
private AutoTrim() { private AutoTrim() {
} }
@@ -49,29 +62,29 @@ final class AutoTrim {
} }
static void update(AeroState.NodeState state, NodeData node, Vector3dc worldVelocity, double altitude, static void update(AeroState.NodeState state, NodeData node, Vector3dc worldVelocity, double altitude,
Quaterniondc rotation, Matrix3dc inertia, double[] w, double dt, boolean stalled) { Quaterniondc rotation, Matrix3dc inertia, double[] w, double[] torque, double dt, boolean stalled) {
Targets targets = state.trimTargets; Targets targets = state.trimTargets;
boolean aimed = CursorAimRegistry.isActive(node.key());
if (!node.autoTrim()) { if (!node.autoTrim()) {
state.trimIntegral = 0.0; state.trimIntegral = 0.0;
state.trimOutput = AeroState.moveTowards(state.trimOutput, 0.0, TRIM_RATE * dt); state.trimOutput = AeroState.moveTowards(state.trimOutput, 0.0, TRIM_RATE * dt);
state.savedTrim = 0.0; state.savedTrim = 0.0;
state.computerOutput = state.pitch + state.trimOutput;
targets.altitude = Double.NaN; targets.altitude = Double.NaN;
targets.attitudeIntegral = 0.0; targets.attitudeIntegral = 0.0;
return; return;
} }
double authority = Config.TRIM_AUTHORITY.get();
double response = state.pitchResponse.length(); double response = state.pitchResponse.length();
double speed = worldVelocity.length(); double speed = worldVelocity.length();
boolean hold = Math.abs(node.pitch()) > PILOT_DEADBAND || speed < Config.TRIM_MIN_SPEED.get() || stalled if (aimed || speed < Config.TRIM_MIN_SPEED.get() || response < 1.0E-6) {
|| response < 1.0E-6; // Cursor aim, no airflow or no pitch surfaces: the computer holds what it has
if (hold || Double.isNaN(targets.altitude)) { targets.altitude = Double.NaN;
// Hold whatever altitude the craft is at once the pilot lets go
targets.altitude = altitude;
targets.attitudeIntegral = 0.0; targets.attitudeIntegral = 0.0;
state.computerOutput = state.pitch + state.trimOutput;
state.savedTrim = state.trimIntegral;
return;
} }
double target = state.trimIntegral;
if (!hold) {
// Pitch axis in ship axes, oriented so that positive rotation is what more pitch input does // Pitch axis in ship axes, oriented so that positive rotation is what more pitch input does
Vector3d axis = state.pitchResponse.div(response, new Vector3d()); Vector3d axis = state.pitchResponse.div(response, new Vector3d());
double axisInertia = axis.dot(inertia.transform(axis, new Vector3d())); double axisInertia = axis.dot(inertia.transform(axis, new Vector3d()));
@@ -82,24 +95,50 @@ final class AutoTrim {
Vector3d direction = worldVelocity.normalize(new Vector3d()); Vector3d direction = worldVelocity.normalize(new Vector3d());
double noseUp = Math.signum(worldAxis.cross(direction, new Vector3d()).y); double noseUp = Math.signum(worldAxis.cross(direction, new Vector3d()).y);
double attitude = attitude(axis, rotation); double attitude = attitude(axis, rotation);
if (noseUp == 0.0 || Double.isNaN(attitude)) {
return;
}
double pilot = node.pitch();
boolean pilotActive = Math.abs(pilot) > PILOT_DEADBAND;
double rateCommand;
if (pilotActive || stalled || noseUp == 0.0 || Double.isNaN(attitude)) {
// Hold whatever altitude the craft is at once the pilot lets go
targets.altitude = altitude;
targets.attitudeIntegral = 0.0;
rateCommand = pilotActive
? pilot * Math.toRadians(Config.FBW_STICK_RATE_DEGREES.get()) * (stalled ? STALLED_COMMAND : 1.0)
: 0.0;
} else {
if (Double.isNaN(targets.altitude)) {
targets.altitude = altitude;
}
double climbTarget = clamp(ALTITUDE_GAIN * (targets.altitude - altitude), MAX_CLIMB_RATE); double climbTarget = clamp(ALTITUDE_GAIN * (targets.altitude - altitude), MAX_CLIMB_RATE);
double climbError = climbTarget - worldVelocity.y(); double climbError = climbTarget - worldVelocity.y();
targets.attitudeIntegral = clamp(targets.attitudeIntegral + CLIMB_INTEGRAL_GAIN * climbError * dt, MAX_ATTITUDE); targets.attitudeIntegral = clamp(targets.attitudeIntegral + CLIMB_INTEGRAL_GAIN * climbError * dt, MAX_ATTITUDE);
// Nose elevation wanted, turned into rotation about the axis // Nose elevation wanted, turned into rotation about the axis
double attitudeTarget = noseUp * clamp(targets.attitudeIntegral + CLIMB_GAIN * climbError, MAX_ATTITUDE); double attitudeTarget = noseUp * clamp(targets.attitudeIntegral + CLIMB_GAIN * climbError, MAX_ATTITUDE);
double rateTarget = clamp(ATTITUDE_GAIN * (attitudeTarget - attitude), MAX_PITCH_RATE); rateCommand = clamp(ATTITUDE_GAIN * (attitudeTarget - attitude), MAX_PITCH_RATE);
double error = rateTarget - pitchRate;
// Pitch input needed per rad/s² of angular acceleration
double gain = axisInertia / response;
state.trimIntegral = clamp(state.trimIntegral + INTEGRAL_GAIN * error * dt * gain, authority);
target = clamp(state.trimIntegral + PROPORTIONAL_GAIN * error * gain, authority);
} }
state.trimOutput = AeroState.moveTowards(state.trimOutput, target, TRIM_RATE * dt);
// Pitching torque about the axis the pitch surfaces are not making: total of the last substep minus what the
// input of that substep made
double total = axis.x * torque[0] + axis.y * torque[1] + axis.z * torque[2];
double external = total - response * state.computerOutput;
double authority = Config.FBW_AUTHORITY.get();
double rateError = rateCommand - pitchRate;
double acceleration = Config.FBW_RATE_GAIN.get() * rateError + state.trimIntegral;
double wanted = (axisInertia * acceleration - external) / (response * RESPONSE_MARGIN);
boolean saturated = Math.abs(wanted) > authority;
wanted = clamp(wanted, authority);
if (!saturated) {
// Integral of the acceleration, bounded so it cannot run away if the model is far off
double limit = Config.FBW_RATE_GAIN.get() * MAX_ERROR_FOR_INTEGRAL;
state.trimIntegral = clamp(state.trimIntegral + Config.FBW_INTEGRAL_GAIN.get() * rateError * dt, limit);
}
double applied = AeroState.moveTowards(state.computerOutput, wanted, Config.FBW_ACTUATOR_RATE.get() * dt);
state.computerOutput = applied;
// The surfaces get the pilot's smoothed input plus the trim, so the trim is what makes up the difference
state.trimOutput = applied - state.pitch;
state.savedTrim = state.trimIntegral; state.savedTrim = state.trimIntegral;
} }
@@ -0,0 +1,45 @@
package dev.lumareth.improvedAerodynamics.flight;
import dev.lumareth.improvedAerodynamics.network.ControlModePacket;
import dev.lumareth.improvedAerodynamics.network.Network;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.network.PacketDistributor;
/**
* How a player flies: with the keyboard (Warium's own seat controls) or with cursor aim. Chosen per player with
* {@code /aero controls}, kept in the player's persistent data and told to their client, which does the rest.
*/
public final class ControlMode {
private static final String KEY = "improved_aerodynamics_cursor_aim";
private ControlMode() {
}
public static boolean isCursorAim(ServerPlayer player) {
CompoundTag persisted = player.getPersistentData().getCompound(Player.PERSISTED_NBT_TAG);
return persisted.getBoolean(KEY);
}
public static void set(ServerPlayer player, boolean cursorAim) {
CompoundTag data = player.getPersistentData();
CompoundTag persisted = data.getCompound(Player.PERSISTED_NBT_TAG);
persisted.putBoolean(KEY, cursorAim);
data.put(Player.PERSISTED_NBT_TAG, persisted);
sync(player);
}
public static void sync(ServerPlayer player) {
Network.CHANNEL.send(PacketDistributor.PLAYER.with(() -> player), new ControlModePacket(isCursorAim(player)));
}
@SubscribeEvent
public static void onLogin(PlayerEvent.PlayerLoggedInEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
sync(player);
}
}
}
@@ -0,0 +1,44 @@
package dev.lumareth.improvedAerodynamics.flight;
import java.util.concurrent.ConcurrentHashMap;
/**
* Where each control node's pilot currently holds the cursor of cursor aim. Written by the server thread when the
* client's packet arrives, read by the physics thread. An entry that is not refreshed for a moment (the player left
* the seat, lost the connection) counts as gone.
*/
public final class CursorAimRegistry {
private static final long EXPIRE_NANOS = 500_000_000L;
private static final ConcurrentHashMap<Long, Aim> AIMS = new ConcurrentHashMap<>();
/** Cursor as components along the basis axes (unit length), and the basis itself, in ship space. */
public record Aim(double cf, double cr, double cu, double[] forward, double[] right, double[] up, long nanos) {
}
private CursorAimRegistry() {
}
public static void set(long nodeKey, Aim aim) {
AIMS.put(nodeKey, aim);
}
public static void clear(long nodeKey) {
AIMS.remove(nodeKey);
}
public static Aim get(long nodeKey) {
Aim aim = AIMS.get(nodeKey);
if (aim == null) {
return null;
}
if (System.nanoTime() - aim.nanos() > EXPIRE_NANOS) {
AIMS.remove(nodeKey, aim);
return null;
}
return aim;
}
public static boolean isActive(long nodeKey) {
return get(nodeKey) != null;
}
}
@@ -0,0 +1,35 @@
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;
/** Server side of cursor aim: takes the cursor a pilot sends and hands it to the control node's virtual pilot. */
public final class CursorAimServer {
private CursorAimServer() {
}
public static void accept(ServerPlayer player, boolean active, double cf, double cr, double cu) {
// Only a player who chose cursor aim may set or clear the cursor of a node
if (!ControlMode.isCursorAim(player)) {
return;
}
Entity vehicle = player.getVehicle();
if (!(vehicle instanceof SeatEntityEntity)) {
return;
}
AimBasis basis = AimBasis.of(vehicle.level(), vehicle.blockPosition());
if (basis == null) {
return;
}
long nodeKey = basis.nodePos().asLong();
double length = Math.sqrt(cf * cf + cr * cr + cu * cu);
if (!active || !Config.CURSOR_AIM_ENABLED.get() || !(length > 1.0E-6) || !Double.isFinite(length)) {
CursorAimRegistry.clear(nodeKey);
return;
}
CursorAimRegistry.set(nodeKey, new CursorAimRegistry.Aim(cf / length, cr / length, cu / length,
basis.forward(), basis.right(), basis.up(), System.nanoTime()));
}
}
@@ -0,0 +1,269 @@
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 direction fixed in the world, and the pilot turns the craft until
* the gun reticle sits on it, so the offset it gets shrinks to nothing as the turn completes:
* <ul>
* <li>it banks towards the cursor, judged with the wings level so the bank does not hide where the cursor is: the
* further the cursor is to the side, the steeper the bank. While banked it pulls as much as a level turn at that
* bank needs, so the craft turns without losing height;</li>
* <li>it pitches towards the cursor as seen from the cockpit, so once banked the cursor is above the nose and the
* pull brings the nose round onto it;</li>
* <li>a cursor far below the nose (more than {@code reverseTurnDegrees}, judged with the wings level) is not reached
* by pushing: the pilot rolls until the cursor is above the canopy and pulls, a reversal or split-S. It keeps at it
* until the reticle is close to the cursor, or the cursor is moved above the nose again. A cursor only a little below
* the nose is reached with the elevators alone, gently, so the craft can still be flown down to a landing;</li>
* <li>below {@code minSpeed} there is too little airflow to fly by: the cursor moves elevators and rudder directly;</li>
* <li>close to the reticle it levels the wings and puts the reticle on the cursor with rudder and elevator, so
* aiming does not rock the wings.</li>
* </ul>
* The wanted rotation is turned into stick inputs by the measured effect of each input on the craft (torque per unit
* of input, see {@link AeroState}), so it works for any craft however its surfaces are mixed or oriented: angular
* acceleration wanted times inertia, solved for the three inputs with a little regularization for axes that have no
* surfaces. Keys the pilot holds win over it, per axis.
* <p>
* How hard the craft can turn is worked out live, not configured: the lift its wings can make at the edge of the stall
* at the current speed and altitude, over its mass and speed, and the steady pitch rate its pitch surfaces can hold
* against its pitch damping, whichever is lower. The same lift limit caps the bank to what the craft can hold level, so
* a heavy bomber banks gently and turns wide while a light fighter racks into a steep, tight turn.
*/
final class CursorPilot {
private static final double FINE_START = Math.toRadians(4.0);
private static final double FINE_END = Math.toRadians(16.0);
private static final double MANUAL_OVERRIDE = 0.05;
// Gravity of VS
private static final double GRAVITY = 10.0;
// Share of the stall limited lift the pilot plans with, so it does not ride the stall
private static final double LIFT_MARGIN = 0.85;
// Bank the pilot rolls to at full sideways deflection even when the wings cannot hold the turn level
private static final double MIN_BANK = Math.toRadians(20.0);
// Smallest pull allowed, as a share of the turn rate, when gravity takes most of the lift
private static final double MIN_PULL_SHARE = 0.2;
// Time constant of easing the pull back in and out of a stall
private static final double STALL_EASE_SECONDS = 0.4;
// Turn rate when neither limit can be measured, like the first moments of a flight
private static final double FALLBACK_TURN_RATE = Math.toRadians(30.0);
private CursorPilot() {
}
/**
* @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 dt) {
if (!Config.CURSOR_AIM_ENABLED.get()) {
return node;
}
CursorAimRegistry.Aim aim = CursorAimRegistry.get(node.key());
if (aim == null) {
return node;
}
double fullAngle = Math.toRadians(Config.CURSOR_AIM_FULL_DEFLECTION_DEGREES.get());
double maxRoll = Math.toRadians(Config.CURSOR_AIM_MAX_ROLL_RATE_DEGREES.get());
double maxBank = Math.toRadians(Config.CURSOR_AIM_MAX_BANK_DEGREES.get());
double[] f = aim.forward();
double[] r = aim.right();
double[] u = aim.up();
learnSigns(state, f, r, u);
// Bank of the craft: positive when the right wing is down, through upside down
Vector3d rightWorld = rotation.transform(new Vector3d(r[0], r[1], r[2]));
Vector3d upWorld = rotation.transform(new Vector3d(u[0], u[1], u[2]));
double bank = Math.atan2(-rightWorld.y, upWorld.y);
// 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));
// The cursor as it would be with the wings level: what the bank is chosen by
double levelRight = cr * Math.cos(bank) + cu * Math.sin(bank);
double levelUp = cu * Math.cos(bank) - cr * Math.sin(bank);
double levelVertical = Math.atan2(levelUp, Math.hypot(cf, levelRight));
if (speed < Config.CURSOR_AIM_MIN_SPEED.get()) {
// Too slow for the rate loop: the cursor moves the surfaces directly, rudder towards it (which also steers on
// the ground) and elevator up or down, so the controls follow the cursor while taxiing and rotating
state.reversing = false;
double pitch = clamp(state.pitchSign * saturate(vertical / fullAngle) - state.trimOutput, -1.0, 1.0);
double yaw = state.yawSign * saturate(sideways / fullAngle);
return withInputs(node, pitch, yaw, 0.0);
}
// What this craft can do right now. Lift: pitch rate at the highest usable lift (a = L / m, rate = a / v), and
// the bank whose level turn needs that load factor. Without a stall model only the control limit applies
double usableLift = LIFT_MARGIN * liftAcceleration;
double liftRate = usableLift > 0.0 ? usableLift / speed : Double.POSITIVE_INFINITY;
double controlRate = state.controlRate > 0.0 ? state.controlRate : Double.POSITIVE_INFINITY;
double maxTurn = Math.min(liftRate, controlRate);
if (!Double.isFinite(maxTurn)) {
maxTurn = FALLBACK_TURN_RATE;
}
// Fastest pull: part of the lift already holds the craft up against gravity, only the rest curves the flight
// path, so asking for the full lift rate in a steep bank would drag the wings into the stall
double maxPull = maxTurn;
if (Double.isFinite(liftAcceleration)) {
double loadFactor = usableLift / GRAVITY;
double levelBank = loadFactor > 1.0 ? Math.acos(1.0 / loadFactor) : 0.0;
maxBank = Math.max(Math.min(MIN_BANK, maxBank), Math.min(maxBank, levelBank));
double pullLift = (usableLift - GRAVITY * upWorld.y) / speed;
maxPull = clamp(Math.min(pullLift, controlRate), MIN_PULL_SHARE * maxTurn, maxTurn);
}
// Reversal: entered when the cursor is far below the nose, left once the reticle is close to it or the cursor
// is above the nose again
double offsetAngle = Math.acos(clamp(cf, -1.0, 1.0));
if (state.reversing) {
state.reversing = offsetAngle > FINE_END && levelVertical < 0.0;
} else {
state.reversing = levelVertical < -Math.toRadians(Config.CURSOR_AIM_REVERSE_TURN_DEGREES.get());
}
// Bank: roll so the cursor is above the canopy, which is where a pull takes the nose. Judged by the direction of
// the cursor around the nose, which stays well defined wherever the cursor is, even 90 degrees off the nose
// straight above or to the side; the angle of the cursor from the nose only sets how much of that bank is used,
// so the wings come level as the reticle closes on the cursor
double blend = state.reversing ? 1.0 : smoothstep(offsetAngle, FINE_START, FINE_END);
double bankError;
if (state.reversing) {
// All the way over, the shorter way round
bankError = wrap(Math.atan2(levelRight, levelUp) - bank);
} else {
// A cursor below the nose counts as if it were as far above it: only its sideways part banks the craft, and
// the craft is never rolled upside down for a slight descent
double overhead = Math.atan2(levelRight, Math.abs(levelUp));
double bankCommand = clamp(overhead, -maxBank, maxBank) * saturate(offsetAngle / fullAngle) * blend;
bankError = bankCommand - bank;
}
double rollRate = clamp(Config.CURSOR_AIM_BANK_GAIN.get() * bankError, -maxRoll, maxRoll);
// Pitch: the pull of a level turn at the current bank, the turn rate g tan(bank) / v seen about the pitch axis,
// plus a pull towards the cursor. Past 90 degrees of bank the level turn pull would only dive, so it is left out
double pitchStick = saturate(vertical / fullAngle);
double push = Config.CURSOR_AIM_PUSH_FACTOR.get();
// While rolling into a reversal the cursor is still below the nose, pushing there would only work against it
double pitchRate = pitchStick >= 0.0 ? maxPull * pitchStick : state.reversing ? 0.0 : maxTurn * pitchStick * push;
double cosBank = Math.cos(bank);
if (cosBank > 0.0) {
double sinBank = Math.sin(bank);
pitchRate += GRAVITY * sinBank * sinBank / (speed * Math.max(cosBank, 0.1));
}
// Stalled wings ease the pull back, and it comes back as gently once they fly again: switching it at once with
// the stall would make the craft buck in and out of the stall
double stallTarget = wingStalled ? Config.CURSOR_AIM_STALLED_PITCH_FACTOR.get() : 1.0;
state.stallEase += (stallTarget - state.stallEase) * Math.min(1.0, dt / STALL_EASE_SECONDS);
pitchRate = clamp(pitchRate, -maxTurn * push, maxPull);
if (pitchRate > 0.0) {
pitchRate *= state.stallEase;
}
// Close to the reticle the nose follows the cursor with the rudder
double yawRate = maxTurn * 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(
rollRate * f[0] + pitchRate * r[0] - yawRate * u[0],
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(Config.CURSOR_AIM_RATE_GAIN.get());
Vector3d torque = inertia.transform(acceleration, new Vector3d());
double[] inputs = solve(state.pitchResponse, state.yawResponse, state.rollResponse, torque);
if (inputs == null) {
return node;
}
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,
Math.abs(node.roll()) > MANUAL_OVERRIDE ? node.roll() : roll,
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);
Matrix3d transposed = responses.transpose(new Matrix3d());
Matrix3d normal = transposed.mul(responses, new Matrix3d());
double trace = normal.m00() + normal.m11() + normal.m22();
if (!(trace > 1.0E-9) || !Double.isFinite(trace)) {
return null;
}
double ridge = trace / 3.0 * 1.0E-3;
normal.m00(normal.m00() + ridge);
normal.m11(normal.m11() + ridge);
normal.m22(normal.m22() + ridge);
Vector3d right = transposed.transform(torque, new Vector3d());
Vector3d x = normal.invert().transform(right);
if (!x.isFinite()) {
return null;
}
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);
}
private static double smoothstep(double value, double from, double to) {
double t = clamp((value - from) / (to - from), 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
}
@@ -0,0 +1,145 @@
package dev.lumareth.improvedAerodynamics.flight;
import dev.lumareth.improvedAerodynamics.Config;
import dev.lumareth.improvedAerodynamics.network.DeflectionPacket;
import dev.lumareth.improvedAerodynamics.network.Network;
import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ShortOpenHashMap;
import it.unimi.dsi.fastutil.longs.LongArrayList;
import it.unimi.dsi.fastutil.shorts.ShortArrayList;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.server.ServerStoppedEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.network.PacketDistributor;
import org.joml.Vector3d;
import org.valkyrienskies.core.api.ships.Ship;
import org.valkyrienskies.mod.common.VSGameUtilsKt;
import java.util.HashMap;
import java.util.Map;
/**
* Tells the clients near a ship how far its control surfaces and flaps are deflected, so they can show it, see
* {@code SurfaceAnimation}. Every tick each Warium control surface block reports its deflection from the procedure that
* runs it ({@link WariumCapture#begin}); at the end of the tick what changed goes out, in one packet per ship, and
* everything again once a second so players who arrive later get it too.
* <p>
* The deflection is what the physics uses: the smoothed pilot input plus trim and flap angle of the surface's control
* node. While the ship is not flying (no physics, nothing is moving) the node's raw input and flap lever are used, and the
* flaps are moved towards their angle here, at the configured speed.
*/
public final class DeflectionSync {
/** Degrees of deflection the physics gives a surface at full input, see ImprovedAero.MAX_DEFLECTION. */
private static final double FULL_INPUT_DEGREES = 20.0;
private static final long RESEND_TICKS = 20;
private static final double RANGE = 256.0;
private record Batch(ServerLevel level, double x, double y, double z, LongArrayList positions, ShortArrayList tenths) {
}
private static final Map<ResourceKey<Level>, Long2ShortOpenHashMap> LAST_VALUE = new HashMap<>();
private static final Map<ResourceKey<Level>, Long2LongOpenHashMap> LAST_SENT = new HashMap<>();
// Flap angle (radians) shown while a node is not flying, and the tick it was last moved
private static final Map<Long, double[]> STATIC_FLAPS = new HashMap<>();
private static final Map<Long, Batch> BATCHES = new HashMap<>();
private DeflectionSync() {
}
/** Called by the procedure of every control surface block each tick, before it adds its forces. */
static void report(ServerLevel level, BlockPos pos, SurfaceType type) {
if (!type.controllable) {
return;
}
BlockEntity blockEntity = level.getBlockEntity(pos);
if (blockEntity == null) {
return;
}
CompoundTag tag = blockEntity.getPersistentData();
BlockPos nodePos = BlockPos.containing(tag.getDouble("ControlX"), tag.getDouble("ControlY"), tag.getDouble("ControlZ"));
BlockEntity nodeEntity = level.getBlockEntity(nodePos);
if (nodeEntity == null || !tag.contains("ControlX")) {
return;
}
long nodeKey = nodePos.asLong();
CompoundTag nodeTag = nodeEntity.getPersistentData();
AeroState.NodeState state = LiveInputs.flying(nodeKey);
boolean flying = state != null;
double input = axisInput(nodeKey, nodeTag, tag.getString("Key")) + axisInput(nodeKey, nodeTag, tag.getString("Key2"));
double degrees = FULL_INPUT_DEGREES * Math.max(-1.0, Math.min(1.0, input));
if (type == SurfaceType.CONTROL_SURFACE && tag.getBoolean(WariumCapture.FLAP_TAG)) {
degrees += Math.toDegrees(flying ? state.flapAngle : staticFlap(level, nodeKey, nodeTag));
}
short tenths = (short) Math.round(Math.max(-90.0, Math.min(90.0, degrees)) * 10.0);
ResourceKey<Level> dimension = level.dimension();
Long2ShortOpenHashMap lastValue = LAST_VALUE.computeIfAbsent(dimension, key -> new Long2ShortOpenHashMap());
Long2LongOpenHashMap lastSent = LAST_SENT.computeIfAbsent(dimension, key -> new Long2LongOpenHashMap());
long posKey = pos.asLong();
long now = level.getGameTime();
if (lastSent.containsKey(posKey) && lastValue.get(posKey) == tenths && now - lastSent.get(posKey) < RESEND_TICKS) {
return;
}
lastValue.put(posKey, tenths);
lastSent.put(posKey, now);
Ship ship = VSGameUtilsKt.getShipManagingPos(level, pos);
long shipId = ship != null ? ship.getId() : Long.MIN_VALUE;
Batch batch = BATCHES.get(shipId);
if (batch == null) {
Vector3d world = new Vector3d(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
if (ship != null) {
ship.getShipToWorld().transformPosition(world);
}
batch = new Batch(level, world.x, world.y, world.z, new LongArrayList(), new ShortArrayList());
BATCHES.put(shipId, batch);
}
batch.positions.add(posKey);
batch.tenths.add(tenths);
}
/** One key's share of a surface's input, like Warium reads "Pitch+" / "Roll-". Throttle keys do not deflect it. */
private static double axisInput(long nodeKey, CompoundTag nodeTag, String key) {
return ControlAxis.of(key) == ControlAxis.THROTTLE ? 0.0 : LiveInputs.key(nodeKey, nodeTag, key);
}
/** Flap angle of a node that is not flying: moves towards the lever's angle, once per tick however many flaps ask. */
private static double staticFlap(ServerLevel level, long nodeKey, CompoundTag nodeTag) {
double target = Math.toRadians(FlapSetting.of(nodeTag.getInt(WariumCapture.FLAP_SETTING_TAG)).degrees());
double[] entry = STATIC_FLAPS.computeIfAbsent(nodeKey, key -> new double[]{target, Double.NaN});
long now = level.getGameTime();
if (entry[1] != now) {
entry[1] = now;
entry[0] = AeroState.moveTowards(entry[0], target, Math.toRadians(Config.FLAP_SLEW_DEGREES.get()) / 20.0);
}
return entry[0];
}
@SubscribeEvent
public static void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END || BATCHES.isEmpty()) {
return;
}
for (Batch batch : BATCHES.values()) {
DeflectionPacket packet = new DeflectionPacket(batch.positions.toLongArray(), batch.tenths.toShortArray());
Network.CHANNEL.send(PacketDistributor.NEAR.with(() -> new PacketDistributor.TargetPoint(batch.x, batch.y, batch.z,
RANGE, batch.level.dimension())), packet);
}
BATCHES.clear();
}
@SubscribeEvent
public static void onServerStopped(ServerStoppedEvent event) {
BATCHES.clear();
LAST_VALUE.clear();
LAST_SENT.clear();
STATIC_FLAPS.clear();
}
}
@@ -129,11 +129,8 @@ final class ImprovedAero {
// Low aspect ratio wings keep their flow attached longer thanks to leading-edge vortices. Control surfaces // 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 // 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) double vortexFactor = vortexFactor(s, tuning);
? VortexLift.factor(s.aspectRatio()) : 0.0; double critical = criticalAngle(s, tuning, vortexFactor);
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 absAlpha = Math.abs(alpha); double absAlpha = Math.abs(alpha);
if (alpha >= 0.0) { if (alpha >= 0.0) {
critical += FLAP_STALL_GAIN * flapShift; 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 // Finite wing theory: lift slope and induced drag follow from the wing's aspect ratio
double aspectRatio = s.aspectRatio(); 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 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) { if (s.type() == SurfaceType.WING) {
attachedLift += VortexLift.liftCoefficient(alpha, vortexFactor, tuning.vortexLiftStrength()); attachedLift += VortexLift.liftCoefficient(alpha, vortexFactor, tuning.vortexLiftStrength());
} }
@@ -175,6 +171,44 @@ final class ImprovedAero {
return stalled; 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) { static double smoothstep(double edge0, double edge1, double x) {
double t = Math.max(0.0, Math.min(1.0, (x - edge0) / (edge1 - edge0))); double t = Math.max(0.0, Math.min(1.0, (x - edge0) / (edge1 - edge0)));
return t * t * (3.0 - 2.0 * t); return t * t * (3.0 - 2.0 * t);
@@ -0,0 +1,44 @@
package dev.lumareth.improvedAerodynamics.flight;
import net.minecraft.nbt.CompoundTag;
/**
* The pilot inputs a control node's devices should follow, for everything that is not an aerodynamic surface (thrust
* vectoring nozzles). While the node's ship is flying these are the inputs the flight physics uses: the pilot's keys
* smoothed, or what cursor aim asks for in their place, plus the flight computer's pitch trim. A node without physics
* (on the ground, nothing moving, physics rate aerodynamics off) gives Warium's raw key values, as before.
*/
public final class LiveInputs {
// A node state the physics thread has not touched for this long is not flying any more
private static final long FRESH_MILLIS = 500;
private LiveInputs() {
}
/** The node's physics state if its ship is flying now, else null. */
static AeroState.NodeState flying(long nodeKey) {
AeroState.NodeState state = AeroState.nodeState(nodeKey);
return state != null && System.currentTimeMillis() - state.updatedMillis < FRESH_MILLIS ? state : null;
}
/**
* One axis of a node, in Warium's node units (-1 to 1), without any key multiplier.
*
* @param nodeTag the node block entity's persistent data, for the raw value
*/
public static double value(long nodeKey, CompoundTag nodeTag, ControlAxis axis) {
AeroState.NodeState state = flying(nodeKey);
return switch (axis) {
case PITCH -> state != null ? state.pitch + state.trimOutput : nodeTag.getDouble("Pitch");
case YAW -> state != null ? state.yaw : nodeTag.getDouble("Yaw");
case ROLL -> state != null ? state.roll : nodeTag.getDouble("Roll");
case THROTTLE -> nodeTag.getDouble("Throttle");
case NONE -> 0.0;
};
}
/** A Warium key such as "Pitch+" or "Roll-" read from the node: its axis' value times the key's sign. */
public static double key(long nodeKey, CompoundTag nodeTag, String key) {
return value(nodeKey, nodeTag, ControlAxis.of(key)) * ControlAxis.multiplier(key);
}
}
@@ -23,11 +23,13 @@ public final class MixinPlugin implements IMixinConfigPlugin {
private boolean warium; private boolean warium;
private boolean futurism; private boolean futurism;
private boolean geckolib;
@Override @Override
public void onLoad(String mixinPackage) { public void onLoad(String mixinPackage) {
warium = supported("valkyrien_warium", WARIUM_VERSION, "Warium VS"); warium = supported("valkyrien_warium", WARIUM_VERSION, "Warium VS");
futurism = supported("futurism", FUTURISM_VERSION, "Futurism"); futurism = supported("futurism", FUTURISM_VERSION, "Futurism");
geckolib = LoadingModList.get().getModFileById("geckolib") != null;
} }
private static boolean supported(String modId, String version, String name) { private static boolean supported(String modId, String version, String name) {
@@ -49,6 +51,10 @@ public final class MixinPlugin implements IMixinConfigPlugin {
if (mixinClassName.contains(".mixin.warium.")) { if (mixinClassName.contains(".mixin.warium.")) {
return warium; return warium;
} }
if (mixinClassName.contains(".mixin.gecko.")) {
// Animates Warium's control surfaces, which are GeckoLib models
return warium && geckolib;
}
if (mixinClassName.contains(".mixin.futurism.")) { if (mixinClassName.contains(".mixin.futurism.")) {
return futurism; return futurism;
} }
@@ -50,6 +50,9 @@ public final class WariumCapture {
world = level; world = level;
pos = BlockPos.containing(x, y, z); pos = BlockPos.containing(x, y, z);
state = blockState; state = blockState;
if (surfaceType.controllable && level instanceof ServerLevel serverLevel && Config.PHYSICS_RATE_AERODYNAMICS.get()) {
DeflectionSync.report(serverLevel, pos, surfaceType);
}
} }
/** /**
@@ -0,0 +1,55 @@
package dev.lumareth.improvedAerodynamics.flight.mixin.futurism;
import dev.lumareth.improvedAerodynamics.flight.LiveInputs;
import net.mcreator.futurism.procedures.VectoredAfterburnerSystemProcedure;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.LevelAccessor;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.ModifyVariable;
/**
* Futurism's vectored afterburner reads the raw key values of its control node, so it ignored cursor aim and the flight
* computer. This hands it the same inputs the control surfaces follow ({@link LiveInputs}), through the same Key and Key2
* it is configured with. It stays what Futurism made it, 2D vectoring: the procedure still only tilts the thrust up or
* down. The value is kept on Warium's 0.2 key steps, since every change of it plays Futurism's servo sound and sends a
* block update.
*/
@Mixin(value = VectoredAfterburnerSystemProcedure.class, remap = false)
public abstract class VectoredAfterburnerSystemProcedureMixin {
private static final double STEP = 0.2;
@ModifyVariable(method = "execute", at = @At("STORE"), name = "input")
private static double improvedAerodynamics$liveInput(double input, LevelAccessor world, double x, double y, double z, BlockState blockstate) {
if (!(world instanceof ServerLevel level)) {
return input;
}
BlockEntity own = level.getBlockEntity(BlockPos.containing(x, y, z));
if (own == null) {
return input;
}
CompoundTag tag = own.getPersistentData();
BlockPos nodePos = BlockPos.containing(tag.getDouble("ControlX"), tag.getDouble("ControlY"), tag.getDouble("ControlZ"));
BlockEntity node = level.getBlockEntity(nodePos);
if (node == null) {
return input;
}
long nodeKey = nodePos.asLong();
CompoundTag nodeTag = node.getPersistentData();
double live = keyInput(nodeKey, nodeTag, tag.getString("Key")) + keyInput(nodeKey, nodeTag, tag.getString("Key2"));
live = Math.max(-1.0, Math.min(1.0, live));
return Math.round(live / STEP) * STEP;
}
/** Like Futurism reads a key: pitch, yaw or roll with its sign, anything else (Throttle, Null) adds nothing. */
private static double keyInput(long nodeKey, CompoundTag nodeTag, String key) {
if (key.startsWith("Pitch") || key.startsWith("Yaw") || key.startsWith("Roll")) {
return LiveInputs.key(nodeKey, nodeTag, key);
}
return 0.0;
}
}
@@ -0,0 +1,25 @@
package dev.lumareth.improvedAerodynamics.flight.mixin.gecko;
import dev.lumareth.improvedAerodynamics.client.animation.SurfaceAnimation;
import net.minecraft.world.level.block.entity.BlockEntity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import software.bernie.geckolib.core.animatable.GeoAnimatable;
import software.bernie.geckolib.core.animation.AnimationState;
import software.bernie.geckolib.model.GeoModel;
/**
* Runs after GeckoLib has played an animatable's animations, to put Warium's control surfaces at the deflection the
* flight physics gives them, see {@link SurfaceAnimation}.
*/
@Mixin(value = GeoModel.class, remap = false)
public abstract class GeoModelMixin {
@Inject(method = "handleAnimations", at = @At("RETURN"))
private void improvedAerodynamics$deflectSurface(GeoAnimatable animatable, long instanceId, AnimationState animationState, CallbackInfo ci) {
if (animatable instanceof BlockEntity blockEntity) {
SurfaceAnimation.apply(blockEntity, (GeoModel<?>) (Object) this);
}
}
}
@@ -0,0 +1,45 @@
package dev.lumareth.improvedAerodynamics.network;
import dev.lumareth.improvedAerodynamics.flight.CursorAimServer;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.fml.ModList;
import net.minecraftforge.network.NetworkEvent;
import java.util.function.Supplier;
/**
* Sent every tick while a pilot has cursor aim on, and once when it goes off. The cursor is given as components along
* the craft's boresight, right and up axes, so it does not depend on the camera.
*/
public final class AimPacket {
private final boolean active;
private final float forward;
private final float right;
private final float up;
public AimPacket(boolean active, float forward, float right, float up) {
this.active = active;
this.forward = forward;
this.right = right;
this.up = up;
}
public void encode(FriendlyByteBuf buffer) {
buffer.writeBoolean(active);
buffer.writeFloat(forward);
buffer.writeFloat(right);
buffer.writeFloat(up);
}
public static AimPacket decode(FriendlyByteBuf buffer) {
return new AimPacket(buffer.readBoolean(), buffer.readFloat(), buffer.readFloat(), buffer.readFloat());
}
public void handle(Supplier<NetworkEvent.Context> context) {
ServerPlayer player = context.get().getSender();
if (player != null && ModList.get().isLoaded("valkyrien_warium")) {
CursorAimServer.accept(player, active, forward, right, up);
}
}
}
@@ -0,0 +1,32 @@
package dev.lumareth.improvedAerodynamics.network;
import dev.lumareth.improvedAerodynamics.client.aim.CursorAimClient;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.fml.DistExecutor;
import net.minecraftforge.network.NetworkEvent;
import java.util.function.Supplier;
/** Tells a client whether its player flies with cursor aim, see {@code /aero controls}. */
public final class ControlModePacket {
private final boolean cursorAim;
public ControlModePacket(boolean cursorAim) {
this.cursorAim = cursorAim;
}
public void encode(FriendlyByteBuf buffer) {
buffer.writeBoolean(cursorAim);
}
public static ControlModePacket decode(FriendlyByteBuf buffer) {
return new ControlModePacket(buffer.readBoolean());
}
public void handle(Supplier<NetworkEvent.Context> context) {
boolean value = cursorAim;
DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> () -> CursorAimClient.setMode(value));
context.get().setPacketHandled(true);
}
}
@@ -0,0 +1,50 @@
package dev.lumareth.improvedAerodynamics.network;
import dev.lumareth.improvedAerodynamics.client.animation.SurfaceAnimation;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.fml.DistExecutor;
import net.minecraftforge.network.NetworkEvent;
import java.util.function.Supplier;
/** How far control surfaces are deflected, for the clients near a ship: block positions and tenths of a degree. */
public final class DeflectionPacket {
private final long[] positions;
private final short[] tenths;
public DeflectionPacket(long[] positions, short[] tenths) {
this.positions = positions;
this.tenths = tenths;
}
public void encode(FriendlyByteBuf buffer) {
buffer.writeVarInt(positions.length);
for (int i = 0; i < positions.length; i++) {
buffer.writeLong(positions[i]);
buffer.writeShort(tenths[i]);
}
}
public static DeflectionPacket decode(FriendlyByteBuf buffer) {
int count = buffer.readVarInt();
long[] positions = new long[count];
short[] tenths = new short[count];
for (int i = 0; i < count; i++) {
positions[i] = buffer.readLong();
tenths[i] = buffer.readShort();
}
return new DeflectionPacket(positions, tenths);
}
public void handle(Supplier<NetworkEvent.Context> context) {
long[] pos = positions;
short[] values = tenths;
DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> () -> {
for (int i = 0; i < pos.length; i++) {
SurfaceAnimation.set(pos[i], values[i] / 10.0F);
}
});
context.get().setPacketHandled(true);
}
}
@@ -33,6 +33,22 @@ public final class Network {
.decoder(ExhaustPacket::decode) .decoder(ExhaustPacket::decode)
.consumerMainThread(ExhaustPacket::handle) .consumerMainThread(ExhaustPacket::handle)
.add(); .add();
// High id so it cannot collide with packets added on other branches
CHANNEL.messageBuilder(AimPacket.class, 50, NetworkDirection.PLAY_TO_SERVER)
.encoder(AimPacket::encode)
.decoder(AimPacket::decode)
.consumerMainThread(AimPacket::handle)
.add();
CHANNEL.messageBuilder(ControlModePacket.class, 51, NetworkDirection.PLAY_TO_CLIENT)
.encoder(ControlModePacket::encode)
.decoder(ControlModePacket::decode)
.consumerMainThread(ControlModePacket::handle)
.add();
CHANNEL.messageBuilder(DeflectionPacket.class, 11, NetworkDirection.PLAY_TO_CLIENT)
.encoder(DeflectionPacket::encode)
.decoder(DeflectionPacket::decode)
.consumerMainThread(DeflectionPacket::handle)
.add();
// Higher id than the neighbours so it does not collide with packets added on other branches // Higher id than the neighbours so it does not collide with packets added on other branches
CHANNEL.messageBuilder(FlapPacket.class, 10, NetworkDirection.PLAY_TO_SERVER) CHANNEL.messageBuilder(FlapPacket.class, 10, NetworkDirection.PLAY_TO_SERVER)
.encoder(FlapPacket::encode) .encoder(FlapPacket::encode)
@@ -1,8 +1,8 @@
{ {
"key.categories.improved_aerodynamics": "Improved Aerodynamics", "key.categories.improved_aerodynamics": "Improved Aerodynamics",
"key.improved_aerodynamics.toggle_auto_trim": "Automatische Trimmung umschalten", "key.improved_aerodynamics.toggle_auto_trim": "Automatische Trimmung umschalten",
"message.improved_aerodynamics.auto_trim.on": "Automatische Trimmung an", "message.improved_aerodynamics.auto_trim.on": "Flugcomputer an (automatische Trimmung)",
"message.improved_aerodynamics.auto_trim.off": "Automatische Trimmung aus", "message.improved_aerodynamics.auto_trim.off": "Flugcomputer aus (automatische Trimmung)",
"message.improved_aerodynamics.auto_trim.unavailable": "Automatische Trimmung an, sie braucht aber physicsRateAerodynamics in der Serverkonfiguration", "message.improved_aerodynamics.auto_trim.unavailable": "Automatische Trimmung an, sie braucht aber physicsRateAerodynamics in der Serverkonfiguration",
"message.improved_aerodynamics.auto_trim.no_seat": "Setz dich in einen Warium-Steuersitz, um die automatische Trimmung zu nutzen", "message.improved_aerodynamics.auto_trim.no_seat": "Setz dich in einen Warium-Steuersitz, um die automatische Trimmung zu nutzen",
"message.improved_aerodynamics.auto_trim.no_node": "Dieser Sitz ist mit keinem Vehicle Control Node verbunden", "message.improved_aerodynamics.auto_trim.no_node": "Dieser Sitz ist mit keinem Vehicle Control Node verbunden",
@@ -23,6 +23,11 @@
"message.improved_aerodynamics.afterburner.throttle": "Der Nachbrenner braucht mindestens Schub %s", "message.improved_aerodynamics.afterburner.throttle": "Der Nachbrenner braucht mindestens Schub %s",
"message.improved_aerodynamics.afterburner.no_seat": "Setz dich in einen Warium-Steuersitz, um den Nachbrenner zu nutzen", "message.improved_aerodynamics.afterburner.no_seat": "Setz dich in einen Warium-Steuersitz, um den Nachbrenner zu nutzen",
"message.improved_aerodynamics.afterburner.no_node": "Dieser Sitz ist mit keinem Vehicle Control Node verbunden", "message.improved_aerodynamics.afterburner.no_node": "Dieser Sitz ist mit keinem Vehicle Control Node verbunden",
"key.improved_aerodynamics.cursor_aim_free_look": "Cursor-Steuerung: Freie Sicht (halten)",
"message.improved_aerodynamics.cursor_aim.on": "Cursor-Steuerung an",
"message.improved_aerodynamics.cursor_aim.off": "Cursor-Steuerung aus",
"message.improved_aerodynamics.cursor_aim.no_seat": "Setz dich in einen Warium-Steuersitz, um die Cursor-Steuerung zu nutzen",
"message.improved_aerodynamics.cursor_aim.no_server": "Dieser Server hat Improved Aerodynamics nicht, die Cursor-Steuerung braucht es",
"key.improved_aerodynamics.flaps_down": "Klappen runter (eine Stufe)", "key.improved_aerodynamics.flaps_down": "Klappen runter (eine Stufe)",
"key.improved_aerodynamics.flaps_up": "Klappen rauf (eine Stufe)", "key.improved_aerodynamics.flaps_up": "Klappen rauf (eine Stufe)",
"message.improved_aerodynamics.flaps": "Klappen: %s", "message.improved_aerodynamics.flaps": "Klappen: %s",
@@ -1,8 +1,8 @@
{ {
"key.categories.improved_aerodynamics": "Improved Aerodynamics", "key.categories.improved_aerodynamics": "Improved Aerodynamics",
"key.improved_aerodynamics.toggle_auto_trim": "Toggle auto trim", "key.improved_aerodynamics.toggle_auto_trim": "Toggle auto trim",
"message.improved_aerodynamics.auto_trim.on": "Auto trim on", "message.improved_aerodynamics.auto_trim.on": "Flight computer on (auto trim)",
"message.improved_aerodynamics.auto_trim.off": "Auto trim off", "message.improved_aerodynamics.auto_trim.off": "Flight computer off (auto trim)",
"message.improved_aerodynamics.auto_trim.unavailable": "Auto trim on, but it needs physicsRateAerodynamics enabled in the server config", "message.improved_aerodynamics.auto_trim.unavailable": "Auto trim on, but it needs physicsRateAerodynamics enabled in the server config",
"message.improved_aerodynamics.auto_trim.no_seat": "Sit in a Warium control seat to use auto trim", "message.improved_aerodynamics.auto_trim.no_seat": "Sit in a Warium control seat to use auto trim",
"message.improved_aerodynamics.auto_trim.no_node": "This seat is not linked to a vehicle control node", "message.improved_aerodynamics.auto_trim.no_node": "This seat is not linked to a vehicle control node",
@@ -23,6 +23,11 @@
"message.improved_aerodynamics.afterburner.throttle": "The afterburner needs a throttle of at least %s", "message.improved_aerodynamics.afterburner.throttle": "The afterburner needs a throttle of at least %s",
"message.improved_aerodynamics.afterburner.no_seat": "Sit in a Warium control seat to use the afterburner", "message.improved_aerodynamics.afterburner.no_seat": "Sit in a Warium control seat to use the afterburner",
"message.improved_aerodynamics.afterburner.no_node": "This seat is not linked to a vehicle control node", "message.improved_aerodynamics.afterburner.no_node": "This seat is not linked to a vehicle control node",
"key.improved_aerodynamics.cursor_aim_free_look": "Cursor aim: free look (hold)",
"message.improved_aerodynamics.cursor_aim.on": "Cursor aim on",
"message.improved_aerodynamics.cursor_aim.off": "Cursor aim off",
"message.improved_aerodynamics.cursor_aim.no_seat": "Sit in a Warium control seat to use cursor aim",
"message.improved_aerodynamics.cursor_aim.no_server": "This server does not have Improved Aerodynamics, cursor aim needs it",
"key.improved_aerodynamics.flaps_down": "Flaps down (one step)", "key.improved_aerodynamics.flaps_down": "Flaps down (one step)",
"key.improved_aerodynamics.flaps_up": "Flaps up (one step)", "key.improved_aerodynamics.flaps_up": "Flaps up (one step)",
"message.improved_aerodynamics.flaps": "Flaps: %s", "message.improved_aerodynamics.flaps": "Flaps: %s",
@@ -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,8 @@
"compatibilityLevel": "JAVA_17", "compatibilityLevel": "JAVA_17",
"mixins": [ "mixins": [
"futurism.ForceInducedShipsMixin", "futurism.ForceInducedShipsMixin",
"futurism.VectoredAfterburnerSystemProcedureMixin",
"gecko.GeoModelMixin",
"warium.AirBrakeTickProcedureMixin", "warium.AirBrakeTickProcedureMixin",
"warium.BypassedCompressorScriptProcedureMixin", "warium.BypassedCompressorScriptProcedureMixin",
"warium.ControlSurfaceTickProcedureMixin", "warium.ControlSurfaceTickProcedureMixin",