Animate control surfaces and flaps from the real deflection

A GeckoLib GeoModel mixin sets the Fin bone of Warium's control surfaces to the
deflection the flight physics uses, synced to nearby clients per ship.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVREx3uo7nz1FEhzveWTUh
This commit is contained in:
Claude committed 2026-11-11 18:44:58 +01:00
1 parent 782df70a69
commit 23f0f91ede
12 files changed
+356 -2

No files matched your search

+4
View File
@@ -84,6 +84,10 @@ dependencies {
compileOnly fg.deobf("blank:WariumVS:1.1.7") compileOnly fg.deobf("blank:WariumVS:1.1.7")
compileOnly fg.deobf("blank:futurism:0.0.4") compileOnly fg.deobf("blank:futurism:0.0.4")
// GeckoLib 4.8.4, which Warium VS animates its control surfaces with (optional dependency, remapped, the surface animation mixin
// only applies when it and Warium VS are present)
compileOnly fg.deobf("maven.modrinth:geckolib:aC5KMoNg")
// AzureLib 3.2.7 for the animated block models (optional dependency, the common jar uses obfuscated names, so it is remapped) // AzureLib 3.2.7 for the animated block models (optional dependency, the common jar uses obfuscated names, so it is remapped)
compileOnly fg.deobf("mod.azure.azurelib:azurelib-common-${minecraft_version}:${azurelib_version}") compileOnly fg.deobf("mod.azure.azurelib:azurelib-common-${minecraft_version}:${azurelib_version}")
} }
@@ -220,6 +220,11 @@ public final class Config {
.comment("Vectored afterburner nozzle: afterburner ignition and heavy afterburner sound") .comment("Vectored afterburner nozzle: afterburner ignition and heavy afterburner sound")
.define("soundEnabled", true); .define("soundEnabled", true);
public static final ForgeConfigSpec.BooleanValue ANIMATE_SURFACES = CLIENT_BUILDER
.comment("Control surfaces and flaps visibly deflect by as much as the flight physics deflects them, instead of Warium's",
"fixed 20 degree steps. Needs physicsRateAerodynamics on the server")
.define("animateControlSurfaces", true);
static final ForgeConfigSpec CLIENT_SPEC = CLIENT_BUILDER.build(); static final ForgeConfigSpec CLIENT_SPEC = CLIENT_BUILDER.build();
private Config() { private Config() {
@@ -29,6 +29,7 @@ public class ImprovedAerodynamics {
MinecraftForge.EVENT_BUS.register(ExhaustSync.class); MinecraftForge.EVENT_BUS.register(ExhaustSync.class);
MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.ControlMode.class); MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.ControlMode.class);
MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.FlapInteraction.class); MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.FlapInteraction.class);
MinecraftForge.EVENT_BUS.register(dev.lumareth.improvedAerodynamics.flight.DeflectionSync.class);
ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC); ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC);
ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, Config.CLIENT_SPEC); ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, Config.CLIENT_SPEC);
// Own file name: the default one is taken by Config.CLIENT_SPEC // Own file name: the default one is taken by Config.CLIENT_SPEC
@@ -0,0 +1,84 @@
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 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.
*/
@Mod.EventBusSubscriber(modid = dev.lumareth.improvedAerodynamics.ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT)
public final class SurfaceAnimation {
private static final String BONE = "Fin";
// 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<>();
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 (ENTRIES.isEmpty() || !Config.ANIMATE_SURFACES.get()) {
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;
}
// The same block can be drawn more than once a frame (shadows, other passes), the easing steps once per frame
if (entry.drawn != 0L && now != entry.drawn) {
double dt = (now - entry.drawn) / 1.0E9;
entry.shown += (entry.target - entry.shown) * (float) (1.0 - Math.exp(-dt / EASE_SECONDS));
}
entry.drawn = now;
CoreGeoBone bone = model.getAnimationProcessor().getBone(BONE);
if (bone != null) {
bone.setRotX((float) -Math.toRadians(entry.shown));
}
}
@SubscribeEvent
public static void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) {
ENTRIES.clear();
}
}
@@ -42,6 +42,8 @@ public final class AeroState implements PhysicsBridge.Publishable {
private boolean[] stalled = new boolean[0]; private boolean[] stalled = new boolean[0];
private boolean wingStalled; private boolean wingStalled;
private final Map<Long, NodeState> nodeStates = new ConcurrentHashMap<>(); private final Map<Long, NodeState> nodeStates = new ConcurrentHashMap<>();
// Every ship's node states by node position, for the control surface animation on the server thread
private static final Map<Long, NodeState> ALL_NODE_STATES = new ConcurrentHashMap<>();
private final Wrench wrench = new Wrench(); private final Wrench wrench = new Wrench();
private final Wrench probe = new Wrench(); private final Wrench probe = new Wrench();
@@ -57,6 +59,8 @@ public final class AeroState implements PhysicsBridge.Publishable {
double trimOutput; double trimOutput;
// Deflection of this node's flaps in radians, moves towards what the flap lever and airspeed allow // Deflection of this node's flaps in radians, moves towards what the flap lever and airspeed allow
double flapAngle; double flapAngle;
// Wall clock time of the last physics substep that ran this node, to tell whether its ship is flying
volatile long updatedMillis;
// Total pitch input the flight computer applied last substep, pilot and trim together // Total pitch input the flight computer applied last substep, pilot and trim together
double computerOutput; double computerOutput;
// Torque (ship axes) one unit of pitch input on this node's pitch surfaces makes, measured every substep // Torque (ship axes) one unit of pitch input on this node's pitch surfaces makes, measured every substep
@@ -209,9 +213,15 @@ public final class AeroState implements PhysicsBridge.Publishable {
for (int i = 0; i < stalled.length; i++) { for (int i = 0; i < stalled.length; i++) {
stalled[i] = previous.get(frame.surfaces[i].key()); stalled[i] = previous.get(frame.surfaces[i].key());
} }
nodeStates.keySet().removeIf(key -> !frame.nodes.containsKey((long) key)); nodeStates.keySet().removeIf(key -> {
boolean gone = !frame.nodes.containsKey((long) key);
if (gone) {
ALL_NODE_STATES.remove(key);
}
return gone;
});
for (NodeData node : frame.nodes.values()) { for (NodeData node : frame.nodes.values()) {
nodeStates.computeIfAbsent(node.key(), key -> new NodeState(node)); ALL_NODE_STATES.put(node.key(), nodeStates.computeIfAbsent(node.key(), key -> new NodeState(node)));
} }
current = frame; current = frame;
} }
@@ -333,6 +343,7 @@ public final class AeroState implements PhysicsBridge.Publishable {
state.yaw = moveTowards(state.yaw, effective.yaw(), step); state.yaw = moveTowards(state.yaw, effective.yaw(), step);
state.roll = moveTowards(state.roll, effective.roll(), step); state.roll = moveTowards(state.roll, effective.roll(), step);
updateFlaps(state, node, worldVelocity.length(), dt); updateFlaps(state, node, worldVelocity.length(), dt);
state.updatedMillis = System.currentTimeMillis();
AutoTrim.update(state, effective, worldVelocity, altitude, rotation, inertia, w, torque, dt, improved && wingStalled); AutoTrim.update(state, effective, worldVelocity, altitude, rotation, inertia, w, torque, dt, improved && wingStalled);
} }
} }
@@ -351,6 +362,11 @@ public final class AeroState implements PhysicsBridge.Publishable {
state.flapAngle = moveTowards(state.flapAngle, target, Math.toRadians(Config.FLAP_SLEW_DEGREES.get()) * dt); state.flapAngle = moveTowards(state.flapAngle, target, Math.toRadians(Config.FLAP_SLEW_DEGREES.get()) * dt);
} }
/** The physics state of a control node, null if no physics runs for it. */
static NodeState nodeState(long nodeKey) {
return ALL_NODE_STATES.get(nodeKey);
}
static double moveTowards(double value, double target, double maxStep) { static double moveTowards(double value, double target, double maxStep) {
if (Math.abs(target - value) <= maxStep) { if (Math.abs(target - value) <= maxStep) {
return target; return target;
@@ -0,0 +1,154 @@
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;
// A node state the physics thread has not touched for this long is not flying any more
private static final long FRESH_MILLIS = 500;
private record Batch(ServerLevel level, double x, double y, double z, LongArrayList positions, ShortArrayList tenths) {
}
private 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 = AeroState.nodeState(nodeKey);
boolean flying = state != null && System.currentTimeMillis() - state.updatedMillis < FRESH_MILLIS;
double input = axis(tag.getString("Key"), nodeTag, state, flying) + axis(tag.getString("Key2"), nodeTag, state, flying);
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-". */
private static double axis(String key, CompoundTag nodeTag, AeroState.NodeState state, boolean flying) {
ControlAxis axis = ControlAxis.of(key);
double multiplier = ControlAxis.multiplier(key);
return switch (axis) {
case PITCH -> (flying ? state.pitch + state.trimOutput : nodeTag.getDouble("Pitch")) * multiplier;
case YAW -> (flying ? state.yaw : nodeTag.getDouble("Yaw")) * multiplier;
case ROLL -> (flying ? state.roll : nodeTag.getDouble("Roll")) * multiplier;
default -> 0.0;
};
}
/** 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();
}
}
@@ -23,11 +23,13 @@ public final class MixinPlugin implements IMixinConfigPlugin {
private boolean warium; private boolean warium;
private boolean futurism; private boolean futurism;
private boolean geckolib;
@Override @Override
public void onLoad(String mixinPackage) { public void onLoad(String mixinPackage) {
warium = supported("valkyrien_warium", WARIUM_VERSION, "Warium VS"); warium = supported("valkyrien_warium", WARIUM_VERSION, "Warium VS");
futurism = supported("futurism", FUTURISM_VERSION, "Futurism"); futurism = supported("futurism", FUTURISM_VERSION, "Futurism");
geckolib = LoadingModList.get().getModFileById("geckolib") != null;
} }
private static boolean supported(String modId, String version, String name) { private static boolean supported(String modId, String version, String name) {
@@ -49,6 +51,10 @@ public final class MixinPlugin implements IMixinConfigPlugin {
if (mixinClassName.contains(".mixin.warium.")) { if (mixinClassName.contains(".mixin.warium.")) {
return warium; return warium;
} }
if (mixinClassName.contains(".mixin.gecko.")) {
// Animates Warium's control surfaces, which are GeckoLib models
return warium && geckolib;
}
if (mixinClassName.contains(".mixin.futurism.")) { if (mixinClassName.contains(".mixin.futurism.")) {
return futurism; return futurism;
} }
@@ -50,6 +50,9 @@ public final class WariumCapture {
world = level; world = level;
pos = BlockPos.containing(x, y, z); pos = BlockPos.containing(x, y, z);
state = blockState; state = blockState;
if (surfaceType.controllable && level instanceof ServerLevel serverLevel && Config.PHYSICS_RATE_AERODYNAMICS.get()) {
DeflectionSync.report(serverLevel, pos, surfaceType);
}
} }
/** /**
@@ -0,0 +1,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,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);
}
}
@@ -44,6 +44,11 @@ public final class Network {
.decoder(ControlModePacket::decode) .decoder(ControlModePacket::decode)
.consumerMainThread(ControlModePacket::handle) .consumerMainThread(ControlModePacket::handle)
.add(); .add();
CHANNEL.messageBuilder(DeflectionPacket.class, 11, NetworkDirection.PLAY_TO_CLIENT)
.encoder(DeflectionPacket::encode)
.decoder(DeflectionPacket::decode)
.consumerMainThread(DeflectionPacket::handle)
.add();
// Higher id than the neighbours so it does not collide with packets added on other branches // Higher id than the neighbours so it does not collide with packets added on other branches
CHANNEL.messageBuilder(FlapPacket.class, 10, NetworkDirection.PLAY_TO_SERVER) CHANNEL.messageBuilder(FlapPacket.class, 10, NetworkDirection.PLAY_TO_SERVER)
.encoder(FlapPacket::encode) .encoder(FlapPacket::encode)
@@ -6,6 +6,7 @@
"compatibilityLevel": "JAVA_17", "compatibilityLevel": "JAVA_17",
"mixins": [ "mixins": [
"futurism.ForceInducedShipsMixin", "futurism.ForceInducedShipsMixin",
"gecko.GeoModelMixin",
"warium.AirBrakeTickProcedureMixin", "warium.AirBrakeTickProcedureMixin",
"warium.BypassedCompressorScriptProcedureMixin", "warium.BypassedCompressorScriptProcedureMixin",
"warium.ControlSurfaceTickProcedureMixin", "warium.ControlSurfaceTickProcedureMixin",