Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80bb63d44f | ||
|
|
3478a04885 | ||
|
|
23f0f91ede | ||
|
|
782df70a69 | ||
|
|
2e75d956a0 | ||
|
|
9f10ff08ff | ||
|
|
2f4f90c773 | ||
|
|
d66e8a83d2 | ||
|
|
8bb2cfd859 | ||
|
|
d376a6b9fa | ||
|
|
da74c2950c |
No files matched your search
@@ -84,6 +84,10 @@ dependencies {
|
||||
compileOnly fg.deobf("blank:WariumVS:1.1.7")
|
||||
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)
|
||||
compileOnly fg.deobf("mod.azure.azurelib:azurelib-common-${minecraft_version}:${azurelib_version}")
|
||||
}
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ mapping_version=2023.09.03-1.20.1
|
||||
mod_id=improved_aerodynamics
|
||||
mod_name=VS: Improved Aerodynamics
|
||||
mod_license=MIT
|
||||
mod_version=0.4.0
|
||||
mod_version=0.4.3
|
||||
azurelib_version=3.2.7
|
||||
mod_group_id=dev.lumareth.improvedaerodynamics
|
||||
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")
|
||||
.defineInRange("vortexLiftStrength", 1.0D, 0.0D, 3.0D);
|
||||
|
||||
public static final ForgeConfigSpec.DoubleValue TRIM_AUTHORITY = BUILDER
|
||||
.comment("Auto trim: largest pitch input (fraction of full deflection) the trim may hold")
|
||||
.defineInRange("trimAuthority", 0.5D, 0.0D, 1.0D);
|
||||
public static final ForgeConfigSpec.DoubleValue FBW_AUTHORITY = BUILDER
|
||||
.comment("Flight computer (auto trim): largest total pitch input, as a fraction of full deflection, the computer may use.",
|
||||
"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
|
||||
.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);
|
||||
|
||||
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")
|
||||
.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 {
|
||||
BUILDER.pop();
|
||||
}
|
||||
@@ -201,6 +266,45 @@ public final class Config {
|
||||
.comment("Vectored afterburner nozzle: afterburner ignition and heavy afterburner sound")
|
||||
.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();
|
||||
|
||||
private Config() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.tree.LiteralCommandNode;
|
||||
import dev.lumareth.improvedAerodynamics.exhaust.NozzleCommands;
|
||||
import dev.lumareth.improvedAerodynamics.flight.ControlMode;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
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.
|
||||
* {@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.
|
||||
*/
|
||||
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_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<>("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<>("air_placeholder", Config.ENABLE_AIR_PLACEHOLDER, BoolArgumentType.bool(), Boolean.class),
|
||||
new Option<>("nozzle_max_deflection", Config.NOZZLE_MAX_DEFLECTION, DoubleArgumentType.doubleArg(), Double.class),
|
||||
@@ -78,21 +84,47 @@ public final class ConfigCommands {
|
||||
|
||||
@SubscribeEvent
|
||||
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")
|
||||
.requires(ConfigCommands::mayUse)
|
||||
.executes(context -> list(context.getSource()));
|
||||
.executes(context -> mayUse(context.getSource()) ? list(context.getSource()) : denied(context.getSource()));
|
||||
for (Option<?> option : OPTIONS) {
|
||||
root.then(optionNode(option));
|
||||
root.then(optionNode(option).requires(ConfigCommands::mayUse));
|
||||
}
|
||||
root.then(SeedgenCommands.node());
|
||||
root.then(NozzleCommands.node());
|
||||
root.then(SeedgenCommands.node().requires(ConfigCommands::mayUse));
|
||||
root.then(NozzleCommands.node().requires(ConfigCommands::mayUse));
|
||||
root.then(controlsNode());
|
||||
LiteralCommandNode<CommandSourceStack> node = event.getDispatcher().register(root);
|
||||
event.getDispatcher().register(Commands.literal("aero")
|
||||
.requires(ConfigCommands::mayUse)
|
||||
.executes(context -> list(context.getSource()))
|
||||
.executes(context -> mayUse(context.getSource()) ? list(context.getSource()) : denied(context.getSource()))
|
||||
.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) {
|
||||
return Commands.literal(option.name())
|
||||
.executes(context -> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.lumareth.improvedAerodynamics;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import dev.lumareth.improvedAerodynamics.client.pipper.PipperConfig;
|
||||
import dev.lumareth.improvedAerodynamics.exhaust.ExhaustSync;
|
||||
import dev.lumareth.improvedAerodynamics.exhaust.NozzleContent;
|
||||
import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge;
|
||||
@@ -24,9 +25,13 @@ public class ImprovedAerodynamics {
|
||||
MinecraftForge.EVENT_BUS.register(PhysicsBridge.class);
|
||||
MinecraftForge.EVENT_BUS.register(ConfigCommands.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.DeflectionSync.class);
|
||||
ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.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());
|
||||
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);
|
||||
}
|
||||
}
|
||||
+138
@@ -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;
|
||||
|
||||
import dev.lumareth.improvedAerodynamics.flight.ControlAxis;
|
||||
import dev.lumareth.improvedAerodynamics.flight.LiveInputs;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
@@ -13,7 +15,8 @@ import org.jetbrains.annotations.Nullable;
|
||||
* with Warium's two key configuration screen, are read. Either key can name any axis: Pitch+ means W raises the nose and
|
||||
* Pitch- means S does, Roll+ or Roll- says which roll input rolls the plane right, Yaw+ or Yaw- which yaw input yaws it
|
||||
* right. An axis no key names is off, except that a nozzle with no key at all pitches with W, and yaw follows D without
|
||||
* a key.
|
||||
* a key. While the ship flies, the inputs are the ones the flight physics uses ({@link LiveInputs}), so the nozzles follow
|
||||
* cursor aim and the flight computer like the control surfaces do.
|
||||
*/
|
||||
final class NozzleControl {
|
||||
private static final ResourceLocation NODE_BLOCK = new ResourceLocation("valkyrien_warium", "vehicle_control_node");
|
||||
@@ -43,9 +46,10 @@ final class NozzleControl {
|
||||
String key = own.getString("Key");
|
||||
String key2 = own.getString("Key2");
|
||||
boolean unset = (key.isEmpty() || key.equals("Null")) && (key2.isEmpty() || key2.equals("Null"));
|
||||
double pitch = clamp(tag.getDouble("Pitch")) * sign(key, key2, "Pitch", unset ? 1.0 : 0.0);
|
||||
double roll = clamp(tag.getDouble("Roll")) * sign(key, key2, "Roll", 0.0);
|
||||
double yaw = clamp(tag.getDouble("Yaw")) * sign(key, key2, "Yaw", 1.0);
|
||||
long nodeKey = nodePos.asLong();
|
||||
double pitch = clamp(LiveInputs.value(nodeKey, tag, ControlAxis.PITCH)) * sign(key, key2, "Pitch", unset ? 1.0 : 0.0);
|
||||
double roll = clamp(LiveInputs.value(nodeKey, tag, ControlAxis.ROLL)) * sign(key, key2, "Roll", 0.0);
|
||||
double yaw = clamp(LiveInputs.value(nodeKey, tag, ControlAxis.YAW)) * sign(key, key2, "Yaw", 1.0);
|
||||
return new Inputs(node, pitch, roll, yaw, Math.max(0.0, tag.getDouble("Throttle")), tag.getBoolean(AfterburnerToggle.TAG));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
* motion in each substep until the next tick arrives.
|
||||
* <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.
|
||||
*/
|
||||
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 VELOCITY_EPSILON = 0.05;
|
||||
private static final double ANGULAR_EPSILON = 0.005;
|
||||
// Time constant of the smoothing of measured control rates
|
||||
private static final double RATE_SMOOTHING_SECONDS = 0.3;
|
||||
private static final ControlAxis[] MEASURED_AXES = {ControlAxis.PITCH, ControlAxis.YAW, ControlAxis.ROLL};
|
||||
|
||||
private final SubstepClock clock;
|
||||
|
||||
@@ -40,7 +43,12 @@ public final class AeroState implements PhysicsBridge.Publishable {
|
||||
private Frame current;
|
||||
private boolean[] stalled = new boolean[0];
|
||||
private boolean wingStalled;
|
||||
// Lift the wings could make at the edge of the stall in the airflow of the last substep, NaN without the improved
|
||||
// flight model, which has no stall
|
||||
private double maxLift = Double.NaN;
|
||||
private final Map<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 probe = new Wrench();
|
||||
|
||||
@@ -56,19 +64,48 @@ public final class AeroState implements PhysicsBridge.Publishable {
|
||||
double trimOutput;
|
||||
// Deflection of this node's flaps in radians, moves towards what the flap lever and airspeed allow
|
||||
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
|
||||
final Vector3d pitchResponse = 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();
|
||||
volatile double savedTrim;
|
||||
|
||||
Vector3d responseBuilding(ControlAxis axis) {
|
||||
return switch (axis) {
|
||||
case YAW -> yawResponseBuilding;
|
||||
case ROLL -> rollResponseBuilding;
|
||||
default -> pitchResponseBuilding;
|
||||
};
|
||||
}
|
||||
|
||||
NodeState(NodeData data) {
|
||||
pitch = data.pitch();
|
||||
yaw = data.yaw();
|
||||
roll = data.roll();
|
||||
trimIntegral = data.autoTrim() ? data.storedTrim() : 0.0;
|
||||
trimOutput = trimIntegral;
|
||||
trimOutput = data.autoTrim() ? 0.0 : trimIntegral;
|
||||
savedTrim = trimIntegral;
|
||||
computerOutput = pitch + trimOutput;
|
||||
flapAngle = Math.toRadians(FlapSetting.of(data.flapSetting()).degrees());
|
||||
}
|
||||
}
|
||||
@@ -134,9 +171,14 @@ public final class AeroState implements PhysicsBridge.Publishable {
|
||||
double dt = clock.substepSeconds();
|
||||
boolean improved = Config.IMPROVED_FLIGHT_MODEL.get();
|
||||
Matrix3dc inertia = ship.getMomentOfInertia();
|
||||
// Same frames and scaling as Warium applies its forces in
|
||||
double scale = transform.getShipToWorldScaling().x();
|
||||
double forceScale = autoScale ? scale * scale * scale : 1.0;
|
||||
double liftAcceleration = maxLift * forceScale / ship.getMass();
|
||||
|
||||
updateNodes(frame, worldVelocity, transform.getPositionInWorld().y(), rotation, inertia, w, dt, improved);
|
||||
updateNodes(frame, worldVelocity, transform.getPositionInWorld().y(), rotation, inertia, w, dt, improved, liftAcceleration);
|
||||
evaluate(frame, centerOfMass, v, w, improved, true, wrench);
|
||||
measureControlRates(frame, centerOfMass, v, w, improved, dt);
|
||||
|
||||
if (Config.IMPLICIT_DAMPING.get()) {
|
||||
// Implicit Euler for the damping part: scale each axis by 1 / (1 + dt * damping / inertia), using the
|
||||
@@ -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 forceTorque = new Vector3d(wrench.forceTorque[0], wrench.forceTorque[1], wrench.forceTorque[2]).mul(forceScale);
|
||||
if (force.isFinite() && forceTorque.isFinite()) {
|
||||
@@ -192,9 +231,15 @@ public final class AeroState implements PhysicsBridge.Publishable {
|
||||
for (int i = 0; i < stalled.length; i++) {
|
||||
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()) {
|
||||
nodeStates.computeIfAbsent(node.key(), key -> new NodeState(node));
|
||||
ALL_NODE_STATES.put(node.key(), nodeStates.computeIfAbsent(node.key(), key -> new NodeState(node)));
|
||||
}
|
||||
current = frame;
|
||||
}
|
||||
@@ -212,8 +257,11 @@ public final class AeroState implements PhysicsBridge.Publishable {
|
||||
double[] r = new double[3];
|
||||
if (commit) {
|
||||
wingStalled = false;
|
||||
maxLift = improved ? 0.0 : Double.NaN;
|
||||
for (NodeState state : nodeStates.values()) {
|
||||
state.pitchResponseBuilding.zero();
|
||||
state.yawResponseBuilding.zero();
|
||||
state.rollResponseBuilding.zero();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +272,7 @@ public final class AeroState implements PhysicsBridge.Publishable {
|
||||
r[2] = s.z() - centerOfMass.z();
|
||||
NodeData node = s.node() != AeroSurface.NO_NODE ? frame.nodes.get(s.node()) : 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;
|
||||
|
||||
if (improved) {
|
||||
@@ -232,76 +280,125 @@ public final class AeroState implements PhysicsBridge.Publishable {
|
||||
if (commit) {
|
||||
stalled[i] = nowStalled;
|
||||
wingStalled |= nowStalled && s.type() == SurfaceType.WING;
|
||||
if (s.type() == SurfaceType.WING || s.type() == SurfaceType.CONTROL_SURFACE && s.wingGroup()) {
|
||||
maxLift += ImprovedAero.maxLift(s, v, w, r, flap, weight, tuning);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
WariumAero.evaluate(s, v, w, r, input, weight, out);
|
||||
}
|
||||
|
||||
// How much a little more pitch input on this node would change the torque, for auto trim
|
||||
if (commit && node != null && node.autoTrim() && s.type().controllable && s.listensToPitch()) {
|
||||
double delta = 0.05;
|
||||
probe.clear();
|
||||
double base = 0.0;
|
||||
if (improved) {
|
||||
ImprovedAero.evaluate(s, v, w, r, input, flap, weight, tuning, stalled[i], probe);
|
||||
} else {
|
||||
WariumAero.evaluate(s, v, w, r, input, weight, probe);
|
||||
// How much a little more input on an axis would change the torque: pitch for auto trim, all three for
|
||||
// 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;
|
||||
probe.clear();
|
||||
if (improved) {
|
||||
ImprovedAero.evaluate(s, v, w, r, input, flap, weight, tuning, stalled[i], probe);
|
||||
} else {
|
||||
WariumAero.evaluate(s, v, w, r, input, weight, probe);
|
||||
}
|
||||
double[] before = {probe.totalTorque(0), probe.totalTorque(1), probe.totalTorque(2)};
|
||||
probe.clear();
|
||||
double nudged = input(s, node, state, axis, delta);
|
||||
if (improved) {
|
||||
ImprovedAero.evaluate(s, v, w, r, nudged, flap, weight, tuning, stalled[i], probe);
|
||||
} else {
|
||||
WariumAero.evaluate(s, v, w, r, nudged, weight, probe);
|
||||
}
|
||||
state.responseBuilding(axis).add(
|
||||
(probe.totalTorque(0) - before[0]) / delta,
|
||||
(probe.totalTorque(1) - before[1]) / delta,
|
||||
(probe.totalTorque(2) - before[2]) / delta);
|
||||
}
|
||||
double[] before = {probe.totalTorque(0), probe.totalTorque(1), probe.totalTorque(2)};
|
||||
probe.clear();
|
||||
double nudged = input(s, node, state, delta);
|
||||
if (improved) {
|
||||
ImprovedAero.evaluate(s, v, w, r, nudged, flap, weight, tuning, stalled[i], probe);
|
||||
} else {
|
||||
WariumAero.evaluate(s, v, w, r, nudged, weight, probe);
|
||||
}
|
||||
state.pitchResponseBuilding.add(
|
||||
(probe.totalTorque(0) - before[0]) / delta,
|
||||
(probe.totalTorque(1) - before[1]) / delta,
|
||||
(probe.totalTorque(2) - before[2]) / delta);
|
||||
}
|
||||
}
|
||||
|
||||
if (commit) {
|
||||
for (NodeState state : nodeStates.values()) {
|
||||
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. */
|
||||
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) {
|
||||
return 0.0;
|
||||
}
|
||||
double value = axisValue(s.axis1(), node, state, extraPitch) * s.multiplier1()
|
||||
+ axisValue(s.axis2(), node, state, extraPitch) * s.multiplier2();
|
||||
double value = axisValue(s.axis1(), node, state, nudged, extra) * s.multiplier1()
|
||||
+ axisValue(s.axis2(), node, state, nudged, extra) * s.multiplier2();
|
||||
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) {
|
||||
case PITCH -> state.pitch + state.trimOutput + extraPitch;
|
||||
case YAW -> state.yaw;
|
||||
case ROLL -> state.roll;
|
||||
case PITCH -> state.pitch + state.trimOutput + nudge;
|
||||
case YAW -> state.yaw + nudge;
|
||||
case ROLL -> state.roll + nudge;
|
||||
case THROTTLE -> node.throttle();
|
||||
case NONE -> 0.0;
|
||||
};
|
||||
}
|
||||
|
||||
private void updateNodes(Frame frame, Vector3dc worldVelocity, double altitude, Quaterniondc rotation, Matrix3dc inertia,
|
||||
double[] w, double dt, boolean improved) {
|
||||
double[] w, double dt, boolean improved, double liftAcceleration) {
|
||||
double step = Config.CONTROL_SMOOTHING.get() ? INPUT_RATE_PER_TICK * clock.weight() : Double.POSITIVE_INFINITY;
|
||||
// Torque of the last substep, in ship axes, which the flight computer separates from its own surfaces' share
|
||||
double[] torque = {wrench.totalTorque(0), wrench.totalTorque(1), wrench.totalTorque(2)};
|
||||
for (NodeData node : frame.nodes.values()) {
|
||||
NodeState state = nodeStates.get(node.key());
|
||||
if (state == null) {
|
||||
continue;
|
||||
}
|
||||
state.pitch = moveTowards(state.pitch, node.pitch(), step);
|
||||
state.yaw = moveTowards(state.yaw, node.yaw(), step);
|
||||
state.roll = moveTowards(state.roll, node.roll(), step);
|
||||
// Cursor aim, if the pilot has it on, supplies the inputs the pilot is not giving with the keys
|
||||
NodeData effective = CursorPilot.apply(state, node, rotation, inertia, w, worldVelocity.length(), wingStalled,
|
||||
liftAcceleration, 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);
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
if (Math.abs(target - value) <= maxStep) {
|
||||
return target;
|
||||
|
||||
@@ -37,6 +37,10 @@ public record AeroSurface(long key, SurfaceType type, boolean slab, double x, do
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Auto trim for the pitch control surfaces of one control node: while the pilot gives no pitch input it holds the
|
||||
* altitude the craft had when the pilot let go, cancelling whatever pitching moment the craft has (off-center weight,
|
||||
* thrust line, speed changes).
|
||||
* The flight computer of one control node, switched on and off with the auto trim key. It is a fly-by-wire pitch
|
||||
* system: the pilot's pitch input is a pitch rate command, not a surface deflection, and the computer moves the
|
||||
* surfaces to make the craft follow it, whatever the airframe does on its own.
|
||||
* <p>
|
||||
* Three nested loops: altitude error sets a gentle climb rate, climb rate error sets a nose attitude (never more than
|
||||
* {@link #MAX_ATTITUDE} from level), attitude error sets a pitch rate, and a PI controller moves the trim to reach
|
||||
* that rate. Bounding the attitude matters: in Warium's 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.
|
||||
* Every physics substep it measures the pitching torque the craft makes with the pitch surfaces taken out
|
||||
* ({@code external}: weight offset, thrust line, and above all the airframe's own stability or instability), and the
|
||||
* torque one unit of pitch input makes ({@code response}). It then picks the input that makes the craft's total
|
||||
* 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>
|
||||
* Attitude is measured from the ship's own up axis, so it assumes the craft is built level, as Warium craft are. The
|
||||
* trim's effect is measured every substep (torque per unit of pitch input), which gives the pitch axis and lets the
|
||||
* inner gains be expressed as angular acceleration, so the same tuning works for any craft, speed and altitude.
|
||||
* The trim holds still while the pilot pitches, when too slow, and when wings stall, so it never fights maneuvers.
|
||||
* Without pilot input the rate command comes from three nested loops that hold the altitude the craft had when the
|
||||
* pilot let go: altitude error sets a gentle climb rate, climb rate error sets a nose attitude (never more than
|
||||
* {@link #MAX_ATTITUDE} from level), attitude error sets a pitch rate. Bounding the attitude matters: in Warium's
|
||||
* 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 {
|
||||
// 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
|
||||
private static final double ATTITUDE_GAIN = 1.0;
|
||||
private static final double MAX_PITCH_RATE = 0.15;
|
||||
// Angular acceleration per rad/s of pitch rate error, and integral gain
|
||||
private static final double PROPORTIONAL_GAIN = 3.0;
|
||||
private static final double INTEGRAL_GAIN = 1.5;
|
||||
// Fastest the trim moves, in input per second
|
||||
// Slow trim movement when the computer is switched off
|
||||
private static final double TRIM_RATE = 1.0;
|
||||
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() {
|
||||
}
|
||||
@@ -49,57 +62,83 @@ final class AutoTrim {
|
||||
}
|
||||
|
||||
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;
|
||||
boolean aimed = CursorAimRegistry.isActive(node.key());
|
||||
if (!node.autoTrim()) {
|
||||
state.trimIntegral = 0.0;
|
||||
state.trimOutput = AeroState.moveTowards(state.trimOutput, 0.0, TRIM_RATE * dt);
|
||||
state.savedTrim = 0.0;
|
||||
state.computerOutput = state.pitch + state.trimOutput;
|
||||
targets.altitude = Double.NaN;
|
||||
targets.attitudeIntegral = 0.0;
|
||||
return;
|
||||
}
|
||||
double authority = Config.TRIM_AUTHORITY.get();
|
||||
double response = state.pitchResponse.length();
|
||||
double speed = worldVelocity.length();
|
||||
boolean hold = Math.abs(node.pitch()) > PILOT_DEADBAND || speed < Config.TRIM_MIN_SPEED.get() || stalled
|
||||
|| response < 1.0E-6;
|
||||
if (hold || Double.isNaN(targets.altitude)) {
|
||||
if (aimed || speed < Config.TRIM_MIN_SPEED.get() || response < 1.0E-6) {
|
||||
// Cursor aim, no airflow or no pitch surfaces: the computer holds what it has
|
||||
targets.altitude = Double.NaN;
|
||||
targets.attitudeIntegral = 0.0;
|
||||
state.computerOutput = state.pitch + state.trimOutput;
|
||||
state.savedTrim = state.trimIntegral;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pitch axis in ship axes, oriented so that positive rotation is what more pitch input does
|
||||
Vector3d axis = state.pitchResponse.div(response, new Vector3d());
|
||||
double axisInertia = axis.dot(inertia.transform(axis, new Vector3d()));
|
||||
double pitchRate = axis.x * w[0] + axis.y * w[1] + axis.z * w[2];
|
||||
|
||||
// Does rotating about that axis raise the nose? Sign of the vertical part of axis x flight direction
|
||||
Vector3d worldAxis = rotation.transform(axis, new Vector3d());
|
||||
Vector3d direction = worldVelocity.normalize(new Vector3d());
|
||||
double noseUp = Math.signum(worldAxis.cross(direction, new Vector3d()).y);
|
||||
double attitude = attitude(axis, rotation);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
double target = state.trimIntegral;
|
||||
if (!hold) {
|
||||
// Pitch axis in ship axes, oriented so that positive rotation is what more pitch input does
|
||||
Vector3d axis = state.pitchResponse.div(response, new Vector3d());
|
||||
double axisInertia = axis.dot(inertia.transform(axis, new Vector3d()));
|
||||
double pitchRate = axis.x * w[0] + axis.y * w[1] + axis.z * w[2];
|
||||
|
||||
// Does rotating about that axis raise the nose? Sign of the vertical part of axis x flight direction
|
||||
Vector3d worldAxis = rotation.transform(axis, new Vector3d());
|
||||
Vector3d direction = worldVelocity.normalize(new Vector3d());
|
||||
double noseUp = Math.signum(worldAxis.cross(direction, new Vector3d()).y);
|
||||
double attitude = attitude(axis, rotation);
|
||||
if (noseUp == 0.0 || Double.isNaN(attitude)) {
|
||||
return;
|
||||
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 climbError = climbTarget - worldVelocity.y();
|
||||
targets.attitudeIntegral = clamp(targets.attitudeIntegral + CLIMB_INTEGRAL_GAIN * climbError * dt, MAX_ATTITUDE);
|
||||
// Nose elevation wanted, turned into rotation about the axis
|
||||
double attitudeTarget = noseUp * clamp(targets.attitudeIntegral + CLIMB_GAIN * climbError, MAX_ATTITUDE);
|
||||
double rateTarget = 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);
|
||||
rateCommand = clamp(ATTITUDE_GAIN * (attitudeTarget - attitude), MAX_PITCH_RATE);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// in a wing's group stall with the wing instead of before it
|
||||
double vortexFactor = tuning.vortex() && (s.type() == SurfaceType.WING || s.wingGroup() && s.type().controllable)
|
||||
? VortexLift.factor(s.aspectRatio()) : 0.0;
|
||||
double baseStall = s.slab() ? SurfaceType.SLAB_STALL_ANGLE
|
||||
: s.wingGroup() && s.type().controllable ? SurfaceType.WING.stallAngle : s.type().stallAngle;
|
||||
double critical = Math.toRadians(baseStall) * (1.0 + tuning.vortexStallBonus() * vortexFactor) * tuning.stallAngleScale();
|
||||
double vortexFactor = vortexFactor(s, tuning);
|
||||
double critical = criticalAngle(s, tuning, vortexFactor);
|
||||
double absAlpha = Math.abs(alpha);
|
||||
if (alpha >= 0.0) {
|
||||
critical += FLAP_STALL_GAIN * flapShift;
|
||||
@@ -149,9 +146,8 @@ final class ImprovedAero {
|
||||
|
||||
// Finite wing theory: lift slope and induced drag follow from the wing's aspect ratio
|
||||
double aspectRatio = s.aspectRatio();
|
||||
double slope = aspectRatio > 0.0 ? 2.0 * Math.PI / (1.0 + 2.0 / aspectRatio) : LIFT_SLOPE;
|
||||
double inducedDrag = aspectRatio > 0.0 ? 1.0 / (Math.PI * EFFICIENCY * aspectRatio) : INDUCED_DRAG;
|
||||
double attachedLift = slope * alpha;
|
||||
double attachedLift = liftSlope(s) * alpha;
|
||||
if (s.type() == SurfaceType.WING) {
|
||||
attachedLift += VortexLift.liftCoefficient(alpha, vortexFactor, tuning.vortexLiftStrength());
|
||||
}
|
||||
@@ -175,6 +171,44 @@ final class ImprovedAero {
|
||||
return stalled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift (along the surface's normal) the surface would make in the airflow it sees now at the highest angle of
|
||||
* attack it can fly without starting to stall, flaps included. Only meaningful for horizontal surfaces.
|
||||
*/
|
||||
static double maxLift(AeroSurface s, double[] v, double[] w, double[] r, double flap, double weight, Tuning tuning) {
|
||||
double vx = v[0] + w[1] * r[2] - w[2] * r[1];
|
||||
double vy = v[1] + w[2] * r[0] - w[0] * r[2];
|
||||
double vz = v[2] + w[0] * r[1] - w[1] * r[0];
|
||||
double u = vx * s.facingX() + vz * s.facingZ();
|
||||
double planeSpeedSq = u * u + vy * vy;
|
||||
double vortexFactor = vortexFactor(s, tuning);
|
||||
double flapShift = FLAP_ALPHA_FACTOR * flap;
|
||||
double alpha = criticalAngle(s, tuning, vortexFactor) + FLAP_STALL_GAIN * flapShift - STALL_BLEND;
|
||||
double lift = liftSlope(s) * alpha;
|
||||
if (s.type() == SurfaceType.WING) {
|
||||
lift += VortexLift.liftCoefficient(alpha, vortexFactor, tuning.vortexLiftStrength());
|
||||
}
|
||||
return LIFT_CONSTANT * s.pressure() * weight * s.fluidFactor() * planeSpeedSq * lift * tuning.liftScale();
|
||||
}
|
||||
|
||||
private static double vortexFactor(AeroSurface s, Tuning tuning) {
|
||||
return tuning.vortex() && (s.type() == SurfaceType.WING || s.wingGroup() && s.type().controllable)
|
||||
? VortexLift.factor(s.aspectRatio()) : 0.0;
|
||||
}
|
||||
|
||||
/** Angle of attack (radians, camber and flaps included) at which the surface starts to stall, without flaps. */
|
||||
private static double criticalAngle(AeroSurface s, Tuning tuning, double vortexFactor) {
|
||||
double baseStall = s.slab() ? SurfaceType.SLAB_STALL_ANGLE
|
||||
: s.wingGroup() && s.type().controllable ? SurfaceType.WING.stallAngle : s.type().stallAngle;
|
||||
return Math.toRadians(baseStall) * (1.0 + tuning.vortexStallBonus() * vortexFactor) * tuning.stallAngleScale();
|
||||
}
|
||||
|
||||
/** Lift slope per radian from finite wing theory, from the wing's aspect ratio. */
|
||||
private static double liftSlope(AeroSurface s) {
|
||||
double aspectRatio = s.aspectRatio();
|
||||
return aspectRatio > 0.0 ? 2.0 * Math.PI / (1.0 + 2.0 / aspectRatio) : LIFT_SLOPE;
|
||||
}
|
||||
|
||||
static double smoothstep(double edge0, double edge1, double x) {
|
||||
double t = Math.max(0.0, Math.min(1.0, (x - edge0) / (edge1 - edge0)));
|
||||
return t * t * (3.0 - 2.0 * t);
|
||||
|
||||
@@ -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 futurism;
|
||||
private boolean geckolib;
|
||||
|
||||
@Override
|
||||
public void onLoad(String mixinPackage) {
|
||||
warium = supported("valkyrien_warium", WARIUM_VERSION, "Warium VS");
|
||||
futurism = supported("futurism", FUTURISM_VERSION, "Futurism");
|
||||
geckolib = LoadingModList.get().getModFileById("geckolib") != null;
|
||||
}
|
||||
|
||||
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.")) {
|
||||
return warium;
|
||||
}
|
||||
if (mixinClassName.contains(".mixin.gecko.")) {
|
||||
// Animates Warium's control surfaces, which are GeckoLib models
|
||||
return warium && geckolib;
|
||||
}
|
||||
if (mixinClassName.contains(".mixin.futurism.")) {
|
||||
return futurism;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ public final class WariumCapture {
|
||||
world = level;
|
||||
pos = BlockPos.containing(x, y, z);
|
||||
state = blockState;
|
||||
if (surfaceType.controllable && level instanceof ServerLevel serverLevel && Config.PHYSICS_RATE_AERODYNAMICS.get()) {
|
||||
DeflectionSync.report(serverLevel, pos, surfaceType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+55
@@ -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)
|
||||
.consumerMainThread(ExhaustPacket::handle)
|
||||
.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
|
||||
CHANNEL.messageBuilder(FlapPacket.class, 10, NetworkDirection.PLAY_TO_SERVER)
|
||||
.encoder(FlapPacket::encode)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"key.categories.improved_aerodynamics": "Improved Aerodynamics",
|
||||
"key.improved_aerodynamics.toggle_auto_trim": "Automatische Trimmung umschalten",
|
||||
"message.improved_aerodynamics.auto_trim.on": "Automatische Trimmung an",
|
||||
"message.improved_aerodynamics.auto_trim.off": "Automatische Trimmung aus",
|
||||
"message.improved_aerodynamics.auto_trim.on": "Flugcomputer an (automatische Trimmung)",
|
||||
"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.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",
|
||||
@@ -23,6 +23,11 @@
|
||||
"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_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_up": "Klappen rauf (eine Stufe)",
|
||||
"message.improved_aerodynamics.flaps": "Klappen: %s",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"key.categories.improved_aerodynamics": "Improved Aerodynamics",
|
||||
"key.improved_aerodynamics.toggle_auto_trim": "Toggle auto trim",
|
||||
"message.improved_aerodynamics.auto_trim.on": "Auto trim on",
|
||||
"message.improved_aerodynamics.auto_trim.off": "Auto trim off",
|
||||
"message.improved_aerodynamics.auto_trim.on": "Flight computer on (auto trim)",
|
||||
"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.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",
|
||||
@@ -23,6 +23,11 @@
|
||||
"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_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_up": "Flaps up (one step)",
|
||||
"message.improved_aerodynamics.flaps": "Flaps: %s",
|
||||
|
||||
@@ -216,6 +216,18 @@
|
||||
{
|
||||
"id": "valkyrien_warium:green_control_surface_offset_top",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"id": "warframes:airframe_control_surface",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"id": "warframes:airframe_half_control_surface",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"id": "warframes:airframe_stepped_control_surface",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
"compatibilityLevel": "JAVA_17",
|
||||
"mixins": [
|
||||
"futurism.ForceInducedShipsMixin",
|
||||
"futurism.VectoredAfterburnerSystemProcedureMixin",
|
||||
"gecko.GeoModelMixin",
|
||||
"warium.AirBrakeTickProcedureMixin",
|
||||
"warium.BypassedCompressorScriptProcedureMixin",
|
||||
"warium.ControlSurfaceTickProcedureMixin",
|
||||
|
||||
Reference in new issue
Block a user